[object Object]

← back to Kickbacks Ai Upstream

Sync extension v0.3.174 (f946fd8)

e92e85dd20153c245bfb0fb9a2176320d8a1974c · 2026-06-12 12:29:27 -0700 · Andrew McCalip

Files touched

Diff

commit e92e85dd20153c245bfb0fb9a2176320d8a1974c
Author: Andrew McCalip <andrewmccalip@gmail.com>
Date:   Fri Jun 12 12:29:27 2026 -0700

    Sync extension v0.3.174 (f946fd8)
---
 .gitignore                                   |    4 +
 .vscodeignore                                |   11 +
 LICENSE                                      |   98 +
 README.md                                    |  125 +
 esbuild.mjs                                  |  106 +
 media/icon.png                               |  Bin 0 -> 11281 bytes
 media/logo.svg                               |    5 +
 media/logos/apple-touch-icon.png             |  Bin 0 -> 7629 bytes
 media/logos/favicon-32.png                   |  Bin 0 -> 1280 bytes
 media/logos/favicon.ico                      |  Bin 0 -> 3812 bytes
 media/logos/favicon.svg                      |    5 +
 media/logos/icon-192.png                     |  Bin 0 -> 8071 bytes
 media/logos/icon-512.png                     |  Bin 0 -> 25550 bytes
 media/logos/kickbacks-128.png                |  Bin 0 -> 5413 bytes
 media/logos/kickbacks-16.png                 |  Bin 0 -> 635 bytes
 media/logos/kickbacks-180.png                |  Bin 0 -> 7629 bytes
 media/logos/kickbacks-192.png                |  Bin 0 -> 8071 bytes
 media/logos/kickbacks-256.png                |  Bin 0 -> 11281 bytes
 media/logos/kickbacks-32.png                 |  Bin 0 -> 1280 bytes
 media/logos/kickbacks-48.png                 |  Bin 0 -> 1843 bytes
 media/logos/kickbacks-512.png                |  Bin 0 -> 25550 bytes
 media/logos/kickbacks-64.png                 |  Bin 0 -> 2463 bytes
 media/logos/kickbacks-green.svg              |    3 +
 media/logos/kickbacks-lockup.png             |  Bin 0 -> 30675 bytes
 media/logos/kickbacks-white.svg              |    3 +
 media/logos/kickbacks.svg                    |    5 +
 media/logos/og-logo.png                      |  Bin 0 -> 25550 bytes
 package-lock.json                            | 5008 ++++++++++++++++++++++++++
 package.json                                 |  229 ++
 readme_extension.md                          |  142 +
 scripts/_brand.mjs                           |  147 +
 scripts/gen-icon.mjs                         |   16 +
 scripts/gen-logos.mjs                        |   50 +
 scripts/package.mjs                          |  115 +
 src/activation/adRotation.ts                 |  265 ++
 src/activation/bootCanary.ts                 |  121 +
 src/activation/capWarning.ts                 |   69 +
 src/activation/cliSync.ts                    |  224 ++
 src/activation/cliTick.ts                    |  153 +
 src/activation/codexFallback.ts              |   26 +
 src/activation/commands.ts                   |  123 +
 src/activation/context.ts                    |   37 +
 src/activation/desyncDetector.ts             |  122 +
 src/activation/diagnose.ts                   |  138 +
 src/activation/earningsRefresh.ts            |  163 +
 src/activation/incompatNotice.ts             |   36 +
 src/activation/outdatedCliNotice.ts          |   36 +
 src/activation/reloadNudge.ts                |   43 +
 src/activation/selfUpdate.ts                 |  185 +
 src/activation/statusBarAd.ts                |  262 ++
 src/activation/webviewInjection.ts           |  424 +++
 src/activity/logTail.ts                      |  153 +
 src/adapters/claude-cli/adapter.ts           |  258 ++
 src/adapters/claude-cli/cliAd.ts             |  114 +
 src/adapters/claude-cli/cliVersion.ts        |   92 +
 src/adapters/claude-cli/settingsEdit.ts      |  212 ++
 src/adapters/claude-cli/statusline.asset.mjs |   94 +
 src/adapters/claude-code/adapter.ts          |  454 +++
 src/adapters/claude-code/block.asset.js      | 1268 +++++++
 src/adapters/codex-cli/adapter.ts            |  160 +
 src/adapters/codex-cli/wrapper.cmd.asset     |   20 +
 src/adapters/codex-cli/wrapper.sh.asset      |   13 +
 src/adapters/codex/adapter.ts                |  297 ++
 src/adapters/codex/block.asset.js            |  558 +++
 src/adapters/registry.ts                     |  119 +
 src/adapters/types.ts                        |  106 +
 src/auth/client.ts                           |  419 +++
 src/auth/vault.ts                            |  174 +
 src/banner.ts                                |   12 +
 src/buildflags.ts                            |   46 +
 src/buildinfo.ts                             |   41 +
 src/config.ts                                |  117 +
 src/consent/client.ts                        |   62 +
 src/consent/prompt.ts                        |   73 +
 src/debug.ts                                 |  649 ++++
 src/earnings/client.ts                       |  113 +
 src/extension.ts                             |  880 +++++
 src/fleetSignals.ts                          |  152 +
 src/killswitch/client.ts                     |   58 +
 src/locate.ts                                |  175 +
 src/log.ts                                   |  267 ++
 src/loopback.ts                              |  348 ++
 src/metrics/client.ts                        |  155 +
 src/metrics/dedupe.ts                        |   16 +
 src/modes.ts                                 |   57 +
 src/portfolio/client.ts                      |  233 ++
 src/reassert.ts                              |   88 +
 src/reloadSignal.ts                          |   35 +
 src/servingGate.ts                           |  107 +
 src/sessionState.ts                          |   65 +
 src/statusbar.ts                             |  151 +
 src/testHooks.ts                             |  354 ++
 src/types/surface.ts                         |    5 +
 src/update/client.ts                         |  313 ++
 src/util/asset.ts                            |   10 +
 src/util/claudeCodeVersion.ts                |   31 +
 src/util/crypto.ts                           |    4 +
 src/util/errMsg.ts                           |    6 +
 src/util/http.ts                             |   32 +
 src/util/loopback.ts                         |   13 +
 src/util/loopbackBoot.ts                     |  105 +
 src/viewTracking/timer.ts                    |  226 ++
 test/adRotation-token-refresh.test.ts        |  233 ++
 test/adapter.test.ts                         |  447 +++
 test/attribution.test.ts                     |  318 ++
 test/audit-deferred-xfail.test.ts            |   84 +
 test/auth.test.ts                            |  620 ++++
 test/backup-safety.test.ts                   |  238 ++
 test/banner.test.ts                          |   19 +
 test/block.test.ts                           |  206 ++
 test/buildinfo.test.ts                       |   36 +
 test/bundled-codex-asset.test.ts             |   30 +
 test/capWarning.test.ts                      |   75 +
 test/cc-pollad.test.ts                       |  344 ++
 test/cc-spinner-detect.test.ts               |  169 +
 test/cc-viewtimer.test.ts                    |  235 ++
 test/claudeCodeVersion.test.ts               |   24 +
 test/cli-statusline.test.ts                  |  796 ++++
 test/cliTick.test.ts                         |  183 +
 test/click-telemetry.test.ts                 |  284 ++
 test/codex-adapter.test.ts                   |  261 ++
 test/codex-cli-adapter.test.ts               |  223 ++
 test/codex-findrow.test.ts                   |  189 +
 test/codex-gating.test.ts                    |   27 +
 test/codex-pollad.test.ts                    |  276 ++
 test/codex-viewtimer.test.ts                 |  153 +
 test/codexFallback.test.ts                   |   57 +
 test/commands.test.ts                        |  429 +++
 test/consent.test.ts                         |  133 +
 test/consentFlow.test.ts                     |  202 ++
 test/debug.test.ts                           |  419 +++
 test/dedupe.test.ts                          |   34 +
 test/diagnose.test.ts                        |  124 +
 test/e2e.test.ts                             |  266 ++
 test/earnings.test.ts                        |  100 +
 test/earningsRefresh.test.ts                 |  134 +
 test/extension-lifecycle.test.ts             |  214 ++
 test/extension.test.ts                       |  367 ++
 test/fixtures/synthetic-index.js             |    1 +
 test/fixtures/synthetic-thinking-shimmer.js  |   32 +
 test/fleetSignals.test.ts                    |  340 ++
 test/honest-incompatible.test.ts             |   20 +
 test/hooksE2E.test.ts                        |  297 ++
 test/icon-render.test.ts                     |  109 +
 test/incident-guards.test.ts                 |   92 +
 test/incompatNotice.test.ts                  |   56 +
 test/insidersLocate.test.ts                  |   74 +
 test/killEnforcement.test.ts                 |  260 ++
 test/killswitch.test.ts                      |   40 +
 test/killswitchClient.test.ts                |   85 +
 test/localVsixWatcher.test.ts                |  205 ++
 test/locateLog.test.ts                       |  177 +
 test/log.test.ts                             |  168 +
 test/logTail.test.ts                         |  189 +
 test/loopback.test.ts                        |  240 ++
 test/loopbackSingleton.test.ts               |  138 +
 test/loopbackTest.test.ts                    |  233 ++
 test/manifestSigning.test.ts                 |   70 +
 test/metrics.test.ts                         |  225 ++
 test/mocks/vscode.ts                         |  101 +
 test/modes.test.ts                           |   71 +
 test/outdatedCliNotice.test.ts               |   55 +
 test/portfolio.test.ts                       |  200 +
 test/reassert.test.ts                        |  102 +
 test/registry.test.ts                        |   56 +
 test/reloadNudge.test.ts                     |   66 +
 test/reloadSignal.test.ts                    |   41 +
 test/rotation.test.ts                        |  409 +++
 test/selfUpdateWiring.test.ts                |  107 +
 test/servingGate.test.ts                     |  600 +++
 test/servingRetry.test.ts                    |  159 +
 test/setup.ts                                |   24 +
 test/smoke.test.ts                           |   60 +
 test/statusBarAd.test.ts                     |  514 +++
 test/statusbar.test.ts                       |  118 +
 test/statusbarReloadLock.test.ts             |   64 +
 test/testHooks.test.ts                       |  337 ++
 test/untaggedTickIntegration.test.ts         |  108 +
 test/update-cooldown.test.ts                 |  159 +
 test/update.test.ts                          |  245 ++
 test/vault.test.ts                           |  126 +
 test/viewTimer.test.ts                       |  234 ++
 test/webviewInjection-dualtarget.test.ts     |  188 +
 tsconfig.json                                |   14 +
 vitest.config.ts                             |    7 +
 185 files changed, 33174 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b5f78df
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+dist/
+.vsce-stage/
+*.vsix
diff --git a/.vscodeignore b/.vscodeignore
new file mode 100644
index 0000000..c4c0d12
--- /dev/null
+++ b/.vscodeignore
@@ -0,0 +1,11 @@
+test/**
+src/**
+*.ts
+tsconfig.json
+esbuild.mjs
+vitest.config.ts
+.gitignore
+**/*.map
+readme_extension.md
+S3_FINDINGS.md
+media/logo.svg
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..778f903
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,98 @@
+Kickbacks Extension — Proprietary, Source-Available License
+Copyright (c) 2026 ShiftKeys, Inc. All rights reserved.
+
+This is NOT open-source software. The Kickbacks extension, including its source
+code, object code, design, structure, and accompanying materials (the
+"Software"), is the proprietary property of ShiftKeys, Inc. ("ShiftKeys"). The
+Software is licensed, not sold, and is protected by copyright and other
+intellectual property laws. ShiftKeys makes the source publicly viewable for
+transparency only; doing so waives no rights and grants no license except as
+expressly stated below.
+
+1. RESERVATION OF RIGHTS
+   All right, title, and interest in and to the Software — including all
+   intellectual property rights — are and remain the exclusive property of
+   ShiftKeys. No rights are granted except as expressly stated here, and none
+   arise by implication, estoppel, or otherwise.
+
+2. SOURCE-VIEWING PERMISSION
+   ShiftKeys grants you a limited, personal, non-exclusive, non-transferable,
+   non-sublicensable, revocable permission to view and read the published source
+   code, and to fork it on GitHub only to the extent required by GitHub's
+   platform features to view the code. This permission does not include any right
+   to use, run, modify, or redistribute the Software.
+
+3. LIMITED END-USER LICENSE
+   Separately, and solely for end users who obtain the Software through an
+   official distribution channel authorized by ShiftKeys (for example, the Visual
+   Studio Code Marketplace listing or an official build published by ShiftKeys),
+   ShiftKeys grants a personal, non-exclusive, non-transferable,
+   non-sublicensable, revocable license to install and run the Software as
+   distributed, in unmodified form, for its intended purpose. This license does
+   not permit redistribution, modification, or any commercial exploitation of the
+   Software.
+
+4. RESTRICTIONS
+   Except as expressly permitted above or by ShiftKeys in a signed writing, you
+   may not, and may not permit any third party to:
+     (a) copy, reproduce, publish, distribute, sell, rent, lease, sublicense, or
+         otherwise make the Software available to any third party;
+     (b) modify, adapt, translate, or create derivative works of the Software;
+     (c) use the Software, or any portion of it, to develop, train, evaluate
+         against, or operate a product or service that competes with the Software
+         or with ShiftKeys;
+     (d) remove, obscure, or alter any proprietary notice; or
+     (e) circumvent or disable any license, billing, telemetry-integrity, or
+         security mechanism in the Software.
+
+5. CONTRIBUTIONS
+   ShiftKeys does not solicit external contributions. If you submit any
+   contribution to the Software, then to the maximum extent permitted by law you
+   hereby irrevocably assign to ShiftKeys all right, title, and interest in it
+   (including all intellectual property rights) and waive any moral rights; to
+   the extent that assignment is unenforceable, you instead grant ShiftKeys a
+   perpetual, irrevocable, worldwide, royalty-free, fully paid-up, sublicensable,
+   and transferable license to exploit the contribution for any purpose. You
+   represent that you have the right to do so.
+
+6. THIRD-PARTY COMPONENTS
+   The Software may include third-party components licensed under their own
+   terms; those terms govern those components, and this license applies only to
+   the portions owned by ShiftKeys.
+
+7. COMMERCIALIZATION AND ASSIGNMENT
+   ShiftKeys may at any time license, relicense, sell, assign, transfer, or
+   otherwise commercialize the Software or any rights in it; change the terms on
+   which it is offered, including ceasing to publish the source or releasing
+   future versions under different terms; and assign this license together with
+   all of its rights to any successor or acquirer without your consent.
+   Commercial use by you requires a separate, paid, written license; to inquire,
+   contact support@kickbacks.ai.
+
+8. TERMINATION
+   This license terminates automatically upon any breach, and ShiftKeys may
+   revoke it at any time. Upon termination you must cease all use of the Software
+   and destroy all copies in your possession or control. Sections 1, 4, 5, 6, 7,
+   9, 10, and 11 survive termination.
+
+9. NO WARRANTY
+   THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY OF ANY
+   KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT.
+
+10. LIMITATION OF LIABILITY
+   TO THE MAXIMUM EXTENT PERMITTED BY LAW, IN NO EVENT SHALL SHIFTKEYS BE LIABLE
+   FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR
+   ANY LOSS OF PROFITS, REVENUE, DATA, OR GOODWILL, ARISING OUT OF OR RELATED TO
+   THE SOFTWARE OR THIS LICENSE, WHETHER IN CONTRACT, TORT, OR OTHERWISE, EVEN IF
+   ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+11. GENERAL
+   This license is governed by the laws of the State of Delaware, USA, without
+   regard to its conflict-of-laws rules, and the exclusive venue for any dispute
+   is the state and federal courts located in Delaware. If any provision is held
+   unenforceable, it shall be limited or severed so that the remainder remains in
+   effect. No failure to enforce any provision is a waiver of it. You may not
+   assign this license; ShiftKeys may freely assign it.
+
+For licensing inquiries, contact: support@kickbacks.ai
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..9990444
--- /dev/null
+++ b/README.md
@@ -0,0 +1,125 @@
+<div align="center">
+
+<picture>
+  <source media="(prefers-color-scheme: dark)" srcset="media/logos/kickbacks-white.svg">
+  <source media="(prefers-color-scheme: light)" srcset="media/logos/kickbacks-green.svg">
+  <img alt="Kickbacks" src="media/logos/kickbacks.svg" width="320">
+</picture>
+
+### Get paid while you code.
+
+**Kickbacks** sells the little "thinking…" word inside the Claude Code & Codex
+spinners — and pays **up to 50% of ad revenue back to you**, the developer whose
+machine showed it.
+
+[![Website](https://img.shields.io/badge/kickbacks.ai-00B894?style=for-the-badge&logoColor=white)](https://kickbacks.ai)
+[![VS Code Marketplace](https://img.shields.io/badge/VS%20Code-Install-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](https://marketplace.visualstudio.com/items?itemName=Kickbacksai.kickbacks-ai)
+[![License](https://img.shields.io/badge/license-Source--Available-lightgrey?style=for-the-badge)](LICENSE)
+
+</div>
+
+---
+
+## 💡 The idea
+
+When Claude Code or Codex is thinking, it shows a random verb — *"Discombobulating…"*,
+*"Baking…"*, *"Percolating…"*. Cute, but it's prime real estate doing nothing.
+
+Kickbacks turns that one line into a tiny, tasteful, **clickable** sponsored slot.
+Advertisers bid for it in an open auction. You get **up to 50% of ad revenue**
+— credited automatically, per impression and per click.
+
+```diff
+- ✶ Discombobulating… (esc to interrupt)
++ ✶ Linear — issue tracking that's actually fast ↗ (esc to interrupt)
+```
+
+No surveys. No crypto. No "watch this video." You just… keep coding, and your
+balance ticks up in the status bar:
+
+```
+Kickbacks  ($0.42 today · $7.11)
+```
+
+## 🤝 How the money works
+
+- **Advertisers buy blocks.** One block = **1,000 five-second impressions**. They set
+  a price per block and an ad creative, then buy as many blocks as they want.
+- **An English-ascending auction** decides whose ad shows where, and when.
+- **Revenue share.** Up to 50% of ad revenue accrues to the developer whose
+  editor rendered the ad. Clicks are worth **50×** an impression.
+- **Real-time balance.** Today / this month / lifetime, right in your VS Code
+  status bar, with a full ledger at [kickbacks.ai](https://kickbacks.ai).
+
+## 🎯 Where the ad shows up
+
+Four surfaces, one extension:
+
+| Surface | Where | Needs |
+| --- | --- | --- |
+| **Spinner overlay** | Claude Code VS Code panel | A compatible extension build |
+| **Thinking-shimmer** | Codex VS Code panel | A compatible extension build |
+| **Status-bar line** | Claude Code terminal CLI | Any Claude Code version |
+| **Spinner verb** | Claude Code terminal CLI | Claude Code **2.1.143+** |
+
+VS Code surfaces work on local, Remote-SSH, devcontainers, and code-server.
+Older CLIs just keep their stock verbs — nothing breaks.
+
+## 🚀 Install
+
+1. Search **Kickbacks** in the VS Code Marketplace and install.
+2. Click **Kickbacks: Sign in** in the status bar.
+3. Authenticate with Google.
+4. Keep coding. Earnings start automatically.
+
+→ **[Get it on the VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Kickbacksai.kickbacks-ai)**
+
+## 📣 Want to advertise?
+
+You're buying attention from the most technical audience on earth, in the calmest
+possible format — a single line, while they wait. Set your bid, drop in a creative,
+and you're live.
+
+→ **[Buy inventory at kickbacks.ai](https://kickbacks.ai)**
+
+## 🧱 What's in this repo
+
+This is a **public, read-only mirror** of the Kickbacks VS Code extension — the
+client that renders the ads and tracks impressions. It's published for
+transparency: you can read exactly what runs on your machine.
+
+```
+src/
+  adapters/        per-tool injection (claude-code, codex, claude-cli, codex-cli)
+  activation/      lifecycle: ad rotation, self-update, status bar, injection
+  auth/            sign-in + OS-keychain-sealed token vault
+  metrics/         impression / view-threshold / click telemetry (idempotent)
+  viewTracking/    "was it actually on screen long enough?" timer
+  killswitch/      server-controlled global off-switch
+media/             logos + icons
+test/              the vitest suite that guards editor safety
+```
+
+The backend, advertiser portal, auction engine, and marketing site live in a
+separate private repository.
+
+### Build it yourself
+
+```bash
+npm install
+npm run build       # esbuild → dist/
+npm test            # vitest — the editor-safety net
+npm run package     # produce the .vsix
+```
+
+## 📜 License
+
+**Proprietary and source-available — _not_ open source.** © 2026 ShiftKeys, Inc.
+All rights reserved. You may read this code; you may **not** use, copy, modify,
+distribute, or commercialize it without a written license. See [LICENSE](LICENSE).
+Commercial inquiries: **support@kickbacks.ai**.
+
+<div align="center">
+<br>
+<sub>Made with caffeine by <a href="https://kickbacks.ai">ShiftKeys</a> · up to half the ad revenue is yours.</sub>
+</div>
diff --git a/esbuild.mjs b/esbuild.mjs
new file mode 100644
index 0000000..4a7b8be
--- /dev/null
+++ b/esbuild.mjs
@@ -0,0 +1,106 @@
+import { build } from "esbuild";
+import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+// Repo-root .env reader (mirrors scripts/deploy.mjs::readEnvFile so build &
+// publish stay consistent). Trivial KEY=value parser; quotes / blanks /
+// comments tolerated; missing file ⇒ all build flags fall back to safe
+// defaults below. Pulling dotenv as a dep would be overkill here.
+function readDotenv(path) {
+  if (!existsSync(path)) return {};
+  const out = {};
+  for (const raw of readFileSync(path, "utf8").split(/\r?\n/)) {
+    const line = raw.trim();
+    if (!line || line.startsWith("#")) continue;
+    const eq = line.indexOf("=");
+    if (eq <= 0) continue;
+    const k = line.slice(0, eq).trim();
+    let v = line.slice(eq + 1).trim();
+    if ((v.startsWith('"') && v.endsWith('"'))
+        || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+    out[k] = v;
+  }
+  return out;
+}
+const isTrue = (v) => v === "true" || v === "1" || v === "yes" || v === "on";
+
+// extension/ sits one level below the repo root.
+const HERE = dirname(fileURLToPath(import.meta.url));
+const ROOT_ENV = readDotenv(resolve(HERE, "..", ".env"));
+const BUILD_FLAGS = {
+  developer: isTrue(ROOT_ENV.KICKBACKS_DEVELOPER ?? "false"),
+  adminUrl: ROOT_ENV.KICKBACKS_ADMIN_URL ?? "",
+  siteUrl: ROOT_ENV.KICKBACKS_SITE_URL ?? "",
+  verbose: isTrue(ROOT_ENV.KICKBACKS_VERBOSE ?? "false"),
+  codex: isTrue(ROOT_ENV.KICKBACKS_CODEX ?? "false"),
+  testHooks: isTrue(ROOT_ENV.KICKBACKS_TEST_HOOKS ?? "false"),
+  manifestPubkeyPem: ROOT_ENV.KICKBACKS_MANIFEST_PUBKEY_PEM
+    ?? ROOT_ENV.VIBE_ADS_MANIFEST_PUBKEY_PEM
+    ?? "",
+};
+
+function copyAsset(src, dest) {
+  mkdirSync(dirname(dest), { recursive: true });
+  copyFileSync(src, dest);
+}
+
+const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
+// Captured from package.json `version` below and baked into the bundle so the
+// running extension can compare its own semver against the deploy sentinel.
+let pkgVersion = "0.0.0";
+{
+  const pj = JSON.parse(readFileSync("package.json", "utf8"));
+  pkgVersion = String(pj.version || "0.0.0");
+}
+await build({
+  entryPoints: ["src/extension.ts"],
+  bundle: true, platform: "node", format: "cjs",
+  external: ["vscode"], outfile: "dist/extension.js", target: "node18",
+  // Bake the build epoch into the bundle so the extension can show a LIVE
+  // "built Nh ago" at runtime (the VS Code Installation panel is not author-
+  // extensible — this is the only place a truthful relative age can live).
+  define: { __BUILD_TS__: JSON.stringify(stamp),
+            __BUILD_VERSION__: JSON.stringify(pkgVersion),
+            // Build-time flags from the repo-root .env. Runtime sentinels /
+            // process.env still take precedence at call time (see log.ts +
+            // buildflags.ts) — these change the SHIPPED default only.
+            __DEVELOPER_MODE__: JSON.stringify(BUILD_FLAGS.developer),
+            __ADMIN_URL__: JSON.stringify(BUILD_FLAGS.adminUrl),
+            __SITE_URL__: JSON.stringify(BUILD_FLAGS.siteUrl),
+            __BUILD_VERBOSE__: JSON.stringify(BUILD_FLAGS.verbose),
+            __BUILD_CODEX_OPTIN__: JSON.stringify(BUILD_FLAGS.codex),
+            __BUILD_TEST_HOOKS_OPTIN__: JSON.stringify(BUILD_FLAGS.testHooks),
+            __MANIFEST_PUBKEY_PEM__: JSON.stringify(BUILD_FLAGS.manifestPubkeyPem) },
+});
+// The injected block is a shipped raw asset (NOT bundled).
+copyAsset("src/adapters/claude-code/block.asset.js",
+          "dist/adapters/claude-code/block.asset.js");
+// The CLI status-line script is a shipped raw asset (NOT bundled).
+copyAsset("src/adapters/claude-cli/statusline.asset.mjs",
+          "dist/adapters/claude-cli/statusline.asset.mjs");
+// The Codex thinking-shimmer injection is a shipped raw asset (NOT bundled).
+copyAsset("src/adapters/codex/block.asset.js",
+          "dist/adapters/codex/block.asset.js");
+// Codex CLI wrapper templates (Windows .cmd + POSIX shell). Shipped raw.
+copyAsset("src/adapters/codex-cli/wrapper.cmd.asset",
+          "dist/adapters/codex-cli/wrapper.cmd.asset");
+copyAsset("src/adapters/codex-cli/wrapper.sh.asset",
+          "dist/adapters/codex-cli/wrapper.sh.asset");
+// Generate the DETAILS-pane readme under dist. scripts/package.mjs copies it
+// into the temporary VSCE package root as README.md. The tracked source of
+// record is readme_extension.md; there is intentionally no bare README.md in
+// extension/ (only the repo-root README.md keeps that name).
+try {
+  const rd = readFileSync("readme_extension.md", "utf8")
+    .replace(/<!-- BUILD -->.*$/m, "")
+    .replace(/(<\/h1>)/i, `$1\n\n<!-- BUILD --> <p align="center"><sub>build ${stamp}</sub></p>`);
+  writeFileSync("dist/README.md", rd);
+} catch { /* readme is best-effort; never fail the build */ }
+console.log(`built dist/extension.js + CC & Codex block assets + dist/README.md (build ${stamp})`);
+console.log(`  build flags: developer=${BUILD_FLAGS.developer}`
+  + ` verbose=${BUILD_FLAGS.verbose} codex=${BUILD_FLAGS.codex}`
+  + ` testHooks=${BUILD_FLAGS.testHooks}`
+  + (BUILD_FLAGS.developer
+      ? `  admin=${BUILD_FLAGS.adminUrl || "<unset>"} site=${BUILD_FLAGS.siteUrl || "<unset>"}`
+      : ""));
diff --git a/media/icon.png b/media/icon.png
new file mode 100644
index 0000000..c72af28
Binary files /dev/null and b/media/icon.png differ
diff --git a/media/logo.svg b/media/logo.svg
new file mode 100644
index 0000000..f3dc15e
--- /dev/null
+++ b/media/logo.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">
+  <defs><linearGradient id="kb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2aa44f"/><stop offset="1" stop-color="#147a34"/></linearGradient></defs>
+  <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#kb)"/>
+  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif" font-weight="800" font-size="62" letter-spacing="-2" fill="#fff">K$</text>
+</svg>
diff --git a/media/logos/apple-touch-icon.png b/media/logos/apple-touch-icon.png
new file mode 100644
index 0000000..d6be277
Binary files /dev/null and b/media/logos/apple-touch-icon.png differ
diff --git a/media/logos/favicon-32.png b/media/logos/favicon-32.png
new file mode 100644
index 0000000..4cd03cf
Binary files /dev/null and b/media/logos/favicon-32.png differ
diff --git a/media/logos/favicon.ico b/media/logos/favicon.ico
new file mode 100644
index 0000000..2b6b1f1
Binary files /dev/null and b/media/logos/favicon.ico differ
diff --git a/media/logos/favicon.svg b/media/logos/favicon.svg
new file mode 100644
index 0000000..56123f5
--- /dev/null
+++ b/media/logos/favicon.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">
+  <defs><linearGradient id="kb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2aa44f"/><stop offset="1" stop-color="#147a34"/></linearGradient></defs>
+  <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#kb)"/>
+  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif" font-weight="800" font-size="62" letter-spacing="-2" fill="#fff">K$</text>
+</svg>
\ No newline at end of file
diff --git a/media/logos/icon-192.png b/media/logos/icon-192.png
new file mode 100644
index 0000000..7eb4300
Binary files /dev/null and b/media/logos/icon-192.png differ
diff --git a/media/logos/icon-512.png b/media/logos/icon-512.png
new file mode 100644
index 0000000..e6fa19f
Binary files /dev/null and b/media/logos/icon-512.png differ
diff --git a/media/logos/kickbacks-128.png b/media/logos/kickbacks-128.png
new file mode 100644
index 0000000..ce93114
Binary files /dev/null and b/media/logos/kickbacks-128.png differ
diff --git a/media/logos/kickbacks-16.png b/media/logos/kickbacks-16.png
new file mode 100644
index 0000000..21c000e
Binary files /dev/null and b/media/logos/kickbacks-16.png differ
diff --git a/media/logos/kickbacks-180.png b/media/logos/kickbacks-180.png
new file mode 100644
index 0000000..d6be277
Binary files /dev/null and b/media/logos/kickbacks-180.png differ
diff --git a/media/logos/kickbacks-192.png b/media/logos/kickbacks-192.png
new file mode 100644
index 0000000..7eb4300
Binary files /dev/null and b/media/logos/kickbacks-192.png differ
diff --git a/media/logos/kickbacks-256.png b/media/logos/kickbacks-256.png
new file mode 100644
index 0000000..c72af28
Binary files /dev/null and b/media/logos/kickbacks-256.png differ
diff --git a/media/logos/kickbacks-32.png b/media/logos/kickbacks-32.png
new file mode 100644
index 0000000..4cd03cf
Binary files /dev/null and b/media/logos/kickbacks-32.png differ
diff --git a/media/logos/kickbacks-48.png b/media/logos/kickbacks-48.png
new file mode 100644
index 0000000..4be405b
Binary files /dev/null and b/media/logos/kickbacks-48.png differ
diff --git a/media/logos/kickbacks-512.png b/media/logos/kickbacks-512.png
new file mode 100644
index 0000000..e6fa19f
Binary files /dev/null and b/media/logos/kickbacks-512.png differ
diff --git a/media/logos/kickbacks-64.png b/media/logos/kickbacks-64.png
new file mode 100644
index 0000000..dd6413e
Binary files /dev/null and b/media/logos/kickbacks-64.png differ
diff --git a/media/logos/kickbacks-green.svg b/media/logos/kickbacks-green.svg
new file mode 100644
index 0000000..1e66b70
--- /dev/null
+++ b/media/logos/kickbacks-green.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">
+  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif" font-weight="800" font-size="62" letter-spacing="-2" fill="#188a45">K$</text>
+</svg>
\ No newline at end of file
diff --git a/media/logos/kickbacks-lockup.png b/media/logos/kickbacks-lockup.png
new file mode 100644
index 0000000..3bd12c3
Binary files /dev/null and b/media/logos/kickbacks-lockup.png differ
diff --git a/media/logos/kickbacks-white.svg b/media/logos/kickbacks-white.svg
new file mode 100644
index 0000000..0051db2
--- /dev/null
+++ b/media/logos/kickbacks-white.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">
+  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif" font-weight="800" font-size="62" letter-spacing="-2" fill="#fff">K$</text>
+</svg>
\ No newline at end of file
diff --git a/media/logos/kickbacks.svg b/media/logos/kickbacks.svg
new file mode 100644
index 0000000..56123f5
--- /dev/null
+++ b/media/logos/kickbacks.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">
+  <defs><linearGradient id="kb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2aa44f"/><stop offset="1" stop-color="#147a34"/></linearGradient></defs>
+  <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#kb)"/>
+  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif" font-weight="800" font-size="62" letter-spacing="-2" fill="#fff">K$</text>
+</svg>
\ No newline at end of file
diff --git a/media/logos/og-logo.png b/media/logos/og-logo.png
new file mode 100644
index 0000000..e6fa19f
Binary files /dev/null and b/media/logos/og-logo.png differ
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9a11189
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,5008 @@
+{
+  "name": "kickbacks",
+  "version": "0.3.166",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "kickbacks",
+      "version": "0.3.166",
+      "license": "UNLICENSED",
+      "devDependencies": {
+        "@types/jsdom": "21.1.7",
+        "@types/node": "20.14.0",
+        "@types/vscode": "1.80.0",
+        "@vscode/vsce": "2.31.0",
+        "esbuild": "^0.23.0",
+        "jsdom": "24.1.0",
+        "typescript": "5.5.4",
+        "vitest": "2.0.5"
+      },
+      "engines": {
+        "vscode": "^1.80.0"
+      }
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
+      "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+      "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/css-calc": "^2.1.3",
+        "@csstools/css-color-parser": "^3.0.9",
+        "@csstools/css-parser-algorithms": "^3.0.4",
+        "@csstools/css-tokenizer": "^3.0.3",
+        "lru-cache": "^10.4.3"
+      }
+    },
+    "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
+      "version": "10.4.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+      "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/@azure/abort-controller": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.1.2.tgz",
+      "integrity": "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "node_modules/@azure/core-auth": {
+      "version": "1.10.1",
+      "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz",
+      "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/abort-controller": "^2.1.2",
+        "@azure/core-util": "^1.13.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/core-client": {
+      "version": "1.10.1",
+      "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz",
+      "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/abort-controller": "^2.1.2",
+        "@azure/core-auth": "^1.10.0",
+        "@azure/core-rest-pipeline": "^1.22.0",
+        "@azure/core-tracing": "^1.3.0",
+        "@azure/core-util": "^1.13.0",
+        "@azure/logger": "^1.3.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/core-rest-pipeline": {
+      "version": "1.23.0",
+      "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.23.0.tgz",
+      "integrity": "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/abort-controller": "^2.1.2",
+        "@azure/core-auth": "^1.10.0",
+        "@azure/core-tracing": "^1.3.0",
+        "@azure/core-util": "^1.13.0",
+        "@azure/logger": "^1.3.0",
+        "@typespec/ts-http-runtime": "^0.3.4",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/core-tracing": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.3.1.tgz",
+      "integrity": "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/core-util": {
+      "version": "1.13.1",
+      "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.13.1.tgz",
+      "integrity": "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/abort-controller": "^2.1.2",
+        "@typespec/ts-http-runtime": "^0.3.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/identity": {
+      "version": "4.13.1",
+      "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz",
+      "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/abort-controller": "^2.0.0",
+        "@azure/core-auth": "^1.9.0",
+        "@azure/core-client": "^1.9.2",
+        "@azure/core-rest-pipeline": "^1.17.0",
+        "@azure/core-tracing": "^1.0.0",
+        "@azure/core-util": "^1.11.0",
+        "@azure/logger": "^1.0.0",
+        "@azure/msal-browser": "^5.5.0",
+        "@azure/msal-node": "^5.1.0",
+        "open": "^10.1.0",
+        "tslib": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/logger": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.3.0.tgz",
+      "integrity": "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@typespec/ts-http-runtime": "^0.3.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@azure/msal-browser": {
+      "version": "5.11.0",
+      "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.11.0.tgz",
+      "integrity": "sha512-zkGNYS3TwY8lUpPIafAmsFCYZbgFixY9y/LZB9GUg0IILoHTqpN26j5OrkL1AQThh/YdZsawe4iWXfp85lFVxg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/msal-common": "16.6.2"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/@azure/msal-common": {
+      "version": "16.6.2",
+      "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.6.2.tgz",
+      "integrity": "sha512-hQjjsekAjB00cM1EmatWJlzhEoK2Qhz7Rj5gvM6tYf8iL7RM3tkxlpU9fG0+ofkulzg9AEEA6dIEnSmDr5ZqUA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/@azure/msal-node": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.2.2.tgz",
+      "integrity": "sha512-toS+2AePxqyzb0YOKttDOOiSl3jrkK9aiqIvpurpis0O34QcIS5gToqrgT39p04Dpxw3YoUU0lxJKTpSFFfA6Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/msal-common": "16.6.2",
+        "jsonwebtoken": "^9.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/@csstools/color-helpers": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+      "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT-0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@csstools/css-calc": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+      "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-color-parser": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+      "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@csstools/color-helpers": "^5.1.0",
+        "@csstools/css-calc": "^2.1.4"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-parser-algorithms": "^3.0.5",
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-parser-algorithms": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+      "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@csstools/css-tokenizer": "^3.0.4"
+      }
+    },
+    "node_modules/@csstools/css-tokenizer": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+      "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/csstools"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/csstools"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
+      "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
+      "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
+      "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
+      "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
+      "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
+      "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
+      "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
+      "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
+      "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
+      "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
+      "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
+      "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
+      "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
+      "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
+      "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
+      "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
+      "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
+      "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
+      "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
+      "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
+      "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
+      "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
+      "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
+      "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+      "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+      "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+      "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+      "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+      "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+      "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+      "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+      "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+      "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+      "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+      "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+      "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+      "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+      "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+      "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+      "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+      "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+      "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openbsd-x64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+      "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+      "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+      "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+      "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+      "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+      "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/jsdom": {
+      "version": "21.1.7",
+      "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz",
+      "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/tough-cookie": "*",
+        "parse5": "^7.0.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "20.14.0",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.0.tgz",
+      "integrity": "sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/tough-cookie": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
+      "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/vscode": {
+      "version": "1.80.0",
+      "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.80.0.tgz",
+      "integrity": "sha512-qK/CmOdS2o7ry3k6YqU4zD3R2AYlJfbwBoSbKpBoP+GpXNE+0NEgJOli4n0bm0diK5kfBnchgCEj4igQz/44Hg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@typespec/ts-http-runtime": {
+      "version": "0.3.5",
+      "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.5.tgz",
+      "integrity": "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "http-proxy-agent": "^7.0.0",
+        "https-proxy-agent": "^7.0.0",
+        "tslib": "^2.6.2"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@vitest/expect": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
+      "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "2.0.5",
+        "@vitest/utils": "2.0.5",
+        "chai": "^5.1.1",
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "2.1.9",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz",
+      "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz",
+      "integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "2.0.5",
+        "pathe": "^1.1.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz",
+      "integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "2.0.5",
+        "magic-string": "^0.30.10",
+        "pathe": "^1.1.2"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz",
+      "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz",
+      "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyspy": "^3.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz",
+      "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "2.0.5",
+        "estree-walker": "^3.0.3",
+        "loupe": "^3.1.1",
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz",
+      "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^1.2.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vscode/vsce": {
+      "version": "2.31.0",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-2.31.0.tgz",
+      "integrity": "sha512-iZvx9Vn3E81BCwZUAFctQyVxJE09LJ/nLHLGVZmKB5Pjtn62EgjWfJupehmWGmRYMS6ViK+7q7ysNrjxNFhh1g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@azure/identity": "^4.1.0",
+        "@vscode/vsce-sign": "^2.0.0",
+        "azure-devops-node-api": "^12.5.0",
+        "chalk": "^2.4.2",
+        "cheerio": "^1.0.0-rc.9",
+        "cockatiel": "^3.1.2",
+        "commander": "^6.2.1",
+        "form-data": "^4.0.0",
+        "glob": "^7.0.6",
+        "hosted-git-info": "^4.0.2",
+        "jsonc-parser": "^3.2.0",
+        "leven": "^3.1.0",
+        "markdown-it": "^12.3.2",
+        "mime": "^1.3.4",
+        "minimatch": "^3.0.3",
+        "parse-semver": "^1.1.1",
+        "read": "^1.0.7",
+        "semver": "^7.5.2",
+        "tmp": "^0.2.1",
+        "typed-rest-client": "^1.8.4",
+        "url-join": "^4.0.1",
+        "xml2js": "^0.5.0",
+        "yauzl": "^2.3.1",
+        "yazl": "^2.2.2"
+      },
+      "bin": {
+        "vsce": "vsce"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "optionalDependencies": {
+        "keytar": "^7.7.0"
+      }
+    },
+    "node_modules/@vscode/vsce-sign": {
+      "version": "2.0.9",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz",
+      "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optionalDependencies": {
+        "@vscode/vsce-sign-alpine-arm64": "2.0.6",
+        "@vscode/vsce-sign-alpine-x64": "2.0.6",
+        "@vscode/vsce-sign-darwin-arm64": "2.0.6",
+        "@vscode/vsce-sign-darwin-x64": "2.0.6",
+        "@vscode/vsce-sign-linux-arm": "2.0.6",
+        "@vscode/vsce-sign-linux-arm64": "2.0.6",
+        "@vscode/vsce-sign-linux-x64": "2.0.6",
+        "@vscode/vsce-sign-win32-arm64": "2.0.6",
+        "@vscode/vsce-sign-win32-x64": "2.0.6"
+      }
+    },
+    "node_modules/@vscode/vsce-sign-alpine-arm64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz",
+      "integrity": "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "alpine"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-alpine-x64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz",
+      "integrity": "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "alpine"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-darwin-arm64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz",
+      "integrity": "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-darwin-x64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz",
+      "integrity": "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-linux-arm": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz",
+      "integrity": "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-linux-arm64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz",
+      "integrity": "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-linux-x64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz",
+      "integrity": "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-win32-arm64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz",
+      "integrity": "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@vscode/vsce-sign-win32-x64": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz",
+      "integrity": "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "SEE LICENSE IN LICENSE.txt",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/agent-base": {
+      "version": "7.1.4",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true,
+      "license": "Python-2.0"
+    },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/azure-devops-node-api": {
+      "version": "12.5.0",
+      "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz",
+      "integrity": "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tunnel": "0.0.6",
+        "typed-rest-client": "^1.8.4"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.14",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
+      "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/buffer-equal-constant-time": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+      "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+      "dev": true,
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/bundle-name": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
+      "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "run-applescript": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/cac": {
+      "version": "6.7.14",
+      "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+      "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chai": {
+      "version": "5.3.3",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
+      "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "assertion-error": "^2.0.1",
+        "check-error": "^2.1.1",
+        "deep-eql": "^5.0.1",
+        "loupe": "^3.1.0",
+        "pathval": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
+      "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 16"
+      }
+    },
+    "node_modules/cheerio": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+      "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cheerio-select": "^2.1.0",
+        "dom-serializer": "^2.0.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "encoding-sniffer": "^0.2.1",
+        "htmlparser2": "^10.1.0",
+        "parse5": "^7.3.0",
+        "parse5-htmlparser2-tree-adapter": "^7.1.0",
+        "parse5-parser-stream": "^7.1.2",
+        "undici": "^7.19.0",
+        "whatwg-mimetype": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=20.18.1"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+      }
+    },
+    "node_modules/cheerio-select": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+      "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-select": "^5.1.0",
+        "css-what": "^6.1.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "dev": true,
+      "license": "ISC",
+      "optional": true
+    },
+    "node_modules/cockatiel": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz",
+      "integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
+      "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/css-select": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+      "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-what": "^6.1.0",
+        "domhandler": "^5.0.2",
+        "domutils": "^3.0.1",
+        "nth-check": "^2.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/css-what": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+      "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/cssstyle": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+      "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@asamuzakjp/css-color": "^3.2.0",
+        "rrweb-cssom": "^0.8.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/cssstyle/node_modules/rrweb-cssom": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+      "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/data-urls": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+      "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.6.0",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+      "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
+      "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/default-browser": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
+      "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "bundle-name": "^4.1.0",
+        "default-browser-id": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/default-browser-id": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
+      "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/define-lazy-prop": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
+      "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ecdsa-sig-formatter": {
+      "version": "1.0.11",
+      "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+      "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/encoding-sniffer": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+      "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "^0.6.3",
+        "whatwg-encoding": "^3.1.1"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.23.1",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
+      "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.23.1",
+        "@esbuild/android-arm": "0.23.1",
+        "@esbuild/android-arm64": "0.23.1",
+        "@esbuild/android-x64": "0.23.1",
+        "@esbuild/darwin-arm64": "0.23.1",
+        "@esbuild/darwin-x64": "0.23.1",
+        "@esbuild/freebsd-arm64": "0.23.1",
+        "@esbuild/freebsd-x64": "0.23.1",
+        "@esbuild/linux-arm": "0.23.1",
+        "@esbuild/linux-arm64": "0.23.1",
+        "@esbuild/linux-ia32": "0.23.1",
+        "@esbuild/linux-loong64": "0.23.1",
+        "@esbuild/linux-mips64el": "0.23.1",
+        "@esbuild/linux-ppc64": "0.23.1",
+        "@esbuild/linux-riscv64": "0.23.1",
+        "@esbuild/linux-s390x": "0.23.1",
+        "@esbuild/linux-x64": "0.23.1",
+        "@esbuild/netbsd-x64": "0.23.1",
+        "@esbuild/openbsd-arm64": "0.23.1",
+        "@esbuild/openbsd-x64": "0.23.1",
+        "@esbuild/sunos-x64": "0.23.1",
+        "@esbuild/win32-arm64": "0.23.1",
+        "@esbuild/win32-ia32": "0.23.1",
+        "@esbuild/win32-x64": "0.23.1"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/execa": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+      "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^8.0.1",
+        "human-signals": "^5.0.0",
+        "is-stream": "^3.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^5.1.0",
+        "onetime": "^6.0.0",
+        "signal-exit": "^4.1.0",
+        "strip-final-newline": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=16.17"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "dev": true,
+      "license": "(MIT OR WTFPL)",
+      "optional": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fd-slicer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+      "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "pend": "~1.2.0"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/get-stream": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+      "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/glob": {
+      "version": "7.2.3",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+      "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+      "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.1.1",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/hosted-git-info": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+      "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+      "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-encoding": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/htmlparser2": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+      "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+      "dev": true,
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "entities": "^7.0.1"
+      }
+    },
+    "node_modules/htmlparser2/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.0",
+        "debug": "^4.3.4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "agent-base": "^7.1.2",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/human-signals": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=16.17.0"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause",
+      "optional": true
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+      "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "dev": true,
+      "license": "ISC",
+      "optional": true
+    },
+    "node_modules/is-docker": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
+      "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "is-docker": "cli.js"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-inside-container": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
+      "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-docker": "^3.0.0"
+      },
+      "bin": {
+        "is-inside-container": "cli.js"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-stream": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+      "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-wsl": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
+      "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-inside-container": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/jsdom": {
+      "version": "24.1.0",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.0.tgz",
+      "integrity": "sha512-6gpM7pRXCwIOKxX47cgOyvyQDN/Eh0f1MeKySBV2xGdKtqJBLj8P25eY3EVCWo2mglDDzozR2r2MW4T+JiNUZA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssstyle": "^4.0.1",
+        "data-urls": "^5.0.0",
+        "decimal.js": "^10.4.3",
+        "form-data": "^4.0.0",
+        "html-encoding-sniffer": "^4.0.0",
+        "http-proxy-agent": "^7.0.2",
+        "https-proxy-agent": "^7.0.4",
+        "is-potential-custom-element-name": "^1.0.1",
+        "nwsapi": "^2.2.10",
+        "parse5": "^7.1.2",
+        "rrweb-cssom": "^0.7.0",
+        "saxes": "^6.0.0",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^4.1.4",
+        "w3c-xmlserializer": "^5.0.0",
+        "webidl-conversions": "^7.0.0",
+        "whatwg-encoding": "^3.1.1",
+        "whatwg-mimetype": "^4.0.0",
+        "whatwg-url": "^14.0.0",
+        "ws": "^8.17.0",
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "canvas": "^2.11.2"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsonc-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+      "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/jsonwebtoken": {
+      "version": "9.0.3",
+      "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+      "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "jws": "^4.0.1",
+        "lodash.includes": "^4.3.0",
+        "lodash.isboolean": "^3.0.3",
+        "lodash.isinteger": "^4.0.4",
+        "lodash.isnumber": "^3.0.3",
+        "lodash.isplainobject": "^4.0.6",
+        "lodash.isstring": "^4.0.1",
+        "lodash.once": "^4.0.0",
+        "ms": "^2.1.1",
+        "semver": "^7.5.4"
+      },
+      "engines": {
+        "node": ">=12",
+        "npm": ">=6"
+      }
+    },
+    "node_modules/jwa": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+      "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "buffer-equal-constant-time": "^1.0.1",
+        "ecdsa-sig-formatter": "1.0.11",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/jws": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+      "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "jwa": "^2.0.1",
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "node_modules/keytar": {
+      "version": "7.9.0",
+      "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
+      "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "node-addon-api": "^4.3.0",
+        "prebuild-install": "^7.0.1"
+      }
+    },
+    "node_modules/leven": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+      "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/linkify-it": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz",
+      "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "uc.micro": "^1.0.1"
+      }
+    },
+    "node_modules/lodash.includes": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.isboolean": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+      "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.isinteger": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+      "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.isnumber": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+      "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.isplainobject": {
+      "version": "4.0.6",
+      "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+      "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.isstring": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+      "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lodash.once": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+      "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/loupe": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
+      "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/markdown-it": {
+      "version": "12.3.2",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz",
+      "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "argparse": "^2.0.1",
+        "entities": "~2.1.0",
+        "linkify-it": "^3.0.1",
+        "mdurl": "^1.0.1",
+        "uc.micro": "^1.0.5"
+      },
+      "bin": {
+        "markdown-it": "bin/markdown-it.js"
+      }
+    },
+    "node_modules/markdown-it/node_modules/entities": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
+      "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mdurl": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+      "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-fn": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+      "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+      "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/mute-stream": {
+      "version": "0.0.8",
+      "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
+      "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/napi-build-utils": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/node-abi": {
+      "version": "3.92.0",
+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
+      "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
+      "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/npm-run-path": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+      "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "path-key": "^4.0.0"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/npm-run-path/node_modules/path-key": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+      "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "node_modules/nwsapi": {
+      "version": "2.2.23",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+      "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/onetime": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+      "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "mimic-fn": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/open": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
+      "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "default-browser": "^5.2.1",
+        "define-lazy-prop": "^3.0.0",
+        "is-inside-container": "^1.0.0",
+        "wsl-utils": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parse-semver": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/parse-semver/-/parse-semver-1.1.1.tgz",
+      "integrity": "sha512-Eg1OuNntBMH0ojvEKSrvDSnwLmvVuUOSdylH/pSCPNMIspLlweJyIWXCE+k/5hm3cj/EBUYwmWkjhBALNP4LXQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^5.1.0"
+      }
+    },
+    "node_modules/parse-semver/node_modules/semver": {
+      "version": "5.7.2",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+      "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-htmlparser2-tree-adapter": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+      "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "domhandler": "^5.0.3",
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-parser-stream": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+      "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pathe": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+      "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/pathval": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
+      "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.16"
+      }
+    },
+    "node_modules/pend": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+      "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.5.15",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+      "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.12",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^2.0.0",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/psl": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+      "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/lupomontero"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/querystringify": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+      "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "dev": true,
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "optional": true,
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/read": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
+      "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "mute-stream": "~0.0.4"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/requires-port": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+      "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/rollup": {
+      "version": "4.60.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+      "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.60.4",
+        "@rollup/rollup-android-arm64": "4.60.4",
+        "@rollup/rollup-darwin-arm64": "4.60.4",
+        "@rollup/rollup-darwin-x64": "4.60.4",
+        "@rollup/rollup-freebsd-arm64": "4.60.4",
+        "@rollup/rollup-freebsd-x64": "4.60.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+        "@rollup/rollup-linux-arm64-musl": "4.60.4",
+        "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+        "@rollup/rollup-linux-loong64-musl": "4.60.4",
+        "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+        "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+        "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-gnu": "4.60.4",
+        "@rollup/rollup-linux-x64-musl": "4.60.4",
+        "@rollup/rollup-openbsd-x64": "4.60.4",
+        "@rollup/rollup-openharmony-arm64": "4.60.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+        "@rollup/rollup-win32-x64-gnu": "4.60.4",
+        "@rollup/rollup-win32-x64-msvc": "4.60.4",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/rollup/node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/rrweb-cssom": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+      "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/run-applescript": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
+      "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/sax": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+      "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+      "dev": true,
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": ">=11.0.0"
+      }
+    },
+    "node_modules/saxes": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+      "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=v12.22.7"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.8.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+      "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/signal-exit": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+      "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/std-env": {
+      "version": "3.10.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+      "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/strip-final-newline": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+      "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinypool": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
+      "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      }
+    },
+    "node_modules/tinyrainbow": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz",
+      "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tinyspy": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
+      "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/tmp": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
+      "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.14"
+      }
+    },
+    "node_modules/tough-cookie": {
+      "version": "4.1.4",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+      "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+      "dev": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "psl": "^1.1.33",
+        "punycode": "^2.1.1",
+        "universalify": "^0.2.0",
+        "url-parse": "^1.5.3"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+      "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "punycode": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "license": "0BSD"
+    },
+    "node_modules/tunnel": {
+      "version": "0.0.6",
+      "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz",
+      "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6.11 <=0.7.0 || >=0.7.3"
+      }
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/typed-rest-client": {
+      "version": "1.8.11",
+      "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz",
+      "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "qs": "^6.9.1",
+        "tunnel": "0.0.6",
+        "underscore": "^1.12.1"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.5.4",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
+      "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/uc.micro": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+      "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/underscore": {
+      "version": "1.13.8",
+      "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz",
+      "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/undici": {
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+      "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/universalify": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+      "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/url-join": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
+      "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/url-parse": {
+      "version": "1.5.10",
+      "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+      "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "querystringify": "^2.1.1",
+        "requires-port": "^1.0.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-node": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz",
+      "integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cac": "^6.7.14",
+        "debug": "^4.3.5",
+        "pathe": "^1.1.2",
+        "tinyrainbow": "^1.2.0",
+        "vite": "^5.0.0"
+      },
+      "bin": {
+        "vite-node": "vite-node.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/vite/node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/vitest": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz",
+      "integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@ampproject/remapping": "^2.3.0",
+        "@vitest/expect": "2.0.5",
+        "@vitest/pretty-format": "^2.0.5",
+        "@vitest/runner": "2.0.5",
+        "@vitest/snapshot": "2.0.5",
+        "@vitest/spy": "2.0.5",
+        "@vitest/utils": "2.0.5",
+        "chai": "^5.1.1",
+        "debug": "^4.3.5",
+        "execa": "^8.0.1",
+        "magic-string": "^0.30.10",
+        "pathe": "^1.1.2",
+        "std-env": "^3.7.0",
+        "tinybench": "^2.8.0",
+        "tinypool": "^1.0.0",
+        "tinyrainbow": "^1.2.0",
+        "vite": "^5.0.0",
+        "vite-node": "2.0.5",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "@vitest/browser": "2.0.5",
+        "@vitest/ui": "2.0.5",
+        "happy-dom": "*",
+        "jsdom": "*"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+      "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "xml-name-validator": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+      "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+      "dev": true,
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-url": {
+      "version": "14.2.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+      "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "^5.1.0",
+        "webidl-conversions": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/ws": {
+      "version": "8.20.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+      "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/wsl-utils": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
+      "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-wsl": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+      "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xml2js": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
+      "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "sax": ">=0.6.0",
+        "xmlbuilder": "~11.0.0"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/xmlbuilder": {
+      "version": "11.0.1",
+      "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
+      "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/yauzl": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+      "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "buffer-crc32": "~0.2.3",
+        "fd-slicer": "~1.1.0"
+      }
+    },
+    "node_modules/yazl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz",
+      "integrity": "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "buffer-crc32": "~0.2.3"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9880005
--- /dev/null
+++ b/package.json
@@ -0,0 +1,229 @@
+{
+  "name": "kickbacks-ai",
+  "displayName": "Kickbacks.ai",
+  "publisher": "Kickbacksai",
+  "version": "0.3.174",
+  "description": "Get paid while you code. Subtle, clickable ads in the Claude Code and Codex spinners — 50/50 revenue split to users.",
+  "icon": "media/icon.png",
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/andrewmccalip/kickbacks.ai.git"
+  },
+  "engines": {
+    "vscode": "^1.80.0"
+  },
+  "main": "./dist/extension.js",
+  "activationEvents": [
+    "onStartupFinished"
+  ],
+  "contributes": {
+    "commands": [
+      {
+        "command": "kickbacks.signIn",
+        "title": "Kickbacks: Sign in"
+      },
+      {
+        "command": "kickbacks.signOut",
+        "title": "Kickbacks: Sign out"
+      },
+      {
+        "command": "kickbacks.restore",
+        "title": "Kickbacks: Restore Claude Code"
+      },
+      {
+        "command": "kickbacks.status",
+        "title": "Kickbacks: Show status"
+      },
+      {
+        "command": "kickbacks.debugMenu",
+        "title": "Kickbacks: Menu"
+      },
+      {
+        "command": "kickbacks.editConfig",
+        "title": "Kickbacks: Edit Config"
+      },
+      {
+        "command": "kickbacks.diagnose",
+        "title": "Kickbacks: Diagnose"
+      },
+      {
+        "command": "vibe-ads.diagnose",
+        "title": "Kickbacks: Diagnose (legacy alias)"
+      },
+      {
+        "command": "vibe-ads.signIn",
+        "title": "Kickbacks: Sign in (legacy alias)"
+      },
+      {
+        "command": "vibe-ads.signOut",
+        "title": "Kickbacks: Sign out (legacy alias)"
+      },
+      {
+        "command": "vibe-ads.restore",
+        "title": "Kickbacks: Restore Claude Code (legacy alias)"
+      },
+      {
+        "command": "vibe-ads.status",
+        "title": "Kickbacks: Show status (legacy alias)"
+      },
+      {
+        "command": "vibe-ads.debugMenu",
+        "title": "Kickbacks: Menu (legacy alias)"
+      },
+      {
+        "command": "kickbacks.test.fireImpressionRendered",
+        "title": "Kickbacks (Test): Fire impression_rendered"
+      },
+      {
+        "command": "kickbacks.test.fireImpressionViewable",
+        "title": "Kickbacks (Test): Fire impression_viewable"
+      },
+      {
+        "command": "kickbacks.test.fireViewTick",
+        "title": "Kickbacks (Test): Fire view_tick"
+      },
+      {
+        "command": "kickbacks.test.fireViewThresholdMet",
+        "title": "Kickbacks (Test): Fire view_threshold_met (credits)"
+      },
+      {
+        "command": "kickbacks.test.fireErrorImpression",
+        "title": "Kickbacks (Test): Fire error_impression (5s stuck-session safety net)"
+      },
+      {
+        "command": "kickbacks.test.fireClick",
+        "title": "Kickbacks (Test): Fire click"
+      },
+      {
+        "command": "kickbacks.test.refreshPortfolio",
+        "title": "Kickbacks (Test): Refresh portfolio"
+      },
+      {
+        "command": "kickbacks.test.refreshEarnings",
+        "title": "Kickbacks (Test): Refresh earnings"
+      },
+      {
+        "command": "kickbacks.test.getState",
+        "title": "Kickbacks (Test): Get state snapshot"
+      },
+      {
+        "command": "kickbacks.test.clearEventLog",
+        "title": "Kickbacks (Test): Clear event log"
+      },
+      {
+        "command": "kickbacks.test.disableInjection",
+        "title": "Kickbacks (Test): Disable injection"
+      },
+      {
+        "command": "kickbacks.test.enableInjection",
+        "title": "Kickbacks (Test): Enable injection"
+      },
+      {
+        "command": "kickbacks.test.refreshStatusBar",
+        "title": "Kickbacks (Test): Refresh status bar"
+      }
+    ],
+    "menus": {
+      "commandPalette": [
+        {
+          "command": "kickbacks.test.fireImpressionRendered",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.fireImpressionViewable",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.fireViewTick",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.fireViewThresholdMet",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.fireErrorImpression",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.fireClick",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.refreshPortfolio",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.refreshEarnings",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.getState",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.clearEventLog",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.disableInjection",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.enableInjection",
+          "when": "kickbacks.test.enabled"
+        },
+        {
+          "command": "kickbacks.test.refreshStatusBar",
+          "when": "kickbacks.test.enabled"
+        }
+      ]
+    },
+    "configuration": {
+      "title": "Kickbacks",
+      "properties": {
+        "kickbacks.viewThresholdSeconds": {
+          "type": "number",
+          "default": 5,
+          "minimum": 1,
+          "maximum": 600,
+          "description": "Seconds of cumulative visible time an ad must accumulate before it counts as 'shown' and is credited."
+        }
+      }
+    }
+  },
+  "scripts": {
+    "build": "node esbuild.mjs",
+    "test": "vitest run",
+    "typecheck": "tsc --noEmit -p tsconfig.json",
+    "icon": "node scripts/gen-icon.mjs",
+    "package": "node scripts/package.mjs",
+    "marketplace:publish": "vsce publish --no-dependencies --packagePath kickbacks.vsix"
+  },
+  "devDependencies": {
+    "@types/jsdom": "21.1.7",
+    "@types/node": "20.14.0",
+    "@types/vscode": "1.80.0",
+    "@vscode/vsce": "2.31.0",
+    "esbuild": "^0.23.0",
+    "jsdom": "24.1.0",
+    "typescript": "5.5.4",
+    "vitest": "2.0.5"
+  },
+  "author": {
+    "name": "ShiftKeys, Inc.",
+    "url": "https://kickbacks.ai"
+  },
+  "license": "SEE LICENSE IN LICENSE",
+  "homepage": "https://kickbacks.ai",
+  "bugs": {
+    "url": "https://github.com/andrewmccalip/kickbacks.ai/issues"
+  },
+  "keywords": [
+    "claude-code",
+    "ads",
+    "spinner",
+    "earnings",
+    "kickbacks"
+  ]
+}
diff --git a/readme_extension.md b/readme_extension.md
new file mode 100644
index 0000000..f2f5b97
--- /dev/null
+++ b/readme_extension.md
@@ -0,0 +1,142 @@
+<h1 align="center">Kickbacks</h1>
+
+<p align="center"><em>Get paid while you code. Earn up to 50% of ad revenue.</em></p>
+
+<p align="center">
+  <a href="https://kickbacks.ai">kickbacks.ai</a> ·
+  <a href="https://github.com/andrewmccalip/kickbacks.ai">GitHub</a> ·
+  <a href="https://github.com/andrewmccalip/kickbacks.ai/issues">Issues</a>
+</p>
+
+---
+
+## How it works
+
+Kickbacks works with the **Claude Code** and **Codex** VS Code extensions, and
+with the **Claude Code terminal CLI**. While either tool is thinking, its
+spinner shows a random verb ("Discombobulating...", "Baking..."). Kickbacks
+replaces that verb with a tiny, clickable sponsored line. Advertisers bid for
+the slot; **up to 50% of the resulting ad revenue is credited to you**.
+
+In a terminal, the same ad appears two ways: a clickable line in the status bar
+(every Claude Code version) and the thinking-spinner verb itself (Claude Code
+2.1.143 and newer). The terminal verb refreshes when you start a new `claude`
+session.
+
+Your balance shows in the VS Code status bar and updates in real time.
+
+## Features
+
+- **Earn passively** — impressions and clicks accrue while Claude Code or Codex works. Clicks earn 50× an impression.
+- **Revenue share** — up to 50% of ad revenue, credited per impression and click.
+- **Fully reversible** — one click restores Claude Code to its original state.
+- **Zero interference** — never reads your code, prompts, or completions.
+- **Auto-updates** — new versions install silently in the background.
+
+## Getting started
+
+1. Install from the VS Code marketplace (search **Kickbacks**).
+2. Click **Kickbacks: Sign in** in the status bar.
+3. Authenticate with Google.
+4. Start using Claude Code. Earnings begin automatically.
+
+Before you sign in you'll already see real sponsored lines in the spinner — a
+live preview of the product. **Those preview impressions don't earn you
+anything; sign in to start earning your share.**
+
+Your balance appears in the status bar: **Kickbacks ($0.42 today · $7.11)**
+
+## Status bar
+
+| State | Meaning |
+| --- | --- |
+| *Kickbacks: Sign in* | Not signed in yet. Click to authenticate. |
+| *Kickbacks ($0.42 today · $7.11)* | Signed in and earning. |
+| *Kickbacks: Off* | You disabled Kickbacks. Click to re-enable. |
+| *Kickbacks incompatible* | Your Claude Code version isn't supported yet. |
+| *Kickbacks offline* | Backend is temporarily unreachable. |
+| *Kickbacks killed* | Serving is remotely paused fleet-wide (safety kill-switch). |
+| *⚠ Kickbacks: RELOAD to earn money* | An update needs a window reload before earning resumes. Click to reload. |
+
+When you hit an earning limit, a second red pill appears next to your balance —
+*🕐 Hourly cap · 42m* or *⚠ Daily cap · 6h 12m* — showing which cap you hit
+and when it resets. Ads keep showing but stop accruing until the reset; click
+the pill for details.
+
+Click the status bar to open the Kickbacks menu (sign in/out, enable/disable,
+restore Claude Code, check for updates, open debug log).
+
+## Privacy
+
+Kickbacks communicates only with the Kickbacks backend at
+[kickbacks.ai](https://kickbacks.ai). It sends:
+
+- An anonymous device ID (not tied to your identity until you sign in).
+- Per-impression events (ad ID, surface, visible time, click).
+- Your Google email only after you sign in, so earnings can be credited.
+
+**Kickbacks never reads your code, prompts, completions, or any chat content.**
+
+Patching is done by modifying Claude Code's `webview/index.js` (and the Codex
+extension's webview bundle) from byte-exact backups (VS Code), and by editing
+`~/.claude/settings.json` to add a status-line script and a spinner-verb
+override (terminal CLI). If you already use a custom status line (a HUD,
+for example), it is kept: the ad renders on the line above it, and your
+original entry is put back on restore. All edits are fully reversible —
+select *Restore Claude Code* from the menu at any time and the originals
+are restored byte-for-byte.
+
+## Commands
+
+Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
+
+| Command | Description |
+| --- | --- |
+| Kickbacks: Sign in | Authenticate with Google. |
+| Kickbacks: Sign out | Sign out and stop earning. |
+| Kickbacks: Restore Claude Code | Revert to unpatched Claude Code. |
+| Kickbacks: Menu | Open the full Kickbacks menu. |
+
+## Compatibility
+
+Works with both the **VS Code extensions** (Claude Code and Codex) and the
+**Claude Code terminal CLI**:
+
+| Surface | Where | Requirements |
+| --- | --- | --- |
+| Spinner overlay | Claude Code / Codex VS Code panel | Compatible extension build |
+| Status-bar line | Claude Code terminal CLI | Any Claude Code version |
+| Spinner verb | Claude Code terminal CLI | Claude Code **2.1.143+** |
+
+VS Code surfaces work on local VS Code, Cursor, Remote-SSH, and devcontainers.
+The terminal status-bar line works on every Claude Code CLI; the
+terminal spinner-verb ad needs Claude Code 2.1.143 or newer (older CLIs simply
+keep their stock verbs — nothing breaks).
+
+If Kickbacks can't find a compatible target, it does nothing. It will never
+break your editor or your terminal.
+
+## FAQ
+
+**Does this affect Claude Code or Codex?**
+No. The only change is the spinner text and the idle usage banner. All Claude Code
+and Codex features work exactly as before.
+
+**How do I get paid?**
+Earnings are tracked on the Kickbacks backend. Visit [kickbacks.ai](https://kickbacks.ai)
+to view your balance and set up payouts.
+
+**Can I turn it off?**
+Yes. Click the status bar and select *Disable Kickbacks*, or run *Restore Claude Code*
+to fully revert.
+
+**Is my code safe?**
+Kickbacks has no access to your code, prompts, or AI responses. It only modifies
+the spinner display text.
+
+---
+
+<p align="center">
+  <a href="https://kickbacks.ai">kickbacks.ai</a> ·
+  <a href="https://github.com/andrewmccalip/kickbacks.ai">GitHub</a>
+</p>
diff --git a/scripts/_brand.mjs b/scripts/_brand.mjs
new file mode 100644
index 0000000..45dcba6
--- /dev/null
+++ b/scripts/_brand.mjs
@@ -0,0 +1,147 @@
+// Shared Kickbacks brand renderer. The mark is the "K$" wordmark in Montserrat
+// 800 (white) on a vertical green gradient rounded square — a real typeface, so
+// it's rasterized with headless Chromium (Playwright) rather than drawn
+// procedurally. Montserrat is pulled from Google Fonts at render time.
+//
+// Used by gen-icon.mjs (marketplace icon) and gen-logos.mjs (full asset set).
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { join } from "node:path";
+
+// --- brand constants ---
+export const GRAD_TOP = "#2aa44f";
+export const GRAD_BOT = "#147a34";
+export const GREEN = "#188a45";      // solid brand green (mono mark)
+export const INK = "#17181a";
+export const RADIUS_RATIO = 0.22;    // rounded-square corner radius
+export const FONT_RATIO = 0.47;      // K$ cap size vs icon size
+export const FONT_STACK =
+  "Montserrat, -apple-system, 'Segoe UI', Arial, sans-serif";
+
+// Resolve playwright-core from one of the repo's e2e installs (no dedicated dep
+// in extension/). Portable: paths are relative to the repo root.
+export function loadChromium() {
+  const root = fileURLToPath(new URL("../../", import.meta.url));
+  const candidates = [
+    "site/e2e",
+    "test-stack/site-e2e",
+    "test-stack/stripe-e2e",
+    "test-stack",
+  ].map((p) => join(root, p, "package.json"));
+  for (const c of candidates) {
+    try {
+      return createRequire(c)("playwright-core").chromium;
+    } catch {}
+  }
+  try {
+    return createRequire(import.meta.url)("playwright-core").chromium;
+  } catch {}
+  throw new Error(
+    "playwright-core not found. Install it (npm i -D playwright-core && npx playwright install chromium) " +
+    "or run from a checkout that has site/e2e installed."
+  );
+}
+
+const FONT_LINKS = `
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@700;800&display=swap" rel="stylesheet">`;
+
+function iconHTML(size) {
+  const r = Math.round(size * RADIUS_RATIO);
+  const fs = Math.round(size * FONT_RATIO);
+  return `<!doctype html><html><head><meta charset="utf-8">${FONT_LINKS}
+<style>html,body{margin:0;background:transparent}
+#mark{width:${size}px;height:${size}px;border-radius:${r}px;
+background:linear-gradient(180deg,${GRAD_TOP} 0%,${GRAD_BOT} 100%);
+display:flex;align-items:center;justify-content:center;color:#fff;
+font-family:${FONT_STACK};font-weight:800;font-size:${fs}px;line-height:1;
+letter-spacing:-.02em}</style></head>
+<body><div id="mark">K$</div></body></html>`;
+}
+
+function lockupHTML(height) {
+  const badge = Math.round(height * 0.92);
+  const r = Math.round(badge * RADIUS_RATIO);
+  const bfs = Math.round(badge * FONT_RATIO);
+  const wfs = Math.round(height * 0.62);
+  return `<!doctype html><html><head><meta charset="utf-8">${FONT_LINKS}
+<style>html,body{margin:0;background:transparent}
+#lk{display:inline-flex;align-items:center;gap:${Math.round(height * 0.22)}px;padding:${Math.round(height * 0.12)}px}
+#b{width:${badge}px;height:${badge}px;border-radius:${r}px;flex:none;
+background:linear-gradient(180deg,${GRAD_TOP} 0%,${GRAD_BOT} 100%);
+display:flex;align-items:center;justify-content:center;color:#fff;
+font-family:${FONT_STACK};font-weight:800;font-size:${bfs}px;line-height:1;letter-spacing:-.02em}
+#w{font-family:${FONT_STACK};font-weight:700;font-size:${wfs}px;color:${INK};
+letter-spacing:-.02em;line-height:1}</style></head>
+<body><div id="lk"><div id="b">K$</div><div id="w">Kickbacks.ai</div></div></body></html>`;
+}
+
+// Render a batch of icon sizes + an optional lockup with ONE browser launch.
+// Returns { icons: Map<size, Buffer>, lockup?: Buffer }.
+export async function renderAssets({ sizes = [], lockupHeight = 0 } = {}) {
+  const chromium = loadChromium();
+  const browser = await chromium.launch({ headless: true });
+  const icons = new Map();
+  let lockup;
+  try {
+    for (const size of sizes) {
+      const page = await browser.newPage({
+        viewport: { width: size + 40, height: size + 40 },
+        deviceScaleFactor: 1,
+      });
+      await page.setContent(iconHTML(size), { waitUntil: "networkidle" });
+      await page.evaluate(() => document.fonts.ready);
+      const buf = await page.locator("#mark").screenshot({ omitBackground: true });
+      icons.set(size, buf);
+      await page.close();
+    }
+    if (lockupHeight) {
+      const page = await browser.newPage({
+        viewport: { width: 1200, height: lockupHeight * 3 },
+        deviceScaleFactor: 2,
+      });
+      await page.setContent(lockupHTML(lockupHeight), { waitUntil: "networkidle" });
+      await page.evaluate(() => document.fonts.ready);
+      lockup = await page.locator("#lk").screenshot({ omitBackground: true });
+      await page.close();
+    }
+  } finally {
+    await browser.close();
+  }
+  return { icons, lockup };
+}
+
+// --- SVG variants (font-based; render true Montserrat where the font is
+// available, fall back to a bold sans elsewhere) ---
+export function markSVG({ box = true, fill = "#fff" } = {}) {
+  const rect = box
+    ? `\n  <defs><linearGradient id="kb" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${GRAD_TOP}"/><stop offset="1" stop-color="${GRAD_BOT}"/></linearGradient></defs>\n  <rect x="4" y="4" width="120" height="120" rx="28" fill="url(#kb)"/>`
+    : "";
+  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="Kickbacks.ai">${rect}
+  <text x="64" y="64" text-anchor="middle" dominant-baseline="central" font-family="${FONT_STACK}" font-weight="800" font-size="62" letter-spacing="-2" fill="${fill}">K$</text>
+</svg>`;
+}
+
+// --- ICO encoder (packs PNG buffers) ---
+export function toICO(entries) {
+  const count = entries.length;
+  const header = Buffer.alloc(6);
+  header.writeUInt16LE(0, 0);
+  header.writeUInt16LE(1, 2);
+  header.writeUInt16LE(count, 4);
+  let offset = 6 + count * 16;
+  const dir = [];
+  for (const { size, png } of entries) {
+    const e = Buffer.alloc(16);
+    e[0] = size >= 256 ? 0 : size;
+    e[1] = size >= 256 ? 0 : size;
+    e.writeUInt16LE(1, 4);
+    e.writeUInt16LE(32, 6);
+    e.writeUInt32LE(png.length, 8);
+    e.writeUInt32LE(offset, 12);
+    dir.push(e);
+    offset += png.length;
+  }
+  return Buffer.concat([header, ...dir, ...entries.map((e) => e.png)]);
+}
diff --git a/scripts/gen-icon.mjs b/scripts/gen-icon.mjs
new file mode 100644
index 0000000..db4623a
--- /dev/null
+++ b/scripts/gen-icon.mjs
@@ -0,0 +1,16 @@
+// Generates media/icon.png — the VS Code Marketplace icon. The mark is the "K$"
+// wordmark (Montserrat 800, white) on a vertical green gradient rounded square,
+// rasterized with headless Chromium (Montserrat is a real typeface, so it can't
+// be drawn procedurally). Requires playwright-core (resolved from the repo's e2e
+// install — see _brand.mjs). Run: `npm run icon`.
+import { writeFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { renderAssets } from "./_brand.mjs";
+
+const SIZE = 256; // crisp; Marketplace displays ~128
+const { icons } = await renderAssets({ sizes: [SIZE] });
+const png = icons.get(SIZE);
+const out = join(dirname(fileURLToPath(import.meta.url)), "..", "media", "icon.png");
+writeFileSync(out, png);
+console.log(`wrote ${out} (${png.length} bytes, ${SIZE}×${SIZE})`);
diff --git a/scripts/gen-logos.mjs b/scripts/gen-logos.mjs
new file mode 100644
index 0000000..146cd2d
--- /dev/null
+++ b/scripts/gen-logos.mjs
@@ -0,0 +1,50 @@
+// Generates the full Kickbacks logo asset set in extension/media/logos/.
+// The mark is the "K$" wordmark (Montserrat 800, white) on a vertical green
+// gradient rounded square. Raster sizes are produced with headless Chromium
+// (real typeface — see _brand.mjs); SVG variants are font-based.
+// Run: `node extension/scripts/gen-logos.mjs`.
+import { writeFileSync, mkdirSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { renderAssets, markSVG, toICO, GREEN } from "./_brand.mjs";
+
+const outDir = join(dirname(fileURLToPath(import.meta.url)), "..", "media", "logos");
+mkdirSync(outDir, { recursive: true });
+
+const sizes = [16, 32, 48, 64, 128, 180, 192, 256, 512];
+const { icons, lockup } = await renderAssets({ sizes, lockupHeight: 120 });
+
+const bySize = new Map();
+for (const size of sizes) {
+  const png = icons.get(size);
+  const name = `kickbacks-${size}.png`;
+  writeFileSync(join(outDir, name), png);
+  bySize.set(size, png);
+  console.log(`  ${name} (${png.length} bytes)`);
+}
+
+// favicon.ico (16, 32, 48)
+const ico = toICO([16, 32, 48].map((size) => ({ size, png: bySize.get(size) })));
+writeFileSync(join(outDir, "favicon.ico"), ico);
+console.log(`  favicon.ico (${ico.length} bytes, 3 sizes)`);
+
+// Derived web/social names
+writeFileSync(join(outDir, "favicon-32.png"), bySize.get(32));
+writeFileSync(join(outDir, "apple-touch-icon.png"), bySize.get(180));
+writeFileSync(join(outDir, "og-logo.png"), bySize.get(512));
+writeFileSync(join(outDir, "icon-192.png"), bySize.get(192));
+writeFileSync(join(outDir, "icon-512.png"), bySize.get(512));
+console.log("  favicon-32 / apple-touch-icon / og-logo / icon-192 / icon-512");
+
+// Horizontal lockup (mark + "Kickbacks.ai") for README / docs
+writeFileSync(join(outDir, "kickbacks-lockup.png"), lockup);
+console.log(`  kickbacks-lockup.png (${lockup.length} bytes)`);
+
+// SVG variants
+writeFileSync(join(outDir, "kickbacks.svg"), markSVG({ box: true, fill: "#fff" }));
+writeFileSync(join(outDir, "favicon.svg"), markSVG({ box: true, fill: "#fff" }));
+writeFileSync(join(outDir, "kickbacks-white.svg"), markSVG({ box: false, fill: "#fff" }));
+writeFileSync(join(outDir, "kickbacks-green.svg"), markSVG({ box: false, fill: GREEN }));
+console.log("  kickbacks.svg / favicon.svg / kickbacks-white.svg / kickbacks-green.svg");
+
+console.log(`\nDone! All assets in: ${outDir}`);
diff --git a/scripts/package.mjs b/scripts/package.mjs
new file mode 100644
index 0000000..9bfd5eb
--- /dev/null
+++ b/scripts/package.mjs
@@ -0,0 +1,115 @@
+import { spawnSync } from "node:child_process";
+import {
+  copyFileSync,
+  cpSync,
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  readdirSync,
+  rmSync,
+  unlinkSync,
+  writeFileSync,
+} from "node:fs";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+// Packages the extension into TWO files:
+//   - kickbacks.vsix          — the canonical, STABLE artifact. The deploy
+//     uploads this to the fixed GCS object gs://kickbacks-vsix/kickbacks.vsix,
+//     so every running extension's self-update URL stays constant. Never
+//     rename this one.
+//   - kickbacks-<version>.vsix — a versioned copy with the version baked into
+//     the file name, so a built artifact on disk is instantly identifiable.
+// The version is the single source of truth in package.json (the deploy bumps
+// it before packaging, so this picks up the new number automatically).
+
+/** The versioned artifact name for a given semver. Pure; kept tiny but
+ *  separate so the naming convention has one definition. */
+export function versionedVsixName(version) {
+  return `kickbacks-${version}.vsix`;
+}
+
+const STABLE = "kickbacks.vsix";
+
+// scripts/package.mjs -> extension/
+const extDir = join(fileURLToPath(import.meta.url), "..", "..");
+const stageDir = join(extDir, ".vsce-stage");
+
+function packageManifestForVsce(pkg) {
+  const pj = JSON.parse(JSON.stringify(pkg));
+  pj.author ??= {
+    name: "Andrew McCalip",
+    url: "https://github.com/andrewmccalip",
+  };
+  pj.license ??= "SEE LICENSE IN LICENSE";
+  pj.homepage ??= "https://kickbacks.ai";
+  pj.bugs ??= { url: "https://github.com/andrewmccalip/kickbacks/issues" };
+  const c = pj.contributes?.commands;
+  if (c && !c.some((x) => x.command === "kickbacks.signOut")) {
+    const i = c.findIndex((x) => x.command === "kickbacks.signIn");
+    if (i >= 0) c.splice(i + 1, 0,
+      { command: "kickbacks.signOut", title: "Kickbacks: Sign out" });
+  }
+  pj.description = "Get paid while you code. Subtle, clickable ads in the Claude Code and Codex spinners — 50/50 revenue split to users.";
+  return pj;
+}
+
+function stagePackage(pkg) {
+  rmSync(stageDir, { recursive: true, force: true });
+  mkdirSync(stageDir, { recursive: true });
+  writeFileSync(join(stageDir, "package.json"),
+    JSON.stringify(packageManifestForVsce(pkg), null, 2) + "\n");
+
+  const readme = existsSync(join(extDir, "dist", "README.md"))
+    ? readFileSync(join(extDir, "dist", "README.md"), "utf8")
+    : readFileSync(join(extDir, "readme_extension.md"), "utf8");
+  writeFileSync(join(stageDir, "README.md"), readme);
+
+  copyFileSync(join(extDir, "LICENSE"), join(stageDir, "LICENSE"));
+  cpSync(join(extDir, "dist"), join(stageDir, "dist"), { recursive: true });
+  rmSync(join(stageDir, "dist", "README.md"), { force: true });
+  cpSync(join(extDir, "media"), join(stageDir, "media"), { recursive: true });
+  writeFileSync(join(stageDir, ".vscodeignore"), [
+    "node_modules/**",
+    "src/**",
+    "test/**",
+    "scripts/**",
+    "*.vsix",
+    "package-lock.json",
+    "tsconfig.json",
+    "vitest.config.ts",
+    "esbuild.mjs",
+    ".vsce-stage/**",
+    "",
+  ].join("\n"));
+}
+
+function run() {
+  const pkg = JSON.parse(readFileSync(join(extDir, "package.json"), "utf8"));
+  const { version } = pkg;
+  const versioned = versionedVsixName(version);
+
+  // Drop any stale versioned vsix from prior builds so the dir always holds
+  // exactly the current one (plus the stable kickbacks.vsix). Best-effort.
+  for (const f of readdirSync(extDir)) {
+    if (/^kickbacks-.*\.vsix$/.test(f) && f !== versioned) {
+      try { unlinkSync(join(extDir, f)); } catch { /* ignore */ }
+    }
+  }
+
+  stagePackage(pkg);
+
+  // shell:true so the vsce .cmd wrapper resolves on Windows (node_modules/.bin
+  // is on PATH under `npm run`). Package from a temporary staged root so build
+  // metadata never mutates tracked package.json / README.md.
+  const r = spawnSync("vsce",
+    ["package", "--no-dependencies", "-o", join(extDir, STABLE)],
+    { cwd: stageDir, stdio: "inherit", shell: true });
+  if ((r.status ?? 1) !== 0) process.exit(r.status ?? 1);
+  rmSync(stageDir, { recursive: true, force: true });
+
+  copyFileSync(join(extDir, STABLE), join(extDir, versioned));
+  console.error(`packaged ${STABLE} + ${versioned} (v${version})`);
+}
+
+run();
diff --git a/src/activation/adRotation.ts b/src/activation/adRotation.ts
new file mode 100644
index 0000000..d6bca04
--- /dev/null
+++ b/src/activation/adRotation.ts
@@ -0,0 +1,265 @@
+import type { PatchAd, PortfolioResponse } from "../portfolio/client";
+import { fetchPortfolioWithDemoFallback } from "../portfolio/client";
+import type { PortfolioClient } from "../portfolio/client";
+import type { TargetAdapter, PatchParams } from "../adapters/types";
+import type { DebugController } from "../debug";
+import type { AuthClient } from "../auth/client";
+import type { SessionState } from "../sessionState";
+import { canPatch, servingVerdict } from "../servingGate";
+import { dlog } from "../log";
+
+export interface AdRotationDeps {
+  adapter: TargetAdapter;
+  portfolio: PortfolioClient;
+  auth: AuthClient;
+  debugCtl: DebugController;
+  session: SessionState;
+  ccVersion: string;
+  port: number;
+  patchParams: PatchParams;
+  /** Mutable ref: the currently active ad. Updated in-place by rotation.
+   *  Null after a sign-out clear until the next portfolio apply. */
+  activeAdRef: { current: PatchAd | null };
+  /** Mutable ref: the current correlation id. Updated on ad change. */
+  corrRef: { current: string };
+  /** Mutable ref: the outer ad variable (closure-scope in activate). */
+  adRef: { current: PatchAd | null };
+  impDedupe: { reset(): void };
+  reapplyCodex: (() => void) | null;
+  /** Fired after every ad apply (initial, rotation, sign-in demo→real swap) so
+   *  other surfaces — notably the timer-driven CLI sync — can refresh NOW
+   *  instead of waiting for their own poll. Best-effort; must never throw. */
+  onAdApplied?: () => void;
+  timers: NodeJS.Timeout[];
+}
+
+export interface AdRotationState {
+  adQueue: PatchAd[];
+  rotationIdx: number;
+  rotationTimer: ReturnType<typeof setInterval> | null;
+  lastAdSetSig: string;
+  /** Monotonic refresh identity. Bumped by every FORCED refresh (the sign-in
+   *  demo→real swap) and by the sign-out clear; an in-flight refresh whose
+   *  captured epoch is stale by the time its fetch resolves discards the
+   *  response instead of reinstating outdated (e.g. demo) ads over the
+   *  just-applied ones. */
+  refreshEpoch: number;
+}
+
+/** Apply a new ad to the adapter + Codex, updating all shared refs.
+ *  `force` re-applies even when the adId is unchanged — needed for the
+ *  signed-out→signed-in swap, where the demo ad and the real ad can share an
+ *  adId but the real ad carries a real (user-crediting) session token that the
+ *  overlay's `activeAd` MUST pick up, or it would keep billing the demo token. */
+function applyAd(
+  next: PatchAd,
+  deps: AdRotationDeps,
+  state: AdRotationState,
+  force = false,
+): boolean {
+  // Serving gate (wave 2, audit #3/#9): the rotation tick and the 60s
+  // refresh were the un-gated writers that re-patched a killed install right
+  // after checkKill restored it. Killed / offline-frozen / user-disabled /
+  // canary-suspended ⇒ no ref swaps, no patch write, no new ad served; the
+  // 60s reassert re-applies on its own first healthy tick after recovery.
+  // Returns false when gated so refreshPortfolio knows NOT to latch the ad-set
+  // signature (an unchanged set must still re-apply after recovery).
+  if (!canPatch()) {
+    dlog("ext", "rotation.gated", { verdict: servingVerdict(), adId: next.adId });
+    return false;
+  }
+  const adChanged = force || next.adId !== deps.adRef.current?.adId;
+  deps.adRef.current = next;
+  deps.session.set({ hasAd: true });
+  deps.debugCtl?.setPortfolioAd(next.adText, next.clickUrl || "");
+  if (adChanged && deps.port > 0) {
+    deps.activeAdRef.current = next;
+    deps.corrRef.current = next.adId + "." + Math.random().toString(36).slice(2, 8);
+    deps.impDedupe.reset();
+    Object.assign(deps.patchParams, { adText: next.adText,
+      iconRef: next.iconRef, iconUrl: next.iconUrl,
+      clickUrl: next.clickUrl });
+    deps.adapter.applyPatch(deps.patchParams);
+    deps.reapplyCodex?.();
+    dlog("ext", "portfolio.rotated", { adId: next.adId, corr: deps.corrRef.current });
+  }
+  // Outside the adChanged/port guard on purpose: the forced sign-in swap shares
+  // an adId yet still needs the CLI surface re-synced to the real ad.
+  try { deps.onAdApplied?.(); } catch { /* best-effort */ }
+  return true;
+}
+
+function rotateNext(deps: AdRotationDeps, state: AdRotationState): void {
+  if (state.adQueue.length < 2) return;
+  state.rotationIdx = (state.rotationIdx + 1) % state.adQueue.length;
+  applyAd(state.adQueue[state.rotationIdx], deps, state);
+}
+
+async function refreshPortfolio(
+  deps: AdRotationDeps,
+  state: AdRotationState,
+  force = false,
+): Promise<void> {
+  try {
+    // Demo-aware: a signed-out client refreshes from the public demo portfolio
+    // so the preview rotates like the live product. A signed-in client uses the
+    // authed portfolio (real, user-crediting session tokens). `force` (the
+    // sign-in swap) re-applies even when the adId set is unchanged so the
+    // overlay drops the demo token for the real one.
+    // A forced refresh starts a new epoch so any slower refresh already in
+    // flight (e.g. the 60s timer's signed-out DEMO fetch racing the sign-in
+    // swap) is recognised as stale below and discarded instead of applied.
+    if (force) state.refreshEpoch++;
+    const epoch = state.refreshEpoch;
+    // Dead-token recovery (the "frozen ads" 401 loop, 2026-06-11): a client
+    // whose cached access token the server rejects used to 401 here every 60s
+    // FOREVER — the catch below swallowed it, no new inventory ever arrived,
+    // and every surface kept the last-baked creative. The fallback helper
+    // applies the same conservative ladder as activation: signed-out → demo;
+    // signed-in failure → ONE auth refresh (re-mint → real ads), demote to
+    // demo only on an authoritative rejection (token cleared), and hold —
+    // no demotion, no churn — on transient failures (offline/5xx).
+    const r = await fetchPortfolioWithDemoFallback(
+      deps.portfolio, deps.auth, deps.ccVersion,
+      // Active campaign scopes the piggybacked kill verdict (parity with
+      // the standalone /v1/killswitch poll's `campaign=` param).
+      deps.activeAdRef.current?.campaignId || "");
+    if (epoch !== state.refreshEpoch) {
+      // The world changed while this fetch was in flight (forced swap or
+      // sign-out clear): applying the response would reinstate stale ads.
+      dlog("ext", "portfolio.refresh_stale",
+        { epoch, current: state.refreshEpoch });
+      return;
+    }
+    if (!r || r.ads.length === 0) return;
+    const newSig = r.ads.map(a => a.adId).sort().join(",");
+    const adsChanged = force || newSig !== state.lastAdSetSig;
+    if (adsChanged) {
+      state.adQueue = r.ads;
+      state.rotationIdx = 0;
+      // Latch the signature only when the apply actually went through: a
+      // gated apply (kill/offline/disabled) must leave the sig unlatched so
+      // the first healthy refresh re-applies even an UNCHANGED ad set —
+      // otherwise recovery would serve the stale pre-kill creative until the
+      // inventory happened to change.
+      if (applyAd(state.adQueue[0], deps, state, force)) {
+        state.lastAdSetSig = newSig;
+      }
+      dlog("ext", "portfolio.refresh", { adId: state.adQueue[0].adId, queueLen: state.adQueue.length, changed: true, forced: force });
+      if (state.rotationTimer) clearInterval(state.rotationTimer);
+      if (state.adQueue.length > 1) {
+        state.rotationTimer = setInterval(() => rotateNext(deps, state), r.rotationIntervalMs);
+        deps.timers.push(state.rotationTimer);
+      }
+    } else {
+      // Same ad SET, but the server mints a FRESH session token on every fetch
+      // (300s TTL). Adopt the new tokens so the in-use token never ages out and
+      // starts 403-ing billable view events — WITHOUT re-patching the overlay
+      // (adText/clickUrl/iconRef are unchanged, so there's no visible churn and
+      // no reason to re-mint the loopback/corr). This closes the silent "billing
+      // stops after ~5 min on stable inventory" bug: pre-fix the refreshed
+      // response was discarded wholesale, so `activeAd.sessionToken` aged out.
+      //
+      // The `demo` stamp travels WITH the token: a mid-session demotion (token
+      // death → this refresh comes from the DEMO portfolio) can return the same
+      // adId set, and an adopted demo token on an object still marked demo:false
+      // would keep the status bar showing ads while signed out — bypassing its
+      // deliberate sign-in gate (statusBarAd keys on `!ad.demo`, audit BL-187).
+      // Same in reverse: a re-auth must clear the stamp so real ads aren't
+      // suppressed as demo.
+      const freshByAdId = new Map(r.ads.map((a) => [a.adId, a]));
+      state.adQueue = state.adQueue.map((a) => {
+        const fresh = freshByAdId.get(a.adId);
+        return fresh
+          ? { ...a, sessionToken: fresh.sessionToken, demo: fresh.demo }
+          : a;
+      });
+      const active = deps.activeAdRef.current;
+      const freshActive = active ? freshByAdId.get(active.adId) : undefined;
+      if (active && freshActive) {
+        deps.activeAdRef.current = {
+          ...active, sessionToken: freshActive.sessionToken,
+          demo: freshActive.demo };
+        if (deps.adRef.current && deps.adRef.current.adId === active.adId) {
+          deps.adRef.current = {
+            ...deps.adRef.current, sessionToken: freshActive.sessionToken,
+            demo: freshActive.demo };
+        }
+      }
+      dlog("ext", "portfolio.token_refreshed",
+        { queueLen: r.ads.length, rotationIdx: state.rotationIdx, adId: active?.adId });
+    }
+  } catch { /* prime directive */ }
+}
+
+/** Sign-out teardown: drop every leftover REAL ad so no surface keeps serving
+ *  — or billing — it signed-out. Empties the queue, disarms the rotation
+ *  timer, nulls the shared ad refs (statusBarAd / reassert / the loopback's
+ *  active-ad snapshot all key off them), and bumps the refresh epoch so an
+ *  in-flight refresh can't re-apply a stale response. The 60s refresh interval
+ *  stays armed on purpose: its next (demo or real) portfolio apply is what
+ *  re-populates the queue and re-arms rotation. */
+function clearAds(deps: AdRotationDeps, state: AdRotationState): void {
+  state.refreshEpoch++;
+  if (state.rotationTimer) {
+    clearInterval(state.rotationTimer);
+    state.rotationTimer = null;
+  }
+  state.adQueue = [];
+  state.rotationIdx = 0;
+  state.lastAdSetSig = "";
+  deps.adRef.current = null;
+  deps.activeAdRef.current = null;
+  deps.session.set({ hasAd: false });
+  dlog("ext", "rotation.cleared", {});
+}
+
+// Exactly one rotation subsystem exists per activation; setup replaces the
+// hook. Module-scoped so the sign-out command (activation/commands.ts) can
+// reach the live rotation state without threading the handle through
+// activate() → registerCommands.
+let liveClear: (() => void) | null = null;
+
+/** Clear the live rotation's ad state on sign-out — see clearAds(). Safe
+ *  no-op when rotation never set up (no ad at activation). Never throws. */
+export function clearAdRotationOnSignOut(): void {
+  try { liveClear?.(); } catch { /* best-effort */ }
+}
+
+/** The state object plus a `refreshNow` trigger. `refreshNow(force)` runs a
+ *  portfolio refresh immediately (used by the sign-in handler to swap demo ads
+ *  for real, user-crediting ones without waiting for the 60s timer). */
+export type AdRotationHandle = AdRotationState & {
+  refreshNow: (force?: boolean) => Promise<void>;
+  /** Sign-out teardown — see clearAds(). */
+  clear: () => void;
+};
+
+/** Set up the ad-rotation subsystem. Returns the live state augmented with a
+ *  `refreshNow` trigger (timers are pushed into the shared `deps.timers`). */
+export function setupAdRotation(
+  deps: AdRotationDeps,
+  portfolioResp: PortfolioResponse | null,
+): AdRotationHandle {
+  const state: AdRotationState = {
+    adQueue: portfolioResp?.ads ?? [],
+    rotationIdx: 0,
+    rotationTimer: null,
+    lastAdSetSig: (portfolioResp?.ads ?? []).map(a => a.adId).sort().join(","),
+    refreshEpoch: 0,
+  };
+  const initialRotationMs = portfolioResp?.rotationIntervalMs ?? 120_000;
+  if (state.adQueue.length > 1) {
+    state.rotationTimer = setInterval(() => rotateNext(deps, state), initialRotationMs);
+    deps.timers.push(state.rotationTimer);
+    dlog("ext", "rotation.init", { intervalMs: initialRotationMs, queueLen: state.adQueue.length });
+  }
+
+  deps.timers.push(setInterval(() => void refreshPortfolio(deps, state), 60_000));
+  // Augment the LIVE state object in place (don't copy — the timers mutate it).
+  const handle = state as AdRotationHandle;
+  handle.refreshNow = (force = false) => refreshPortfolio(deps, state, force);
+  handle.clear = () => clearAds(deps, state);
+  liveClear = handle.clear;
+  return handle;
+}
diff --git a/src/activation/bootCanary.ts b/src/activation/bootCanary.ts
new file mode 100644
index 0000000..478373f
--- /dev/null
+++ b/src/activation/bootCanary.ts
@@ -0,0 +1,121 @@
+import * as vscode from "vscode";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { existsSync, writeFileSync, mkdirSync, unlinkSync, statSync,
+         readFileSync } from "node:fs";
+import type { DebugController } from "../debug";
+import type { TargetAdapter } from "../adapters/types";
+import { canPatch, suspendServing } from "../servingGate";
+import { dlog } from "../log";
+import { errMsg } from "../util/errMsg";
+
+const CANARY_PATH = join(homedir(), ".vibe-ads", "boot.canary");
+const SETTLE_MS = 5_000;
+const CANARY_STALE_MS = 90 * 1000;
+const FIRST_RUN_KEY = "kickbacks.firstRun.completed";
+
+/** `firstRun` is true exactly once — the activation that flips
+ *  FIRST_RUN_KEY (i.e. the install). The crash-recovery path reports
+ *  false (no patch was applied, so a reload nudge would be a lie) and
+ *  leaves the key unset, so the next clean activation still counts.
+ *
+ *  `anyTargetCompatible` (optional) widens the clean-boot auto-enable gate
+ *  to EITHER webview target: a Codex-only machine (no/incompatible Claude
+ *  Code) must still persist K_ON on first run, or it never serves —
+ *  DebugController.apply() already treats a Codex-only patch as success.
+ *  Omitted ⇒ falls back to the Claude-only preflight (legacy call shape). */
+export async function setupBootCanary(
+  adapter: TargetAdapter,
+  debugCtl: DebugController,
+  ctx: vscode.ExtensionContext,
+  anyTargetCompatible?: boolean,
+): Promise<{ firstRun: boolean }> {
+  let firstRun = false;
+  let canaryFromCrash = false;
+  // The settle timer below may only unlink the canary whose content it owns
+  // (crash path: the file it observed; clean path: the token it wrote). The
+  // canary lives in the shared ~/.vibe-ads, so a second VS Code window (or a
+  // parallel test worker) may re-write it at any time — an unguarded unlink
+  // would strip THAT activation's crash protection.
+  let settleToken = "";
+  try {
+    if (existsSync(CANARY_PATH)) {
+      settleToken = readFileSync(CANARY_PATH, "utf8");
+      const age = Date.now() - statSync(CANARY_PATH).mtimeMs;
+      if (age < CANARY_STALE_MS) canaryFromCrash = true;
+    }
+  } catch { /* ignore */ }
+
+  if (canaryFromCrash) {
+    // Suspend the WHOLE session's automatic patch writers (wave 2, audit
+    // #14) — pre-fix only the calls below were skipped and the production
+    // activation path re-patched seconds after the toast. The suspension
+    // lifts only on an explicit user re-enable (DebugController.setOn(true)),
+    // matching the toast's "manually re-enable" wording.
+    suspendServing();
+    dlog("ext", "boot.canary.skip", {
+      reason: "prior activation didn't settle (likely VS Code crash mid-patch)" });
+    try {
+      await vscode.window.showWarningMessage?.(
+        "Kickbacks: prior activation didn't complete cleanly — skipping " +
+        "automatic patch this run. Click the status bar to manually " +
+        "re-enable once you're sure VS Code is stable.");
+    } catch { /* no-op (test mock may lack showWarningMessage) */ }
+  } else {
+    try {
+      mkdirSync(join(homedir(), ".vibe-ads"), { recursive: true });
+      settleToken = String(Date.now());
+      writeFileSync(CANARY_PATH, settleToken);
+    } catch { /* canary is best-effort */ }
+
+    try {
+      const targetOk = anyTargetCompatible ?? adapter.preflight().compatible;
+      // Auto-enable on a clean boot only when the consent gate allows it
+      // (never-toggled first run, or injection was ON before the last
+      // sign-out). PRESERVE a deliberate "Disable Kickbacks" — the old
+      // condition re-enabled on every boot, stomping an explicit opt-out
+      // (audit EXT-01 / 2A-02). canPatch() additionally blocks the
+      // auto-enable's setOn(true) apply on a persisted-kill boot (wave 2,
+      // audit #19) — setOn itself is the MANUAL path and stays ungated.
+      if (targetOk && canPatch() && !debugCtl.on()
+          && debugCtl.shouldAutoEnableOnSignIn()) {
+        await debugCtl.setOn(true);
+        dlog("ext", "boot.autoenable", { applied: true });
+      }
+      if (ctx.globalState.get<boolean>(FIRST_RUN_KEY) !== true) {
+        firstRun = true;
+        await ctx.globalState.update(FIRST_RUN_KEY, true);
+      }
+    } catch (e) {
+      dlog("ext", "boot.autoenable.error",
+        { msg: errMsg(e) });
+    }
+
+    await debugCtl.reapplyIfOn();
+
+    try {
+      const cycleKill = join(homedir(), ".vibe-ads", "no-boot-cycle.enabled");
+      if (existsSync(cycleKill)) {
+        dlog("ext", "boot.cycle.skip", { reason: "sentinel" });
+      } else {
+        dlog("ext", "boot.cycle.start", {});
+        const r = debugCtl.cyclePatch();
+        dlog("ext", "boot.cycle.done", { ok: r.ok, reason: r.reason });
+      }
+    } catch (e) {
+      dlog("ext", "boot.cycle.error",
+        { msg: errMsg(e) });
+    }
+  }
+
+  // Clear the canary once VS Code has been alive for SETTLE_MS — but only
+  // if it is still OUR canary (see settleToken above).
+  setTimeout(() => {
+    try {
+      if (readFileSync(CANARY_PATH, "utf8") === settleToken) {
+        unlinkSync(CANARY_PATH);
+      }
+    } catch { /* ignore */ }
+  }, SETTLE_MS).unref?.();
+  return { firstRun };
+}
diff --git a/src/activation/capWarning.ts b/src/activation/capWarning.ts
new file mode 100644
index 0000000..ad0a51d
--- /dev/null
+++ b/src/activation/capWarning.ts
@@ -0,0 +1,69 @@
+import * as vscode from "vscode";
+import type { EarningCap } from "../earnings/client";
+
+// Same red as the StatusBar "not earning" states (statusbar.ts RED). Kept in
+// sync by value, not import, so this item stays fully independent of the main
+// status-bar item (the whole point of the second-pill design).
+const RED = "#f85149";
+
+/** Format seconds-until-reset into a compact label: "<1m", "Nm", "NhMm"
+ *  (the trailing minutes are dropped when zero). No existing helper for this
+ *  exists in the extension, so it lives here, private to the cap pill. */
+export function formatReset(seconds: number): string {
+  const s = Math.max(0, Math.floor(seconds));
+  if (s < 60) return "<1m";
+  const mins = Math.floor(s / 60);
+  if (mins < 60) return `${mins}m`;
+  const h = Math.floor(mins / 60);
+  const m = mins % 60;
+  return m === 0 ? `${h}h` : `${h}h${m}m`;
+}
+
+/** A SECOND, dedicated status-bar item that appears ONLY while the user has hit
+ *  an earning cap — a red pill beside the green earnings item naming the cap
+ *  and when it resets. Deliberately independent of StatusBar: it never touches
+ *  the earnings item's reload-lock or ad-takeover logic, and the earnings
+ *  figure stays visible alongside it.
+ *
+ *  Both scopes render red; the icon distinguishes them ($(clock) hourly vs
+ *  $(warning) daily). Click opens the Kickbacks menu (where the cap + payout
+ *  are also explained). */
+export class CapWarning {
+  // Priority 999 sits just to the RIGHT of the earnings item (priority 1000)
+  // in the right-aligned cluster.
+  private item = vscode.window.createStatusBarItem(
+    vscode.StatusBarAlignment.Right, 999);
+  constructor() {
+    this.item.command = "kickbacks.debugMenu";
+    this.item.color = RED;
+  }
+
+  /** Paint the cap pill for the given cap state. Idempotent — safe to call on
+   *  every earnings poll; the reset countdown refreshes at poll resolution. */
+  show(cap: EarningCap): void {
+    const reset = formatReset(cap.resetSeconds);
+    // capUsd is digits-only from the backend (e.g. "10.00"); prepend "$" here,
+    // matching how StatusBar renders the earnings figure.
+    const usd = `$${cap.capUsd}`;
+    if (cap.scope === "hourly") {
+      this.item.text = `$(clock) Hourly cap · ${reset}`;
+      this.item.tooltip =
+        `Hourly earning cap reached (${usd}/hr). `
+        + `Earning resumes at the top of the hour (~${reset}). `
+        + `You can earn more after that — this isn't an error.`;
+    } else {
+      this.item.text = `$(warning) Daily cap · ${reset}`;
+      this.item.tooltip =
+        `Daily earning cap reached (${usd}). `
+        + `Earning resumes at 00:00 UTC (~${reset}). `
+        + `You've earned the max for today — this isn't an error.`;
+    }
+    this.item.color = RED;
+    this.item.show();
+  }
+
+  /** Hide the pill (under both ceilings, or any not-earning safety state). */
+  hide(): void { this.item.hide(); }
+
+  dispose(): void { this.item.dispose(); }
+}
diff --git a/src/activation/cliSync.ts b/src/activation/cliSync.ts
new file mode 100644
index 0000000..4244753
--- /dev/null
+++ b/src/activation/cliSync.ts
@@ -0,0 +1,224 @@
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { existsSync } from "node:fs";
+import * as vscode from "vscode";
+import type { TargetAdapter } from "../adapters/types";
+import type { AuthClient } from "../auth/client";
+import type { MetricsClient } from "../metrics/client";
+import type { DebugController } from "../debug";
+import type { PatchAd } from "../portfolio/client";
+import { ClaudeCliStatuslineAdapter } from "../adapters/claude-cli/adapter";
+import { detectClaudeCliSpinnerSupport } from "../adapters/claude-cli/cliVersion";
+import { notifyOutdatedCli } from "./outdatedCliNotice";
+import { CodexCliWrapperAdapter } from "../adapters/codex-cli/adapter";
+import { writeCliAdCache, cliSessionActive, shouldCountCliImpression,
+  shouldCountSpinnerImpression, FRESH_MS }
+  from "../adapters/claude-cli/cliAd";
+import { dlog, codexCliEnabled } from "../log";
+import { errMsg } from "../util/errMsg";
+import { cliMode, webviewMode } from "../modes";
+import { servingVerdict } from "../servingGate";
+import type { ActivationContext } from "./context";
+import { restoreCodexSafe } from "./commands";
+
+export interface CliSyncDeps {
+  actx: ActivationContext;
+  /** Extension context — used for globalState-deduped user notices. */
+  ctx: vscode.ExtensionContext;
+  adapter: TargetAdapter;
+  auth: AuthClient;
+  metrics: MetricsClient;
+  debugCtl: DebugController;
+  ccVersion: string;
+  /** Mutable ref for the outer `ad` variable. */
+  adRef: { current: PatchAd | null };
+  /** Mutable ref for the killed state. */
+  killedRef: { current: boolean };
+  /** Mutable ref for the test-override killed flag. */
+  overrideKilled?: boolean;
+  /** Codex reapply function from webview injection (may be null). */
+  reapplyCodex: (() => void) | null;
+}
+
+/** Resolve the npm-installed Codex CLI shim path, or null if not present. */
+export function locateCodexCliShim(): string | null {
+  try {
+    if (process.platform === "win32") {
+      const appData = process.env.APPDATA;
+      if (!appData) return null;
+      const p = join(appData, "npm", "codex.cmd");
+      return existsSync(p) ? p : null;
+    }
+    const cands = [
+      "/usr/local/bin/codex",
+      "/opt/homebrew/bin/codex",
+      join(homedir(), ".npm-global", "bin", "codex"),
+      join(homedir(), ".local", "bin", "codex"),
+    ];
+    for (const c of cands) if (existsSync(c)) return c;
+    return null;
+  } catch { return null; }
+}
+
+/** Handle returned by setupCliSync: an imperative trigger to re-sync the CLI
+ *  surface NOW (instead of waiting for the 60s timer). Used by the ad-apply
+ *  choke point so a fresh ad — sign-in demo→real swap or a rotation — reaches
+ *  `~/.claude/settings.json` immediately. Guarded + idempotent, so callers can
+ *  fire it freely. */
+export interface CliSyncHandle {
+  syncNow: () => void;
+}
+
+/** Set up the CLI status-line surface and its sync timer. Wires the
+ *  debug-menu reassert callbacks. Pushes timers into actx.timers. */
+export function setupCliSync(deps: CliSyncDeps): CliSyncHandle {
+  const {
+    actx, ctx, adapter, auth, metrics, debugCtl,
+    ccVersion, adRef, killedRef, reapplyCodex,
+  } = deps;
+  const overrideKilled = deps.overrideKilled;
+
+  // CLI status-line surface.
+  const cliSettingsPath = join(homedir(), ".claude", "settings.json");
+  actx.cliStatus = new ClaudeCliStatuslineAdapter(cliSettingsPath);
+
+  // Only count the spinner-verb impression once `claude --version` has
+  // POSITIVELY confirmed support. The adapter's `spinnerVerbsSupported`
+  // render flag is fail-open (defaults true so the verb is written on the
+  // common new-CLI case before detection resolves), but billing must wait
+  // for confirmation — otherwise the first synchronous sync on an old CLI
+  // counts a verb that never renders. See shouldCountSpinnerImpression.
+  let spinnerCountable = false;
+
+  // Codex CLI banner wrapper (off by default; sentinel-gated).
+  if (codexCliEnabled() && !actx.codexCliStatus) {
+    const codexShim = locateCodexCliShim();
+    if (codexShim) {
+      actx.codexCliStatus = new CodexCliWrapperAdapter(codexShim, homedir());
+      dlog("ext", "codex-cli.wired", { shim: codexShim });
+    } else {
+      dlog("ext", "codex-cli.skip", { reason: "shim-not-found" });
+    }
+  }
+
+  const syncCli = (): void => {
+    try {
+      // Serving gate (wave 2, audit #3): pre-fix this 60s tick compared only
+      // the TEST override, so a live kill never stopped the re-apply that
+      // checkKill's cliStatus.restore() had just undone. Three-way now:
+      //   freeze   (offline-unsure / canary-suspended) → neither write nor
+      //            restore — keep the on-disk state, no churn;
+      //   write    (healthy + enabled) → the normal apply path below;
+      //   restore  (confirmed kill / user-disabled) → fall through to the
+      //            restore branch, same as ad-lost/signed-out.
+      const verdict = servingVerdict();
+      if (verdict === "freeze") return;
+      const ad = adRef.current;
+      if (verdict === "write" && ad && auth.accessToken()
+          && overrideKilled !== true && cliMode() === "on") {
+        const pfCli = actx.cliStatus!.preflight();
+        if (pfCli.compatible) {
+          actx.cliStatus!.applyPatch({ tier: 0, adText: ad.adText,
+            iconRef: ad.iconRef, iconUrl: ad.iconUrl, clickToken: "", clickUrl: ad.clickUrl,
+            corr: "cli." + Math.random().toString(36).slice(2, 8),
+            loopbackPort: 0, loopbackToken: "", loopbackBase: "" });
+          writeCliAdCache(homedir(), { adText: ad.adText,
+            iconRef: ad.iconRef, iconUrl: ad.iconUrl, clickUrl: ad.clickUrl });
+          // Codex CLI wrapper.
+          if (actx.codexCliStatus) {
+            try {
+              const pfCx = actx.codexCliStatus.preflight();
+              if (pfCx.compatible) {
+                const r = actx.codexCliStatus.applyPatch({ tier: 0,
+                  adText: ad.adText, iconRef: ad.iconRef, iconUrl: ad.iconUrl,
+                  clickToken: "", clickUrl: ad.clickUrl,
+                  corr: "codex-cli." + ad.adId,
+                  loopbackPort: 0, loopbackToken: "", loopbackBase: "" });
+                dlog("ext", "codex-cli.applyPatch",
+                  { ok: r.ok, reason: r.reason });
+              } else {
+                dlog("ext", "codex-cli.skip", { reason: pfCx.reason });
+              }
+            } catch (e) {
+              dlog("ext", "codex-cli.error",
+                { msg: errMsg(e) });
+            }
+          }
+          if (shouldCountCliImpression({ signedIn: !!auth.accessToken(),
+              haveAd: true,
+              sessionActive: cliSessionActive(Date.now(), FRESH_MS),
+              adId: ad.adId, lastCountedAdId: actx.lastCliAdId })) {
+            actx.lastCliAdId = ad.adId;
+            const cliCorr = "cli." + ad.adId;
+            void metrics.send("impression_rendered", { adId: ad.adId,
+              campaignId: ad.campaignId, ccVersion,
+              corr: cliCorr, surface: "statusline" });
+            void metrics.send("impression_viewable", { adId: ad.adId,
+              campaignId: ad.campaignId, ccVersion,
+              corr: cliCorr, surface: "statusline",
+              sessionToken: ad.sessionToken });
+          }
+          // Spinner-verb surface: a distinct brand-impression surface from
+          // the statusline, deduped on its own counter. Gated on confirmed
+          // spinnerVerbs support — else nothing renders and we'd be billing
+          // for an invisible impression.
+          if (shouldCountSpinnerImpression({ supportConfirmed: spinnerCountable,
+              signedIn: !!auth.accessToken(),
+              haveAd: true,
+              sessionActive: cliSessionActive(Date.now(), FRESH_MS),
+              adId: ad.adId, lastCountedAdId: actx.lastCliSpinnerAdId })) {
+            actx.lastCliSpinnerAdId = ad.adId;
+            const spinnerCorr = "spinner." + ad.adId;
+            void metrics.send("impression_rendered", { adId: ad.adId,
+              campaignId: ad.campaignId, ccVersion,
+              corr: spinnerCorr, surface: "spinner" });
+            void metrics.send("impression_viewable", { adId: ad.adId,
+              campaignId: ad.campaignId, ccVersion,
+              corr: spinnerCorr, surface: "spinner",
+              sessionToken: ad.sessionToken });
+          }
+        }
+      } else {
+        actx.cliStatus!.restore();
+        try { actx.codexCliStatus?.restore(); } catch { /* ignore */ }
+      }
+    } catch { /* prime directive: never break activation */ }
+  };
+  // Detect whether the terminal CLI honours `spinnerVerbs` (CC >= 2.1.143),
+  // then reconcile. Async + non-blocking: the first sync below already wrote
+  // the verb (fail-open render). Once detection resolves we re-sync so a
+  // confirmed-new CLI starts counting the spinner impression, and a
+  // positively-old CLI evicts the optimistically-written key (render flag
+  // flips false) and never counts it.
+  void detectClaudeCliSpinnerSupport().then(({ ok, version, outdated }) => {
+    if (actx.cliStatus) actx.cliStatus.spinnerVerbsSupported = ok;
+    spinnerCountable = ok === true;
+    dlog("ext", "cli.spinnerVerbs", { supported: ok });
+    // Only nag on a POSITIVELY old CLI — never the fail-open (undetectable)
+    // path, which would warn installs we can't even probe. Deduped per version.
+    // Skip when the install is killed: a remotely-disabled extension renders no
+    // ads anyway, so an "update your CLI" toast would be pure noise. (killedRef
+    // is a live getter — detection resolves async, so this reads the kill state
+    // as of resolution.)
+    if (outdated && version && !killedRef.current) notifyOutdatedCli(ctx, version);
+    syncCli();
+  }).catch(() => { /* fail-open render default stays; never count */ });
+
+  syncCli();
+  actx.timers.push(setInterval(syncCli, 60_000));
+
+  debugCtl?.setReassert(() => {
+    try {
+      syncCli();
+      if (webviewMode() === "off") {
+        adapter.restore();
+        restoreCodexSafe(actx.codexAdapter);
+      }
+    } catch { /* prime directive */ }
+  });
+  debugCtl?.setReassertCodex(() => {
+    try { reapplyCodex?.(); } catch { /* prime directive */ }
+  });
+
+  return { syncNow: () => { try { syncCli(); } catch { /* prime directive */ } } };
+}
diff --git a/src/activation/cliTick.ts b/src/activation/cliTick.ts
new file mode 100644
index 0000000..8b774e8
--- /dev/null
+++ b/src/activation/cliTick.ts
@@ -0,0 +1,153 @@
+import type { LogTail } from "../activity/logTail";
+import type { MetricsClient } from "../metrics/client";
+import type { PatchAd } from "../portfolio/client";
+import { canPatch } from "../servingGate";
+import { cliMode } from "../modes";
+import { dlog } from "../log";
+
+// The dwell/billing loop for the TERMINAL statusline surface. The statusline
+// (and spinner-verb) ads are written into ~/.claude/settings.json by cliSync,
+// which emits one impression_viewable per ad — but until this module existed
+// NO TUI surface ever emitted view_tick, so a user consuming ads purely in
+// the terminal saw ads all day and earned nothing (the Steven incident,
+// 2026-06-10). Server-side, view_tick from surface:"statusline" is already
+// allowlisted, block-debited, cooldown-gated (5s shared per user+ad across
+// surfaces — so statusbar+statusline ticking the same ad can never
+// double-credit), and daily-capped; the whole gap was client emission.
+//
+// Activity signal: a LogTail pinned to the newest entrypoint:"cli" transcript
+// (locateClaudeCliLog). Ticks flow only while a terminal turn is actually
+// running (done=false; LogTail forces done after 90s of transcript silence),
+// which matches when the spinner is animating and the statusline is freshly
+// painted. Deliberately NOT gated on window focus: the surface lives in the
+// user's terminal (integrated or external), not in the VS Code chrome.
+
+const POLL_INTERVAL_MS = 1_000;
+const VIEW_TICK_INTERVAL_MS = 5_000;
+const FRESH_ACTIVITY_MS = 4_000;
+
+export interface CliTickDeps {
+  cliTail: Pick<LogTail, "current" | "activityAgeMs">;
+  metrics: MetricsClient;
+  adRef: { current: PatchAd | null };
+  killedRef: { current: boolean };
+  /** Live sign-in probe (auth.accessToken truthiness). */
+  signedIn: () => boolean;
+  /** True when cliSync's adapter can actually render the statusline (its
+   *  preflight().compatible) — billing must track a surface that paints. */
+  surfaceApplied: () => boolean;
+  ccVersion: string;
+  timers: NodeJS.Timeout[];
+  /** Injectable for tests; default to the shared modes/servingGate. */
+  cliModeFn?: () => string;
+  canPatchFn?: () => boolean;
+}
+
+export function setupCliTick(deps: CliTickDeps): void {
+  const {
+    cliTail, metrics, adRef, killedRef, signedIn, surfaceApplied,
+    ccVersion, timers,
+  } = deps;
+  const cliModeFn = deps.cliModeFn ?? cliMode;
+  const canPatchFn = deps.canPatchFn ?? canPatch;
+
+  let showing = false;
+  let shownAd: PatchAd | null = null;
+  let corr = "";
+  let viewTickTimer: NodeJS.Timeout | null = null;
+
+  // Same suspend clamp as the statusbar (audit #23): accrue per poll tick,
+  // capping any single gap, so a laptop sleep never bills as visible time.
+  const VISIBLE_GAP_CAP_MS = 2 * POLL_INTERVAL_MS;
+  let accruedVisibleMs = 0;
+  let lastAccrualMs = 0;
+  const accrueVisible = (): void => {
+    const now = Date.now();
+    const delta = now - lastAccrualMs;
+    if (delta > 0) accruedVisibleMs += Math.min(delta, VISIBLE_GAP_CAP_MS);
+    lastAccrualMs = now;
+  };
+
+  const track = (t: NodeJS.Timeout): NodeJS.Timeout => {
+    timers.push(t);
+    try { t.unref?.(); } catch { /* never disrupt */ }
+    return t;
+  };
+  const untrack = (t: NodeJS.Timeout | null): void => {
+    if (!t) return;
+    const i = timers.indexOf(t);
+    if (i >= 0) timers.splice(i, 1);
+  };
+
+  // Same token-freshness contract as the statusbar (audit #1): the 60s
+  // portfolio refresh REPLACES ad objects to adopt fresh session tokens
+  // (300s TTL) — re-read the live ad each billable emission and adopt its
+  // token when it is still the same ad.
+  const freshenToken = (): void => {
+    const live = adRef.current;
+    if (shownAd && live && live.adId === shownAd.adId
+        && live.sessionToken !== shownAd.sessionToken) {
+      shownAd = { ...shownAd, sessionToken: live.sessionToken };
+    }
+  };
+
+  const endShow = (): void => {
+    if (!showing || !shownAd) return;
+    accrueVisible();
+    if (viewTickTimer) {
+      clearInterval(viewTickTimer); untrack(viewTickTimer); viewTickTimer = null;
+    }
+    // No final impression_viewable here: cliSync owns the statusline's
+    // impression attribution (one per applied ad). This loop is ticks-only.
+    dlog("ext", "clitick.end",
+      { adId: shownAd.adId, visibleMs: accruedVisibleMs, corr });
+    showing = false;
+    shownAd = null;
+  };
+
+  const poll = (): void => {
+    try {
+      if (showing) accrueVisible();
+      const act = cliTail.current();
+      const age = cliTail.activityAgeMs();
+      const fresh = age !== null && age <= FRESH_ACTIVITY_MS;
+      const turnActive = act ? !act.done : fresh;
+      const ad = adRef.current;
+      const eligible = turnActive && !!ad && !ad.demo && signedIn()
+        && !killedRef.current && canPatchFn() && cliModeFn() === "on"
+        && surfaceApplied();
+
+      // Rotation swap mid-show: unlike the statusbar (which keeps painting
+      // the snapshot it showed), the statusline file is REWRITTEN with the
+      // new ad on rotation (cliSync.syncNow at the ad-apply choke point) —
+      // the old ad is no longer on screen, so end its session and let the
+      // next poll open one for the live ad.
+      if (showing && shownAd && ad && ad.adId !== shownAd.adId) endShow();
+
+      if (eligible && !showing) {
+        showing = true;
+        shownAd = ad;
+        accruedVisibleMs = 0;
+        lastAccrualMs = Date.now();
+        corr = "clitick." + ad!.adId + "."
+          + Math.random().toString(36).slice(2, 8);
+        dlog("ext", "clitick.show", { adId: ad!.adId, corr });
+        viewTickTimer = track(setInterval(() => {
+          if (!shownAd) return;
+          accrueVisible();
+          freshenToken();
+          metrics.send("view_tick", {
+            adId: shownAd.adId, campaignId: shownAd.campaignId,
+            ccVersion, corr, surface: "statusline",
+            visibleMs: accruedVisibleMs,
+            sessionToken: shownAd.sessionToken,
+          });
+        }, VIEW_TICK_INTERVAL_MS));
+      } else if (!eligible && showing) {
+        endShow();
+      }
+    } catch { /* prime directive: never break activation */ }
+  };
+
+  timers.push(setInterval(poll, POLL_INTERVAL_MS));
+}
diff --git a/src/activation/codexFallback.ts b/src/activation/codexFallback.ts
new file mode 100644
index 0000000..d3489bb
--- /dev/null
+++ b/src/activation/codexFallback.ts
@@ -0,0 +1,26 @@
+/** Codex discovery policy ("codex fallback").
+ *
+ *  Codex targeting turns on when the user opted in, OR when there is no
+ *  compatible Claude Code target on this machine. The S9 prime-directive
+ *  guard ("crashed Claude Code once" — see log.ts::codexEnabled) protects
+ *  machines RUNNING a compatible Claude Code, so they stay opt-in-only and
+ *  behave exactly as before. Where no compatible Claude Code exists there is
+ *  nothing of ours to crash it with — and without the fallback, Kickbacks on
+ *  a Codex-only machine is dead weight: a red "incompatible" status bar, no
+ *  sign-in, no serving (the bug a public-mirror PR reported).
+ *
+ *  An explicit opt-out always wins (support remediation: codex.disabled
+ *  sentinel / KICKBACKS_CODEX=0 — see log.ts::codexDisabled).
+ *
+ *  Pure on purpose: no fs/env reads here. extension.ts composes the inputs
+ *  (log.ts opt-in/opt-out primitives + the Claude adapter preflight), so the
+ *  whole policy is unit-testable as a truth table. */
+export function codexDiscoveryEnabled(i: {
+  optIn: boolean;
+  optOut: boolean;
+  claudeCompatible: boolean;
+}): boolean {
+  if (i.optOut) return false;
+  if (i.optIn) return true;
+  return !i.claudeCompatible;
+}
diff --git a/src/activation/commands.ts b/src/activation/commands.ts
new file mode 100644
index 0000000..bf833c3
--- /dev/null
+++ b/src/activation/commands.ts
@@ -0,0 +1,123 @@
+import * as vscode from "vscode";
+import type { TargetAdapter } from "../adapters/types";
+import type { DebugController } from "../debug";
+import type { AuthClient } from "../auth/client";
+import type { UpdateClient } from "../update/client";
+import type { SessionState } from "../sessionState";
+import type { SbState } from "../statusbar";
+import { buildLabel, buildVersion } from "../buildinfo";
+import { dlog } from "../log";
+import { errMsg } from "../util/errMsg";
+import { setWebviewMode } from "../modes";
+import { clearAdRotationOnSignOut } from "./adRotation";
+import { noteMetricsSignOut } from "../metrics/client";
+import { noteFleetSignalsSignOut } from "../fleetSignals";
+
+/** Byte-exact revert of the Codex target, never throws. Used at every CC
+ *  teardown site (kill / webview-off / Restore command / deactivate) so Codex
+ *  is never left patched when CC isn't. */
+export function restoreCodexSafe(codexAdapter: TargetAdapter | null): void {
+  try { codexAdapter?.restore(); } catch { /* ignore */ }
+}
+
+export function registerCommands(
+  ctx: vscode.ExtensionContext,
+  adapter: TargetAdapter,
+  codexAdapter: TargetAdapter | null,
+  auth: AuthClient,
+  debugCtl: DebugController,
+  statusBar: { set: (s: SbState) => void },
+  session: SessionState,
+  updater: UpdateClient,
+  ccVersion: string,
+  showActive: () => Promise<void>,
+): void {
+  const guardCmd = <T>(id: string, fn: () => Promise<T> | T) =>
+    async (): Promise<void> => {
+      try {
+        await fn();
+      } catch (e) {
+        dlog("ext", "cmd.err", {
+          id,
+          msg: errMsg(e),
+        });
+      }
+    };
+
+  const cmdSignIn = guardCmd("signIn", async () => {
+    if (await auth.signIn()) {
+      session.set({ signedIn: true, authHealthy: "ok" });
+      // Tiered auto-enable: turn injection back on for a first-run user
+      // (neverToggled) OR a user who was running ads before they signed out
+      // (K_PRESIGNOUT). A deliberate "Disable Kickbacks" survives — see
+      // DebugController.shouldAutoEnableOnSignIn(). Pre-fix this used the
+      // bare neverToggled() gate, so a sign-out (which writes K_ON=false) left
+      // the user permanently disabled on the next sign-in.
+      if (debugCtl?.shouldAutoEnableOnSignIn()) {
+        await debugCtl!.setOn(true);
+        session.set({ injectionOn: true });
+      }
+      // Consume the one-shot sign-out memory regardless of the decision.
+      await debugCtl?.clearSignOutMemory();
+      await showActive();
+    }
+  });
+  const cmdSignOut = guardCmd("signOut", async () => {
+    // Drop the rotation queue and shared ad refs BEFORE the restore: a
+    // rotation/refresh tick firing inside the doSignOut await could otherwise
+    // re-patch CC with the real ads it is about to give up. The next
+    // portfolio refresh re-applies (demo) ads with demo tokens. The metrics
+    // hook resets the demoted-stamp so post-sign-out demo traffic isn't
+    // mislabeled as a mid-session token death.
+    clearAdRotationOnSignOut();
+    noteMetricsSignOut();
+    // Drop piggybacked balances too — they belong to the old identity and
+    // must not repaint a green earnings bar after sign-out.
+    noteFleetSignalsSignOut();
+    await debugCtl?.doSignOut();
+    statusBar.set({ kind: "signed-out" });
+    session.set({ signedIn: false, authHealthy: "unknown",
+                  hasAd: false, injectionOn: false });
+  });
+  const cmdRestore = guardCmd("restore", async () => {
+    setWebviewMode(false);
+    await debugCtl.setOn(false);
+    session.set({ injectionOn: false, hasAd: false });
+  });
+  const cmdStatus = guardCmd("status", () => {
+    const st = auth.storageInfo();
+    vscode.window.showInformationMessage(
+      `Kickbacks (Claude Code ${ccVersion}) — ${
+        auth.signedIn() ? "signed in" : "signed out"} · ${buildLabel()}`
+      + ` · store: ${st.scheme}${
+        st.keyringDurable === false ? " (keyring not durable — using file)" : ""}`);
+  });
+  const cmdCheckUpdates = guardCmd("checkUpdates", async () => {
+    try {
+      const installed = await updater.checkOnce();
+      if (installed) {
+        void vscode.window.showInformationMessage?.(
+          "Kickbacks: update installed; the extension host will restart.");
+      } else {
+        void vscode.window.showInformationMessage?.(
+          `Kickbacks: already up to date (v${buildVersion()}).`);
+      }
+    } catch (e) {
+      void vscode.window.showErrorMessage?.(
+        `Kickbacks: update check failed — `
+        + `${errMsg(e, 200)}`);
+    }
+  });
+
+  ctx.subscriptions.push(
+    vscode.commands.registerCommand("kickbacks.signIn", cmdSignIn),
+    vscode.commands.registerCommand("vibe-ads.signIn", cmdSignIn),
+    vscode.commands.registerCommand("kickbacks.signOut", cmdSignOut),
+    vscode.commands.registerCommand("vibe-ads.signOut", cmdSignOut),
+    vscode.commands.registerCommand("kickbacks.restore", cmdRestore),
+    vscode.commands.registerCommand("vibe-ads.restore", cmdRestore),
+    vscode.commands.registerCommand("kickbacks.status", cmdStatus),
+    vscode.commands.registerCommand("vibe-ads.status", cmdStatus),
+    vscode.commands.registerCommand("kickbacks.checkUpdates", cmdCheckUpdates),
+  );
+}
diff --git a/src/activation/context.ts b/src/activation/context.ts
new file mode 100644
index 0000000..5bb80c8
--- /dev/null
+++ b/src/activation/context.ts
@@ -0,0 +1,37 @@
+import type { Loopback } from "../loopback";
+import type { DebugController } from "../debug";
+import type { TargetAdapter } from "../adapters/types";
+import type { ClaudeCliStatuslineAdapter } from "../adapters/claude-cli/adapter";
+import type { CodexCliWrapperAdapter } from "../adapters/codex-cli/adapter";
+
+/** Mutable activation-lifetime state shared by the extracted subsystems
+ *  and the extension.ts orchestrator. Created once in `activate()` and
+ *  disposed in `deactivate()`. Replaces the former module-scope `let`
+ *  variables so all mutable state is bundled in a single object. */
+export interface ActivationContext {
+  timers: NodeJS.Timeout[];
+  loopback: Loopback | null;
+  debugCtl: DebugController | null;
+  cliStatus: ClaudeCliStatuslineAdapter | null;
+  codexCliStatus: CodexCliWrapperAdapter | null;
+  ccAdapter: TargetAdapter | null;
+  codexAdapter: TargetAdapter | null;
+  lastCliAdId: string | null;
+  lastCliSpinnerAdId: string | null;
+}
+
+/** Create a fresh ActivationContext with all fields at their initial
+ *  (empty / null) values. */
+export function createActivationContext(): ActivationContext {
+  return {
+    timers: [],
+    loopback: null,
+    debugCtl: null,
+    cliStatus: null,
+    codexCliStatus: null,
+    ccAdapter: null,
+    codexAdapter: null,
+    lastCliAdId: null,
+    lastCliSpinnerAdId: null,
+  };
+}
diff --git a/src/activation/desyncDetector.ts b/src/activation/desyncDetector.ts
new file mode 100644
index 0000000..fef819b
--- /dev/null
+++ b/src/activation/desyncDetector.ts
@@ -0,0 +1,122 @@
+import * as vscode from "vscode";
+import { dlog } from "../log";
+import { desyncDecision } from "../reassert";
+
+export interface DesyncState {
+  lastApplyAt: number;
+  lastBlockStartAt: number;
+}
+
+/** Wired by extension.ts. All optional so the detector degrades to a passive
+ *  logger if a dep is missing (and keeps the old call shape working in tests).
+ *  - ccActivityAgeMs: independent "is the user using Claude Code right now"
+ *    signal (CC transcript mtime age). null/unknown => treated as idle.
+ *  - healthy: shouldReassert() snapshot — never act when signed-out / no ad /
+ *    kill-switched.
+ *  - hardReassert: the cheap file-identity nudge (cyclePatch for the debug
+ *    path, restore+applyPatch for the production path). Guarded; never throws. */
+export interface DesyncDeps {
+  ccActivityAgeMs: () => number | null;
+  healthy: () => boolean;
+  hardReassert: () => void;
+  /** True while the CC panel is in an active tool_use turn (sub-agent
+   *  running). Disruptive escalation (reload, toast) is deferred until
+   *  the turn completes. null = unknown. */
+  ccTurnActive?: () => boolean | null;
+}
+
+export function setupDesyncDetector(
+  state: DesyncState,
+  timers: NodeJS.Timeout[],
+  deps?: DesyncDeps,
+): void {
+  const startedAt = Date.now();
+  let cyclePatchTried = false;
+  let reloadTried = false;
+  let toastShownAt = 0;
+  let lastLoggedAt = 0;
+
+  timers.push(setInterval(() => {
+    try {
+      const now = Date.now();
+      const d = desyncDecision({
+        now,
+        startedAt,
+        lastApplyAt: state.lastApplyAt,
+        lastBlockStartAt: state.lastBlockStartAt,
+        ccActivityAgeMs: deps ? deps.ccActivityAgeMs() : null,
+        // No deps (legacy/test): stay passive — never escalate without a
+        // health snapshot to gate on.
+        healthy: deps ? deps.healthy() : false,
+        cyclePatchTried,
+        reloadTried,
+        toastShownAt,
+        ccTurnActive: deps?.ccTurnActive?.() ?? null,
+      });
+
+      // Overlay recovered (block.start observed since apply): re-arm the ladder.
+      if (d.reason === "in-sync") {
+        cyclePatchTried = false;
+        reloadTried = false;
+        return;
+      }
+      if (d.action === "none") return;
+
+      // Throttled desync log (observability) — at most once / 5 min.
+      if (now - lastLoggedAt >= 5 * 60_000) {
+        lastLoggedAt = now;
+        dlog("ext", "block.desync", {
+          action: d.action,
+          ageSinceApplyMs: now - state.lastApplyAt,
+          lastBlockStartAt: state.lastBlockStartAt,
+          note: "patched on disk but overlay telemetry silent while CC is "
+            + "active — webview cached the pre-patch module.",
+        });
+      }
+
+      if (d.action === "cycle") {
+        // Tier 1: cheapest disruption — re-mint the patch so the file's
+        // identity changes and VS Code re-evaluates the module. No reload.
+        cyclePatchTried = true;
+        dlog("ext", "block.desync.cycle", {});
+        try { deps?.hardReassert(); } catch { /* prime directive */ }
+        return;
+      }
+
+      if (d.action === "reload") {
+        // Tier 2: reload the focused webview's content (no window churn).
+        reloadTried = true;
+        dlog("ext", "block.desync.autoReload", {
+          note: "reloadWebviewContent targets the focused webview; "
+            + "if the CC panel isn't focused this may be a no-op",
+        });
+        void vscode.commands.executeCommand(
+          "workbench.action.webview.reloadWebviewContent")
+          .then(
+            () => dlog("ext", "block.desync.autoReload.dispatched", {}),
+            () => dlog("ext", "block.desync.autoReload.fail", {}),
+          );
+        return;
+      }
+
+      if (d.action === "toast") {
+        // Tier 3 (last resort, user-consented): a full window reload.
+        toastShownAt = now;
+        void (async () => {
+          try {
+            const choice = await vscode.window.showWarningMessage?.(
+              "Kickbacks: ads aren't loading in the Claude Code panel. "
+              + "Automatic heals didn't clear the cache. "
+              + "A full window reload will fix it.",
+              "Reload Window", "Dismiss");
+            if (choice === "Reload Window") {
+              await vscode.commands.executeCommand(
+                "workbench.action.reloadWindow");
+            }
+          } catch { /* toast is best-effort */ }
+        })();
+        return;
+      }
+    } catch { /* prime directive */ }
+  }, 30_000));
+}
diff --git a/src/activation/diagnose.ts b/src/activation/diagnose.ts
new file mode 100644
index 0000000..cebfd89
--- /dev/null
+++ b/src/activation/diagnose.ts
@@ -0,0 +1,138 @@
+import * as vscode from "vscode";
+import type { AdapterDiagnostics, TargetAdapter } from "../adapters/types";
+import { buildVersion, buildLabel, BUILD_TS } from "../buildinfo";
+import { debugEnabled, LOG_PATH } from "../log";
+
+/** Plain-English verdict + remedy for a Claude Code diagnosis. This is the line
+ *  that ends the guessing: it maps the raw flags to "what's wrong and what to
+ *  do", so a screenshot of the report is self-explanatory. */
+export function interpret(d: AdapterDiagnostics): string {
+  if (d.compatible) {
+    return d.isPatched
+      ? "VERDICT: OK — Claude Code is patchable and the ad block is live."
+      : "VERDICT: OK — Claude Code is patchable (block not applied yet this session).";
+  }
+  if (!d.targetExists)
+    return "VERDICT: Claude Code not found at the target path. Is the Claude Code "
+      + "extension installed? (No action for Kickbacks.)";
+  if (d.live.bareVerbPresent && !d.live.hasArray)
+    return "VERDICT: the verb words exist but NOT in a matchable array — Claude "
+      + "Code likely changed its bundle format. FIX: update the adapter's array "
+      + "regex/anchors (this is a real CC change, send this report to the dev).";
+  if (!d.live.bareVerbPresent && !d.backup.hasArray)
+    return "VERDICT: the verb array is missing from the live file and no good "
+      + "backup exists — the file was stripped/corrupted (an old patch). FIX: "
+      + "reinstall or update Claude Code to restore its original file, then reload.";
+  if (d.backup.exists && !d.backup.hasArray && d.live.hasArray)
+    return "VERDICT: stale backup but the live file is fine — should self-heal on "
+      + "the next apply. FIX: update Kickbacks to the latest build and reload.";
+  return "VERDICT: incompatible — verb array not located. Send this report to the dev.";
+}
+
+/** Codex inputs for the report. `adapter` is located for DIAGNOSIS regardless
+ *  of the serving policy (null = no Codex install on this machine); `policy`
+ *  is the boot-time codexFallback verdict + its inputs. Keeping both lets the
+ *  report explain the BUG-001 dual-install case — "Codex installed, serving
+ *  deliberately OFF" — instead of silently omitting the section. */
+export interface CodexDiagnostics {
+  adapter: TargetAdapter | null;
+  policy: { discoveryEnabled: boolean; optIn: boolean; optOut: boolean;
+            claudeCompatible: boolean };
+}
+
+/** Plain-English verdict for the Codex section. The dual-install case is the
+ *  one users actually hit (BUG-001): Codex installed alongside a working
+ *  Claude Code → serving is opt-in by design, not broken. */
+export function interpretCodex(
+  p: CodexDiagnostics["policy"], compatible: boolean,
+): string {
+  if (p.optOut)
+    return "VERDICT: Codex ad-serving is explicitly disabled "
+      + "(~/.vibe-ads/codex.disabled or KICKBACKS_CODEX=0).";
+  if (!p.discoveryEnabled)
+    return "VERDICT: Codex detected but Codex ad-serving is OFF — it is "
+      + "opt-in on machines with a working Claude Code (expected, not a bug). "
+      + "To serve ads in Codex too: set KICKBACKS_CODEX=1 or create "
+      + "~/.vibe-ads/codex.enabled, then reload.";
+  return compatible
+    ? "VERDICT: OK — Codex is a live ad target this session."
+    : "VERDICT: Codex targeted but incompatible — send this report to the dev.";
+}
+
+/** Render the full copyable diagnostic report. Pure (no I/O) for easy testing. */
+export function formatDiagnostics(
+  cc: TargetAdapter | null, codex: CodexDiagnostics | null,
+): string {
+  const L: string[] = [];
+  L.push("=== Kickbacks Diagnostics ===");
+  L.push(`extension version: ${buildVersion()}`);
+  L.push(`build: ${buildLabel()}  (BUILD_TS=${BUILD_TS || "dev"})`);
+  L.push(`debug enabled: ${debugEnabled()}`);
+  L.push(`debug log: ${LOG_PATH}`);
+  L.push("");
+  L.push("--- Claude Code ---");
+  const d = cc?.diagnose?.();
+  if (!d) {
+    L.push("(no diagnose() available)");
+  } else {
+    L.push(`target: ${d.target}`);
+    L.push(`target exists: ${d.targetExists}`);
+    L.push(`CC version: ${d.version}`);
+    L.push(`PREFLIGHT compatible: ${d.compatible}`);
+    if (d.reason) L.push(`preflight reason: ${d.reason}`);
+    L.push(`patch live (isPatched): ${d.isPatched}`);
+    L.push(`backup: exists=${d.backup.exists} hasArray=${d.backup.hasArray} `
+      + `hasBlock=${d.backup.hasBlock}`);
+    if (d.backup.path) L.push(`  backup path: ${d.backup.path}`);
+    L.push(`live: hasArray=${d.live.hasArray} bareVerbPresent=${d.live.bareVerbPresent}`);
+    L.push("");
+    L.push(interpret(d));
+  }
+  if (codex) {
+    L.push("");
+    L.push("--- Codex ---");
+    if (!codex.adapter) {
+      L.push("Codex (openai.chatgpt) not found on this machine.");
+    } else {
+      const pf = (() => {
+        try { return codex.adapter.preflight(); }
+        catch { return { compatible: false, reason: "preflight threw",
+                         version: null as string | null }; }
+      })();
+      L.push(`compatible: ${pf.compatible}`
+        + `${pf.reason ? ` reason=${pf.reason}` : ""} version=${pf.version}`);
+      const p = codex.policy;
+      L.push(`serving policy: ${p.discoveryEnabled ? "ON" : "OFF"} `
+        + `(optIn=${p.optIn} optOut=${p.optOut} `
+        + `claudeCompatible=${p.claudeCompatible})`);
+      L.push(interpretCodex(p, pf.compatible));
+    }
+  }
+  return L.join("\n");
+}
+
+/** Register the `Kickbacks: Diagnose` command (+ legacy alias). Opens the report
+ *  in an untitled editor AND copies it to the clipboard so the user can paste it
+ *  straight back. Registered early in activation so it works even when the build
+ *  is incompatible — which is exactly when it's needed. */
+export function registerDiagnoseCommand(
+  cc: TargetAdapter | null, codex: CodexDiagnostics | null,
+): vscode.Disposable[] {
+  const run = async (): Promise<void> => {
+    const report = formatDiagnostics(cc, codex);
+    try { await vscode.env.clipboard.writeText(report); } catch { /* best-effort */ }
+    try {
+      const doc = await vscode.workspace.openTextDocument(
+        { content: report, language: "text" });
+      await vscode.window.showTextDocument(doc, { preview: false });
+    } catch { /* best-effort */ }
+    try {
+      await vscode.window.showInformationMessage?.(
+        "Kickbacks diagnostics copied to clipboard.");
+    } catch { /* best-effort */ }
+  };
+  return [
+    vscode.commands.registerCommand("kickbacks.diagnose", run),
+    vscode.commands.registerCommand("vibe-ads.diagnose", run),
+  ];
+}
diff --git a/src/activation/earningsRefresh.ts b/src/activation/earningsRefresh.ts
new file mode 100644
index 0000000..453a4a5
--- /dev/null
+++ b/src/activation/earningsRefresh.ts
@@ -0,0 +1,163 @@
+import * as vscode from "vscode";
+import type { AuthClient } from "../auth/client";
+import type { EarningsClient } from "../earnings/client";
+import type { SessionState } from "../sessionState";
+import type { SbState } from "../statusbar";
+import type { EarningCap } from "../earnings/client";
+import { servingGateSnapshot } from "../servingGate";
+import { EARNINGS_STALE_MS, type FleetSignals } from "../fleetSignals";
+
+export interface EarningsRefreshResult {
+  showActive: () => Promise<void>;
+  scheduleEarningsRefresh: (delayMs?: number) => void;
+}
+
+export function setupEarningsRefresh(
+  auth: AuthClient,
+  earningsClient: EarningsClient,
+  session: SessionState,
+  statusBar: { set: (s: SbState) => void },
+  ccVersion: string,
+  ctx: vscode.ExtensionContext,
+  // Arbiter: true while the status-bar ad owns the item (set by statusBarAd).
+  // When an ad is showing, showActive keeps the earnings data fresh but does
+  // NOT paint over the ad — statusBarAd repaints the earnings label itself
+  // when the ad reverts. Defaults to "never showing" for callers that don't
+  // wire the arbiter (tests, other surfaces).
+  isAdShowing: () => boolean = () => false,
+  // The red cap-warning pill (a SEPARATE status-bar item). show() when a cap
+  // is hit, hide() in every not-earning safety state so it never lingers next
+  // to a kill/offline/sign-in bar. Defaults to a no-op for callers that don't
+  // wire it (tests, other surfaces) — back-compat.
+  capWarning: { show: (c: EarningCap) => void; hide: () => void } =
+    { show: () => {}, hide: () => {} },
+  // Fleet-signal store (fleet-chattiness fix): when the backend piggybacks
+  // `balances` (incl. the cap) on portfolio/metrics responses, showActive
+  // paints from the store and the standalone GET /v1/earnings stands down.
+  // null/old-backend (no `cap` key on the carrier) keeps today's behavior.
+  signals: FleetSignals | null = null,
+): EarningsRefreshResult {
+  let lastUsd: string | undefined;
+  let lastToday: string | undefined;
+
+  let pendingRefreshTimer: NodeJS.Timeout | null = null;
+  const scheduleEarningsRefresh = (delayMs = 2500): void => {
+    if (pendingRefreshTimer) clearTimeout(pendingRefreshTimer);
+    pendingRefreshTimer = setTimeout(() => {
+      pendingRefreshTimer = null;
+      void showActive();
+    }, delayMs);
+    try { pendingRefreshTimer.unref?.(); } catch { /* never disrupt */ }
+  };
+
+  const showActive = async (): Promise<void> => {
+    // Serving gate (wave 2, audit #4): pre-fix this 30s repaint clobbered
+    // the killed/offline/Off bar back to green "active ($… today)" within
+    // 30s of a kill or a deliberate "Disable Kickbacks". Safety/truth
+    // states always win (the Wave-1 status-bar contract): paint the gated
+    // state and never the earning label while the gate says no-serve.
+    const gate = servingGateSnapshot();
+    if (gate.kill === "confirmed") {
+      capWarning.hide();
+      statusBar.set({ kind: "killed" });
+      return;
+    }
+    if (gate.kill === "offline") {
+      capWarning.hide();
+      statusBar.set({ kind: "offline" });
+      return;
+    }
+    if (!auth.accessToken()) {
+      capWarning.hide();
+      statusBar.set({ kind: "signed-out" });
+      session.set({ signedIn: false });
+      return;
+    }
+    if (!gate.enabled || gate.suspended) {
+      // User-disabled (or crash-canary suspended): the red "Kickbacks: Off —
+      // click to re-enable" bar, the existing debug-OFF SbState.
+      capWarning.hide();
+      statusBar.set({ kind: "debug", on: false });
+      return;
+    }
+    // Fleet-signal fast path: fresh piggybacked balances from an AUTHED 2xx
+    // carrier replace the network fetch entirely. `capCapable` (the carrier
+    // had the `cap` KEY) is the new-backend marker — without it the
+    // standalone fetch below must keep running, it's the only cap source.
+    // The carrier being an authed 200 also vouches for auth health.
+    const snap = signals?.earningsSnapshot();
+    if (snap && snap.capCapable
+        && signals?.earningsFreshWithin(EARNINGS_STALE_MS)) {
+      lastUsd = snap.lifetimeUsd; lastToday = snap.todayUsd;
+      session.set({ signedIn: true, authHealthy: "ok" });
+      if (snap.cap) capWarning.show(snap.cap);
+      else capWarning.hide();
+      if (isAdShowing()) return;
+      statusBar.set({ kind: "active", version: ccVersion,
+                      usd: lastUsd, usdToday: lastToday });
+      return;
+    }
+    const r = await earningsClient.fetchDetailed();
+    // Token died during the await (sign-out / failed rotation mid-fetch):
+    // paint signed-out instead of a stale green "active" bar (audit #34).
+    if (!auth.accessToken()) {
+      capWarning.hide();
+      statusBar.set({ kind: "signed-out" });
+      session.set({ signedIn: false });
+      return;
+    }
+    if (r.outcome === "ok") {
+      lastUsd = r.earnings.lifetimeUsd; lastToday = r.earnings.todayUsd;
+      session.set({ signedIn: true, authHealthy: "ok" });
+      // Drive the red cap pill from the authoritative cap state. Done BEFORE
+      // the isAdShowing() guard below so the cap warning shows even while a
+      // status-bar ad owns the main earnings item (separate item).
+      if (r.earnings.cap) capWarning.show(r.earnings.cap);
+      else capWarning.hide();
+    } else if (r.outcome === "401") {
+      // Only a REAL backend 401 may raise the session-expired signal.
+      session.set({ signedIn: true, authHealthy: "401" });
+    } else {
+      // Transient failure (network blip / 5xx / malformed body): keep the
+      // previous authHealthy verdict — pre-fix this branch asserted "401"
+      // and the debug menu falsely showed "Sign in again — your session
+      // expired" on every offline poll (audit #34).
+      session.set({ signedIn: true });
+    }
+    // Don't clobber a live status-bar ad — it owns the item until it reverts,
+    // at which point statusBarAd calls showActive() again (adShowing=false)
+    // to paint these freshly-fetched figures.
+    if (isAdShowing()) return;
+    statusBar.set({ kind: "active", version: ccVersion,
+                    usd: lastUsd, usdToday: lastToday });
+  };
+
+  // Piggybacked balances repaint immediately — this replaces the old
+  // "schedule a /v1/earnings refetch ~2.5s after a billable event" with
+  // zero requests: the billed metrics RESPONSE delivered the numbers, and
+  // showActive's fast path above paints them without touching the network.
+  signals?.onEarningsUpdated(() => void showActive());
+
+  // Initial status bar state + sign-in nudge.
+  if (!auth.accessToken()) {
+    statusBar.set({ kind: "signed-out" });
+    const NUDGE_KEY = "kickbacks.signinNudge.shownAt";
+    const NUDGE_COOLDOWN_MS = 24 * 60 * 60 * 1000;
+    const lastShownAt = Number(ctx.globalState.get<number>(NUDGE_KEY) || 0);
+    if (Date.now() - lastShownAt > NUDGE_COOLDOWN_MS) {
+      void ctx.globalState.update(NUDGE_KEY, Date.now());
+      void (async () => {
+        try {
+          const choice = await vscode.window.showInformationMessage?.(
+            "Kickbacks: sign in to start earning on Claude Code spinner ads.",
+            "Sign in", "Later");
+          if (choice === "Sign in") {
+            await vscode.commands.executeCommand("kickbacks.signIn");
+          }
+        } catch { /* toast is best-effort */ }
+      })();
+    }
+  } else void showActive();
+
+  return { showActive, scheduleEarningsRefresh };
+}
diff --git a/src/activation/incompatNotice.ts b/src/activation/incompatNotice.ts
new file mode 100644
index 0000000..d4438fa
--- /dev/null
+++ b/src/activation/incompatNotice.ts
@@ -0,0 +1,36 @@
+import * as vscode from "vscode";
+import { dlog } from "../log";
+import type { PreflightResult, TargetAdapter } from "../adapters/types";
+
+/** One-time-per-version warning when Claude Code is genuinely unpatchable.
+ *
+ *  Tightly gated so the known transient/cosmetic "incompatible" flash never
+ *  nags the user:
+ *   - only the structural "verb array not found" miss — a real adapter probe
+ *     miss. "target not found" (CC absent) and transient apply/port failures
+ *     never fire here.
+ *   - never when the live file already carries our block: isPatched() ⇒ the ad
+ *     is serving and the label was a stale flash.
+ *   - deduped per CC version in globalState, so a reload never re-nags and only
+ *     a NEW breaking version notifies again.
+ *
+ *  Best-effort: never throws, never blocks activation.
+ */
+export function notifyIncompatible(
+  ctx: vscode.ExtensionContext,
+  adapter: TargetAdapter,
+  pf: PreflightResult,
+): void {
+  try {
+    if (!pf.reason?.includes("verb array not found")) return;
+    if (adapter.isPatched?.()) return;
+    const version = pf.version ?? "unknown";
+    const key = `kickbacks.incompatNotified:${version}`;
+    if (ctx.globalState.get<boolean>(key)) return;
+    void ctx.globalState.update(key, true);
+    dlog("ext", "incompat.notify", { version });
+    void vscode.window.showWarningMessage?.(
+      `Kickbacks couldn't find Claude Code ${version}'s spinner hook — ads are paused. `
+      + `Your editor is unaffected; run Kickbacks: Diagnose for details.`);
+  } catch { /* notification is best-effort */ }
+}
diff --git a/src/activation/outdatedCliNotice.ts b/src/activation/outdatedCliNotice.ts
new file mode 100644
index 0000000..68e6fc2
--- /dev/null
+++ b/src/activation/outdatedCliNotice.ts
@@ -0,0 +1,36 @@
+import * as vscode from "vscode";
+import { dlog } from "../log";
+import { SPINNER_VERBS_FLOOR, type SemVer } from "../adapters/claude-cli/cliVersion";
+
+/** One-time-per-version warning when the terminal `claude` CLI is POSITIVELY
+ *  detected older than the spinnerVerbs floor (2.1.143). On those builds the
+ *  spinner-verb ad surface is silently ignored, so the user sees stock verbs
+ *  and never learns their CLI is the reason — and their share never accrues
+ *  from that surface. We surface it instead of failing silent.
+ *
+ *  Gated like notifyIncompatible:
+ *   - caller only invokes this on a positively-detected old version, never on
+ *     the fail-open (undetectable / absent CLI) path — see SpinnerSupport.
+ *   - deduped per detected version in globalState, so a reload never re-nags
+ *     and only a different old version notifies again.
+ *
+ *  Best-effort: never throws, never blocks activation.
+ */
+export function notifyOutdatedCli(
+  ctx: vscode.ExtensionContext,
+  v: SemVer,
+): void {
+  try {
+    const cur = `${v[0]}.${v[1]}.${v[2]}`;
+    const floor =
+      `${SPINNER_VERBS_FLOOR[0]}.${SPINNER_VERBS_FLOOR[1]}.${SPINNER_VERBS_FLOOR[2]}`;
+    const key = `kickbacks.outdatedCliNotified:${cur}`;
+    if (ctx.globalState.get<boolean>(key)) return;
+    void ctx.globalState.update(key, true);
+    dlog("ext", "cli.outdated.notify", { version: v, floor: SPINNER_VERBS_FLOOR });
+    void vscode.window.showWarningMessage?.(
+      `Kickbacks: your Claude Code CLI (${cur}) is older than ${floor}, so `
+      + `spinner ads can't render. Run \`claude update\` (or reinstall the `
+      + `latest Claude Code), then reload the window.`);
+  } catch { /* notification is best-effort */ }
+}
diff --git a/src/activation/reloadNudge.ts b/src/activation/reloadNudge.ts
new file mode 100644
index 0000000..ebe39a9
--- /dev/null
+++ b/src/activation/reloadNudge.ts
@@ -0,0 +1,43 @@
+import * as vscode from "vscode";
+import { dlog } from "../log";
+
+const RELOAD_CMD = "workbench.action.reloadWindow";
+
+/** Aggressive post-install nudge. The patch lands during the first
+ *  activation, but the already-running Claude Code webview loaded the
+ *  pre-patch build — so a fresh install earns nothing until the window
+ *  reloads. Modal on purpose (a passive toast was getting missed); pairs
+ *  with the sticky red `needs-reload` status-bar state, which stays up
+ *  until the reload actually happens. Never throws. */
+export async function showInstallReloadNudge(): Promise<void> {
+  try {
+    const choice = await vscode.window.showInformationMessage?.(
+      "Kickbacks won't earn money until you reload.",
+      { modal: true,
+        detail: "Kickbacks is installed, but this window is still running "
+          + "the old Claude Code build. Reload to start collecting money — "
+          + "the status bar stays red until you do." },
+      "Reload Now");
+    dlog("ext", "installNudge.choice", { choice: choice || "dismissed" });
+    if (choice === "Reload Now")
+      await vscode.commands.executeCommand(RELOAD_CMD);
+  } catch { /* prime directive — a nudge must never break activation */ }
+}
+
+/** Shown on every successful interactive sign-in. The live demo→real ad
+ *  swap usually works without a reload, but the webview can hold a stale
+ *  module (block.desync) and silently not credit the user — a reload is
+ *  the one path that always works, so steer the user there. Never throws. */
+export async function showSignInReloadNudge(): Promise<void> {
+  try {
+    const choice = await vscode.window.showInformationMessage?.(
+      "You're signed in — reload to start collecting money.",
+      { modal: true,
+        detail: "A window reload makes sure every ad surface picks up your "
+          + "account, so impressions credit you from the very next spin." },
+      "Reload Now");
+    dlog("ext", "signinNudge.choice", { choice: choice || "dismissed" });
+    if (choice === "Reload Now")
+      await vscode.commands.executeCommand(RELOAD_CMD);
+  } catch { /* prime directive */ }
+}
diff --git a/src/activation/selfUpdate.ts b/src/activation/selfUpdate.ts
new file mode 100644
index 0000000..87675d2
--- /dev/null
+++ b/src/activation/selfUpdate.ts
@@ -0,0 +1,185 @@
+import * as vscode from "vscode";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { writeFileSync, readFileSync, statSync } from "node:fs";
+import { UpdateClient, isNewer } from "../update/client";
+import { timeoutFetch } from "../util/http";
+import { buildVersion } from "../buildinfo";
+import { dlog } from "../log";
+import { errMsg } from "../util/errMsg";
+import { DEFAULT_POLL_MS } from "../config";
+
+const UPD_KEY = "vibe-ads.update.attempted";
+const UPD_TRANSIENT_KEY = "vibe-ads.update.transient";
+// Single-slot SUCCESS record {k, v, ts} (trey-nag-loop 2026-06-11): the
+// artifact that last installed without throwing. Unlike the attempted ring
+// it has NO cooldown — a successfully-installed artifact must never
+// re-install/re-toast just because the user hasn't reloaded yet. A single
+// slot (not a ring) keeps the rollback contract working: a rollback artifact
+// differs from the latest success, so it is never suppressed by old history.
+const UPD_INSTALLED_KEY = "vibe-ads.update.installed";
+const UPD_COOLDOWN_MS = 30 * 60 * 1000;
+const UPD_TRANSIENT_COOLDOWN_MS = 15 * 60 * 1000;
+const UPD_RING_CAP = 16;
+
+type InstalledRec = { k: string; v: string; ts: number };
+
+function installedRec(ctx: vscode.ExtensionContext): InstalledRec | undefined {
+  const raw = ctx.globalState.get<unknown>(UPD_INSTALLED_KEY) as
+    InstalledRec | undefined;
+  return raw && typeof raw.k === "string" && typeof raw.v === "string"
+    ? raw : undefined;
+}
+
+type Ring = { k: string; ts: number }[];
+
+function updKey(v: string, sha?: string): string {
+  return sha ? `${v}@${sha.slice(0, 16)}` : v;
+}
+
+function ringGet(ctx: vscode.ExtensionContext, key: string): Ring {
+  const raw = ctx.globalState.get<unknown>(key);
+  return Array.isArray(raw) ? (raw as Ring).filter(
+    (e) => e && typeof e.k === "string" && typeof e.ts === "number") : [];
+}
+
+function ringSeen(ctx: vscode.ExtensionContext, key: string, k: string, cooldown: number): boolean {
+  const now = Date.now();
+  return ringGet(ctx, key).some((e) => e.k === k && now - e.ts < cooldown);
+}
+
+function ringMark(ctx: vscode.ExtensionContext, key: string, k: string): void {
+  const next = ringGet(ctx, key).filter((e) => e.k !== k);
+  next.push({ k, ts: Date.now() });
+  while (next.length > UPD_RING_CAP) next.shift();
+  void ctx.globalState.update(key, next);
+}
+
+export interface SelfUpdateResult {
+  updater: UpdateClient;
+  installVsix: (vsix: ArrayBuffer) => Promise<void>;
+}
+
+export function setupSelfUpdate(
+  ctx: vscode.ExtensionContext,
+  updateBase: string,
+  currentVersion: string,
+  localVsixPath: string | undefined,
+  lastLocalVsixMtime: number,
+  watchFileFn: typeof import("node:fs").watchFile,
+  timers: NodeJS.Timeout[],
+  updatePollIntervalMs: number | undefined,
+): SelfUpdateResult {
+  // Convergence check: this runs at ACTIVATION, i.e. after a window
+  // (re)load. If the build now running is still OLDER than the version we
+  // recorded as successfully installed, the install did not actually take
+  // (silent installExtension failure, rolled-back extensions dir, …) —
+  // clear the record so the updater gets ONE fresh attempt per activation
+  // (still rate-fenced by the attempted-ring cooldown), instead of being
+  // suppressed forever on a build that never converged.
+  {
+    const rec = installedRec(ctx);
+    if (rec && isNewer(rec.v, currentVersion)) {
+      dlog("ext", "selfupdate.notconverged",
+        { recorded: rec.v, running: currentVersion });
+      void ctx.globalState.update(UPD_INSTALLED_KEY, undefined);
+    }
+  }
+
+  const installVsix = async (vsix: ArrayBuffer): Promise<void> => {
+    const p = join(tmpdir(), `vibe-ads-update-${Date.now()}.vsix`);
+    writeFileSync(p, Buffer.from(vsix));
+    await vscode.commands.executeCommand(
+      "workbench.extensions.installExtension", vscode.Uri.file(p));
+    // Re-arm injection for the new build, but PRESERVE a deliberate user
+    // disable (audit EXT-01 / 2A-02). K_ON === false means the user explicitly
+    // ran "Disable Kickbacks"; undefined/true means default-on or already-on.
+    // The old unconditional `= true` stomped an explicit opt-out on every
+    // self-update, so the only durable opt-out was uninstall.
+    if (ctx.globalState.get<boolean>("kickbacks.debug.on") !== false) {
+      await ctx.globalState.update("kickbacks.debug.on", true);
+    }
+    dlog("ext", "selfupdate.installed", { path: p });
+    void (async () => {
+      try {
+        const choice = await vscode.window.showInformationMessage?.(
+          "Kickbacks updated. Reload window to activate the new build?",
+          { modal: false }, "Reload Window", "Later");
+        dlog("ext", "selfupdate.toast", { choice: choice || "dismissed" });
+        if (choice === "Reload Window") {
+          // The reload tears this extension host down mid-await, so the
+          // command "rejects" with Canceled on every successful reload —
+          // swallow it here instead of logging a misleading toast.err.
+          try {
+            await vscode.commands.executeCommand(
+              "workbench.action.reloadWindow");
+          } catch { /* expected: host dies under us */ }
+        }
+      } catch (e) {
+        dlog("ext", "selfupdate.toast.err",
+          { msg: errMsg(e) });
+      }
+    })();
+  };
+
+  // Local-vsix watcher
+  if (localVsixPath) {
+    let localMtime = lastLocalVsixMtime;
+    try {
+      watchFileFn(localVsixPath, { interval: 2000 }, (curr) => {
+        if (!curr.mtimeMs || curr.mtimeMs === localMtime) return;
+        localMtime = curr.mtimeMs;
+        try {
+          const bytes = readFileSync(localVsixPath);
+          dlog("ext", "selfupdate.local", { path: localVsixPath, bytes: bytes.length });
+          void installVsix(bytes.buffer.slice(
+            bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
+        } catch (e) {
+          dlog("ext", "selfupdate.local.err",
+            { msg: errMsg(e) });
+        }
+      });
+    } catch { /* never block activation */ }
+  }
+
+  const onUpdateAvailable = (info: { version: string; current: string;
+                                      rollback: boolean }) => {
+    try {
+      const msg = info.rollback
+        ? `Kickbacks: rolling back to v${info.version} (from v${info.current})…`
+        : `Kickbacks: v${info.version} available — installing now…`;
+      void vscode.window.showInformationMessage?.(msg);
+    } catch { /* toast best-effort */ }
+  };
+
+  // audit-2026-06-09 #38: passing bare global `fetch` here silently bypassed
+  // the class's timeoutFetch(120000) default — a black-holed manifest/VSIX
+  // connection (2A-01 hang class) would park checkOnce forever and, with the
+  // #31 single-flight guard, wedge every later poll behind it. Same 120s
+  // budget as the class default.
+  const updater = new UpdateClient(updateBase, currentVersion,
+      timeoutFetch(120000), installVsix, {
+    attempted: (v, sha) => ringSeen(ctx, UPD_KEY, updKey(v, sha), UPD_COOLDOWN_MS),
+    markAttempted: (v, sha) => { ringMark(ctx, UPD_KEY, updKey(v, sha)); },
+    installed: (v, sha) => installedRec(ctx)?.k === updKey(v, sha),
+    markInstalled: (v, sha) => {
+      void ctx.globalState.update(UPD_INSTALLED_KEY,
+        { k: updKey(v, sha), v, ts: Date.now() } satisfies InstalledRec);
+    },
+    transientFailed: (v, sha) =>
+      ringSeen(ctx, UPD_TRANSIENT_KEY, updKey(v, sha), UPD_TRANSIENT_COOLDOWN_MS),
+    markTransientFailed: (v, sha) => { ringMark(ctx, UPD_TRANSIENT_KEY, updKey(v, sha)); },
+    recordLkg: (v, vsix) => {
+      try {
+        const lkgPath = join(tmpdir(), `kickbacks-lkg-${v}.vsix`);
+        writeFileSync(lkgPath, vsix);
+        void ctx.globalState.update("vibe-ads.update.lkg", { v, path: lkgPath });
+      } catch { /* best-effort */ }
+    },
+  }, onUpdateAvailable);
+
+  timers.push(setInterval(() => void updater.checkOnce(),
+    updatePollIntervalMs || DEFAULT_POLL_MS));
+
+  return { updater, installVsix };
+}
diff --git a/src/activation/statusBarAd.ts b/src/activation/statusBarAd.ts
new file mode 100644
index 0000000..6e73674
--- /dev/null
+++ b/src/activation/statusBarAd.ts
@@ -0,0 +1,262 @@
+import type { LogTail } from "../activity/logTail";
+import type { MetricsClient } from "../metrics/client";
+import type { PatchAd } from "../portfolio/client";
+import type { SbState } from "../statusbar";
+import { canPatch } from "../servingGate";
+import { dlog } from "../log";
+
+const AD_DISPLAY_HOLD_MS = 6_000;
+const POLL_INTERVAL_MS = 1_000;
+const VIEW_TICK_INTERVAL_MS = 5_000;
+const FRESH_ACTIVITY_MS = 4_000;
+// Time-boxed show cycle: agentic sessions keep `thinking` true for HOURS
+// (every tool call rewrites the transcript), so "ad while thinking" used to
+// degenerate into "ad forever" and the earnings display never surfaced
+// (2026-06-10 report: "the ad never disappears"). A show now runs at most
+// AD_SHOW_MAX_MS continuously, then the bar rests on the earnings/balance
+// display for AD_REST_MS before the ad may return. Billing stops with each
+// show (endShow), so the cap also bounds continuous statusbar dwell.
+const AD_SHOW_MAX_MS = 60_000;
+const AD_REST_MS = 20_000;
+
+export interface StatusBarAdDeps {
+  logTail: LogTail;
+  metrics: MetricsClient;
+  // `set` returns false when the paint was suppressed (the needs-reload lock
+  // owns the bar) — a suppressed ad must never bill. A void-returning setter
+  // counts as painted.
+  statusBar: { set: (s: SbState) => boolean | void };
+  adRef: { current: PatchAd | null };
+  killedRef: { current: boolean };
+  ccVersion: string;
+  showActive: () => Promise<void>;
+  timers: NodeJS.Timeout[];
+  // Shared arbiter flag: set true while the ad owns the status-bar item so the
+  // periodic earnings refresh (showActive) won't paint over it. The poll also
+  // re-asserts the ad each tick as a backstop against other setters
+  // (kill/offline/incompatible) clobbering it mid-display.
+  barState: { adShowing: boolean };
+  // TERMINAL-activity signal (the Steven fix): a second LogTail pinned to the
+  // newest entrypoint:"cli" transcript (locateClaudeCliLog). A TUI-only user
+  // never produces panel activity — locateClaudeCodeLog returns "" for them
+  // by design (audit #24, desync-watchdog safety) — so without this signal
+  // the statusbar ad never engages and their billing silently dies. The CLI
+  // path is gated on `windowFocused` (below): a focused VS Code window with a
+  // live terminal turn means the bar is actually on screen.
+  cliTail?: Pick<LogTail, "current" | "activityAgeMs">;
+  // True when this VS Code window has OS focus (vscode.window.state.focused).
+  // Only consulted for the CLI-activity path — a turn running in an EXTERNAL
+  // terminal while VS Code is backgrounded must not bill a bar nobody sees.
+  // Absent (older call sites / tests) ⇒ the CLI path stays off entirely.
+  windowFocused?: () => boolean;
+}
+
+export function setupStatusBarAd(deps: StatusBarAdDeps): void {
+  const {
+    logTail, metrics, statusBar, adRef, killedRef,
+    ccVersion, showActive, timers, barState,
+    cliTail, windowFocused,
+  } = deps;
+
+  let showing = false;
+  let corr = "";
+  let revertTimer: NodeJS.Timeout | null = null;
+  let viewTickTimer: NodeJS.Timeout | null = null;
+  let shownAd: PatchAd | null = null;
+  let showStartedAt = 0;
+  let restUntil = 0;
+
+  // Visible-time accrual with a suspend clamp (the audit-#23 contract, same
+  // as the CC block): visibleMs used to be a raw wall-clock span
+  // (now - showStart), so a laptop suspend mid-show billed the whole sleep
+  // gap as visible time in the next view_tick / final impression_viewable.
+  // Accrue per timer tick instead, capping any single gap at 2 poll
+  // intervals — timer coalescing across a suspend then contributes at most
+  // one capped slice.
+  const VISIBLE_GAP_CAP_MS = 2 * POLL_INTERVAL_MS;
+  let accruedVisibleMs = 0;
+  let lastAccrualMs = 0;
+  const accrueVisible = (): void => {
+    const now = Date.now();
+    const delta = now - lastAccrualMs;
+    if (delta > 0) accruedVisibleMs += Math.min(delta, VISIBLE_GAP_CAP_MS);
+    lastAccrualMs = now;
+  };
+
+  // The per-show viewTick/revert timers must be tracked in `timers` so
+  // deactivate() (which clears actx.timers) stops them — otherwise a
+  // disable/uninstall mid-display leaves a viewTick interval firing
+  // metrics.send against a stale closure. We also splice on clear so the
+  // shared array doesn't grow one stale handle per show.
+  const track = (t: NodeJS.Timeout): NodeJS.Timeout => {
+    timers.push(t);
+    try { t.unref?.(); } catch { /* never disrupt */ }
+    return t;
+  };
+  const untrack = (t: NodeJS.Timeout | null): void => {
+    if (!t) return;
+    const i = timers.indexOf(t);
+    if (i >= 0) timers.splice(i, 1);
+  };
+
+  const stopViewTicks = (): void => {
+    if (viewTickTimer) {
+      clearInterval(viewTickTimer); untrack(viewTickTimer); viewTickTimer = null;
+    }
+  };
+
+  const clearRevert = (): void => {
+    if (revertTimer) {
+      clearTimeout(revertTimer); untrack(revertTimer); revertTimer = null;
+    }
+  };
+
+  // The 60s portfolio refresh adopts fresh session tokens by REPLACING the
+  // ad objects in adRef (server tokens carry a 300s TTL; adRotation's
+  // token-adopt branch builds new objects), so the `shownAd` snapshot keeps
+  // its aged token — on a show outliving the TTL every later billable event
+  // would 403. Re-read the live ad at each billable emission and, when it is
+  // still the SAME ad, adopt its token. Never changes which ad is displayed —
+  // token freshness only (audit #1).
+  const freshenToken = (): void => {
+    const live = adRef.current;
+    if (shownAd && live && live.adId === shownAd.adId
+        && live.sessionToken !== shownAd.sessionToken) {
+      shownAd = { ...shownAd, sessionToken: live.sessionToken };
+    }
+  };
+
+  const endShow = (): void => {
+    if (!showing || !shownAd) return;
+    accrueVisible();
+    const visibleMs = accruedVisibleMs;
+    stopViewTicks();
+    freshenToken();
+    metrics.send("impression_viewable", {
+      adId: shownAd.adId, campaignId: shownAd.campaignId,
+      ccVersion, corr, surface: "statusbar",
+      visibleMs, sessionToken: shownAd.sessionToken,
+    });
+    dlog("ext", "statusbar.ad.hide", { adId: shownAd.adId, visibleMs, corr });
+    showing = false;
+    shownAd = null;
+    barState.adShowing = false;
+  };
+
+  const poll = (): void => {
+    try {
+      // The 1s poll is the accrual clock while a show is live — each tick
+      // contributes at most VISIBLE_GAP_CAP_MS, so a suspend gap collapses
+      // to one capped slice instead of the whole sleep.
+      if (showing) accrueVisible();
+      const activity = logTail.current();
+      const activityAgeMs = logTail.activityAgeMs();
+      const freshTranscript = activityAgeMs !== null
+        && activityAgeMs <= FRESH_ACTIVITY_MS;
+      const panelThinking = activity ? !activity.done : freshTranscript;
+      // Terminal turns count too — but only while THIS window is focused
+      // (integrated-terminal TUI usage: the bar is visibly on screen). The
+      // panel path is checked first so the common case pays no extra stat.
+      let cliThinking = false;
+      if (!panelThinking && cliTail && windowFocused?.()) {
+        const cliAct = cliTail.current();
+        const cliAge = cliTail.activityAgeMs();
+        const cliFresh = cliAge !== null && cliAge <= FRESH_ACTIVITY_MS;
+        cliThinking = cliAct ? !cliAct.done : cliFresh;
+      }
+      const thinking = panelThinking || cliThinking;
+      const ad = adRef.current;
+      // Eligible to show the ad: Claude is thinking, we have a REAL (user-
+      // crediting) ad, and not killed. Demo ads (signed-out preview) are
+      // intentionally EXCLUDED from the status bar — the lower status-bar text
+      // must stay the red "Kickbacks: Sign in" prompt while signed out so the
+      // bar reads as a call-to-action, not as serving inventory. Demo ads still
+      // render in-window (the overlay surface); only this status-bar surface
+      // gates on sign-in. Kill / no-ad / demo are NOT just "skip" — if we're
+      // mid-display they must END the show (stop view_tick), else the timer
+      // keeps emitting view metrics for a gone ad. canPatch() folds in the
+      // serving gate so a deliberate "Disable Kickbacks" (or a crash-canary
+      // suspension) also ends the show and stops billing (wave 2, audit #4).
+      const eligible = thinking && !!ad && !ad.demo && !killedRef.current
+        && canPatch() && Date.now() >= restUntil;
+
+      // Show time-box: a continuous show hits its cap mid-thinking → end it
+      // (billing stops, impression_viewable fires with the real visible
+      // time), paint the earnings display IMMEDIATELY (that's the point —
+      // the balance must get screen time), and rest before re-showing.
+      if (showing && Date.now() - showStartedAt >= AD_SHOW_MAX_MS) {
+        endShow();
+        restUntil = Date.now() + AD_REST_MS;
+        clearRevert();
+        if (!killedRef.current) void showActive();
+        return;
+      }
+
+      if (eligible) {
+        clearRevert();
+        if (!showing) {
+          // Paint FIRST: a false return means the needs-reload lock owns the
+          // bar and the ad was never displayed — don't start the show and
+          // never bill (no impression_rendered, no view_tick). Audit #29.
+          if (statusBar.set({ kind: "ad", adText: ad!.adText }) === false) {
+            return;
+          }
+          showing = true;
+          accruedVisibleMs = 0;
+          lastAccrualMs = Date.now();
+          showStartedAt = Date.now();
+          shownAd = ad;
+          barState.adShowing = true;
+          corr = "statusbar." + ad!.adId + "." +
+            Math.random().toString(36).slice(2, 8);
+
+          dlog("ext", "statusbar.ad.show", { adId: ad!.adId, corr });
+          metrics.send("impression_rendered", {
+            adId: ad!.adId, campaignId: ad!.campaignId,
+            ccVersion, corr, surface: "statusbar",
+          });
+
+          viewTickTimer = track(setInterval(() => {
+            if (!shownAd) return;
+            accrueVisible();
+            freshenToken();
+            metrics.send("view_tick", {
+              adId: shownAd.adId, campaignId: shownAd.campaignId,
+              ccVersion, corr, surface: "statusbar",
+              visibleMs: accruedVisibleMs,
+              sessionToken: shownAd.sessionToken,
+            });
+          }, VIEW_TICK_INTERVAL_MS));
+        } else {
+          // Re-assert each tick: self-heals any clobber by another setter
+          // (the 30s earnings refresh, offline/incompatible) so whenever a
+          // view_tick fires, a visible ad was repainted within the last ~1s.
+          // A suppressed repaint (needs-reload lock engaged mid-show) means
+          // the ad is no longer visible — end the show so billing stops.
+          if (statusBar.set({ kind: "ad", adText: shownAd!.adText }) === false) {
+            endShow();
+          }
+        }
+      } else if (showing) {
+        // No longer eligible while mid-display. Always end the show so
+        // view_tick stops and impression_viewable fires with the real
+        // visible duration.
+        endShow();
+        // Schedule the 6s revert ONLY on a clean thinking→idle transition.
+        // showActive() itself branches on sign-in (it paints the signed-out
+        // label when there's no token), so demo and real both revert cleanly.
+        // When kill interrupted the show, that setter already owns the bar —
+        // don't paint over it.
+        if (!thinking && !killedRef.current && !revertTimer) {
+          revertTimer = track(setTimeout(() => {
+            untrack(revertTimer);
+            revertTimer = null;
+            void showActive();
+          }, AD_DISPLAY_HOLD_MS));
+        }
+      }
+    } catch { /* prime directive: never break activation */ }
+  };
+
+  timers.push(setInterval(poll, POLL_INTERVAL_MS));
+}
diff --git a/src/activation/webviewInjection.ts b/src/activation/webviewInjection.ts
new file mode 100644
index 0000000..e26b17d
--- /dev/null
+++ b/src/activation/webviewInjection.ts
@@ -0,0 +1,424 @@
+import type * as vscode from "vscode";
+import type { TargetAdapter, PatchParams } from "../adapters/types";
+import type { AuthClient } from "../auth/client";
+import type { DebugController } from "../debug";
+import type { SessionState } from "../sessionState";
+import type { PatchAd, PortfolioResponse } from "../portfolio/client";
+import type { PortfolioClient } from "../portfolio/client";
+import { newMetricEventUuid, type MetricsClient } from "../metrics/client";
+import type { LogTail } from "../activity/logTail";
+import type { TestHooks } from "../testHooks";
+import type { ActivationContext } from "./context";
+import type { SbState } from "../statusbar";
+import { Loopback } from "../loopback";
+import { bootLoopback } from "../util/loopbackBoot";
+import { dlog, debugEnabled } from "../log";
+import { errMsg } from "../util/errMsg";
+import { resolveBannerOn } from "../banner";
+import { webviewMode, bannerOverride } from "../modes";
+import { ImpressionDedupe } from "../metrics/dedupe";
+import { shouldReassert } from "../reassert";
+import { canPatch, canServeAds, servingVerdict } from "../servingGate";
+import type { DesyncState } from "./desyncDetector";
+import { setupAdRotation, type AdRotationDeps } from "./adRotation";
+
+/** Anti-misclick floor: a click within the first CLICK_THRESHOLD_MS of
+ *  cumulative ad visibility is logged but NOT forwarded to the metrics
+ *  ledger. 15s per product call. */
+const CLICK_THRESHOLD_MS = 15_000;
+
+/** Status decision for an injection cycle that can't (re)apply this tick — a
+ *  loopback port race (EADDRINUSE on reload/self-update) or a single applyPatch
+ *  failure. A live, already-patched block is STILL serving the ad, so it must
+ *  not be relabeled "incompatible": return `null` to defer to the active /
+ *  earnings state. `isPatched` is "ANY webview target is patched" — a serving
+ *  Codex block counts exactly like a serving Claude Code block (S9 dual
+ *  target). Only a genuinely un-patched install yields an incompatible
+ *  status. Pure + exported so the honest-label contract is unit-tested without
+ *  standing up the loopback. */
+export function applyMissStatus(
+  isPatched: boolean, ccVersion: string,
+): SbState | null {
+  return isPatched ? null : { kind: "incompatible", version: ccVersion };
+}
+
+export interface WebviewInjectionDeps {
+  ctx: vscode.ExtensionContext;
+  actx: ActivationContext;
+  adapter: TargetAdapter;
+  auth: AuthClient;
+  debugCtl: DebugController;
+  session: SessionState;
+  portfolio: PortfolioClient;
+  metrics: MetricsClient;
+  logTail: LogTail;
+  testHooks: TestHooks;
+  statusBar: { set: (s: SbState) => void };
+  ccVersion: string;
+  killed: boolean;
+  /** Mutated by the caller when the kill-switch state changes. */
+  killedRef: { current: boolean };
+  /** Mutable ref for the outer `ad` variable. */
+  adRef: { current: PatchAd | null };
+  portfolioResp: PortfolioResponse | null;
+  viewThresholdMs: number;
+  statusBarShowActive: () => Promise<void>;
+  scheduleEarningsRefresh: () => void;
+  desyncState: DesyncState;
+  /** Whether the Claude Code target preflighted compatible at activation.
+   *  False on a codex-only boot: the Claude applyPatch/reassert writers are
+   *  skipped (they'd fail anchor validation every tick anyway) and Codex is
+   *  the serving surface. Omitted ⇒ true (legacy callers/tests keep the
+   *  Claude-primary semantics). */
+  claudeCompatible?: boolean;
+  /** Forwarded to ad rotation: fired after each ad apply so the CLI surface can
+   *  re-sync immediately (sign-in swap / rotation) instead of waiting 60s. */
+  onAdApplied?: () => void;
+}
+
+export interface WebviewInjectionResult {
+  lbInfo: { port: number; base: string } | null;
+  reapplyCodex: (() => void) | null;
+  /** Production-path "hard" reassert: restore + re-applyPatch so the file's
+   *  identity changes and VS Code re-evaluates a stale-cached webview module.
+   *  Health-gated, guarded, never throws. Null when injection didn't set up
+   *  (no ad / killed / loopback unavailable). Used by the desync watchdog. */
+  cycleReassert: (() => void) | null;
+  /** Force an immediate portfolio refresh. Used by the sign-in handler to swap
+   *  the signed-out DEMO ad for a real, user-crediting one (and re-point the
+   *  overlay's billing token) without waiting for the 60s rotation timer. Null
+   *  when injection didn't set up (no ad at activation / killed). */
+  refreshPortfolioNow: ((force?: boolean) => Promise<void>) | null;
+}
+
+/** Boot the loopback, patch the webview, set up reassert + ad rotation.
+ *  Returns the loopback info and a Codex reapply function. */
+export async function setupWebviewInjection(
+  deps: WebviewInjectionDeps,
+): Promise<WebviewInjectionResult> {
+  const {
+    ctx, actx, adapter, auth, debugCtl, session, portfolio,
+    metrics, logTail, testHooks, statusBar, ccVersion, killedRef, adRef,
+    portfolioResp, viewThresholdMs, statusBarShowActive,
+    scheduleEarningsRefresh, desyncState,
+  } = deps;
+  const ad = adRef.current;
+  if (!ad || deps.killed || webviewMode() !== "on") {
+    return { lbInfo: null, reapplyCodex: null, cycleReassert: null,
+             refreshPortfolioNow: null };
+  }
+
+  // Stable snapshot of the activation-time ad.
+  let activeAd = ad;
+  let corr = activeAd.adId + "." + Math.random().toString(36).slice(2, 8);
+  const impDedupe = new ImpressionDedupe();
+
+  // Rotation/poll-lag attribution (audit #17): a rotation flips activeAd
+  // instantly, but the webview only learns of it from its own 10s /ad poll —
+  // so for up to 10s it keeps emitting the OLD ad's view events (and clicks),
+  // which used to be stamped with the NEW ad's adId/campaignId/sessionToken
+  // at arrival time (cross-campaign misattribution on every rotation
+  // boundary). Keep a small registry of recently-served ads, lazily refreshed
+  // from the live activeAd on every event/click, and attribute by the ad the
+  // webview CLAIMS (its `ad=` param) when we recently served it. The deployed
+  // block keys its view sessions by ad TEXT (viewShow(AD, …)) and sends that
+  // as `ad=`, so claims resolve by adId OR adText. A ~10s-stale session token
+  // is fine — the server TTL is 300s. Unknown/absent claims fall back to
+  // activeAd, exactly the pre-fix behavior.
+  const RECENT_ADS_MAX = 8;
+  type AdAttribution =
+    { adId: string; campaignId: string; sessionToken: string;
+      demo?: boolean };
+  const recentAds = new Map<string, AdAttribution & { adText: string }>();
+  const resolveAttribution = (claimed?: string): AdAttribution => {
+    const live = activeAd;
+    if (live) {
+      // Delete + re-set: bump recency and adopt a refreshed session token.
+      // The demo stamp travels with the token (BL-187 contract): a registry
+      // entry minted while signed out holds a demo:<client_id>-namespace
+      // token, and the namespace guard below needs to see that.
+      recentAds.delete(live.adId);
+      recentAds.set(live.adId, { adId: live.adId,
+        campaignId: live.campaignId, sessionToken: live.sessionToken,
+        demo: live.demo, adText: live.adText });
+      while (recentAds.size > RECENT_ADS_MAX) {
+        recentAds.delete(recentAds.keys().next().value as string);
+      }
+    }
+    if (claimed) {
+      const byId = recentAds.get(claimed);
+      if (byId) return byId;
+      let byText: AdAttribution | undefined;
+      for (const e of recentAds.values()) {   // last match = most recent
+        if (e.adText === claimed) byText = e;
+      }
+      if (byText) return byText;
+    }
+    return live;
+  };
+
+  // Token-namespace guard (2026-06-11): MetricsClient routes by LIVE auth
+  // state (token present → /v1/metrics, absent → /v1/metrics/demo), but the
+  // attribution above rides whatever session token the registry holds. A
+  // demo-era token POSTed on the authed route (or a real token on the demo
+  // route, after a sign-out) binds to the WRONG uid namespace and the server
+  // rejects it 403 "invalid or expired session_token" — every tick, for the
+  // life of the stale entry; in prod this showed as a chronic all-403 stream
+  // of full view sessions. The mismatch is decidable client-side with no
+  // clock assumptions: bill only when the token's namespace matches the
+  // route the send will take. Mismatched events are dropped with a dlog —
+  // they were 100%-guaranteed server rejects, so this loses no revenue.
+  const namespaceMismatch = (attr: AdAttribution): boolean =>
+    !!attr.demo === !!auth.accessToken();
+
+  const codexAdapter = actx.codexAdapter;
+  actx.loopback = new Loopback({
+    onEvent: (k, payload) => {
+      // Billing gate (wave 2, audit #3): the webview's pollAd ignores the
+      // empty /ad payload (it only adopts a NEW ad), so an already-running
+      // overlay keeps emitting view events straight through a confirmed kill
+      // or a deliberate disable. The extension side is the billing authority:
+      // drop the forwarding here so a stale overlay can render but never bill.
+      if (!canServeAds()) {
+        dlog("ext", "metric.gated", { event: k, verdict: servingVerdict() },
+          { corr });
+        return;
+      }
+      // Audit #17: bill the ad the webview claims (post-rotation poll lag),
+      // not necessarily the current activeAd — see resolveAttribution above.
+      const attr = resolveAttribution(payload.claimedAdId);
+      if (namespaceMismatch(attr)) {
+        dlog("ext", "metric.namespace_drop",
+          { event: k, adId: attr.adId, demo: !!attr.demo,
+            authed: !!auth.accessToken() }, { corr });
+        return;
+      }
+      const eventUuid = payload.eventUuid || newMetricEventUuid();
+      if (k !== "view_tick" && k !== "error_impression"
+          && !impDedupe.shouldSend(k, attr.adId, payload.surface)) {
+        dlog("ext", "metric.deduped",
+          { event: k, surface: payload.surface, eventUuid }, { corr });
+        return;
+      }
+      dlog("ext", "metric.send", { event: k, adId: attr.adId,
+        surface: payload.surface, visibleMs: payload.visibleMs, eventUuid },
+        { corr });
+      metrics.send(k, {
+        adId: attr.adId,
+        campaignId: attr.campaignId,
+        ccVersion,
+        corr,
+        sessionToken: attr.sessionToken,
+        ...payload,
+        eventUuid,
+      });
+      if (k === "view_threshold_met" || k === "impression_viewable"
+          || k === "error_impression") {
+        scheduleEarningsRefresh();
+      }
+    },
+    onClick: (_ct, surface, visibleMs, eventUuidFromLoopback, claimedAdId) => {
+      // Same billing gate as onEvent: a click on a stale (gated) overlay
+      // still opens the advertiser URL webview-side, but is never billed.
+      if (!canServeAds()) {
+        dlog("ext", "metric.gated", { event: "click",
+          verdict: servingVerdict() }, { corr });
+        return;
+      }
+      // Audit #17: a click on the OLD ad's anchor during the /ad poll lag
+      // must bill the OLD campaign/token, not the freshly-rotated one.
+      const attr = resolveAttribution(claimedAdId);
+      if (namespaceMismatch(attr)) {
+        dlog("ext", "metric.namespace_drop",
+          { event: "click", adId: attr.adId, demo: !!attr.demo,
+            authed: !!auth.accessToken() }, { corr });
+        return;
+      }
+      const eventUuid = eventUuidFromLoopback || newMetricEventUuid();
+      if (typeof visibleMs === "number" && visibleMs < CLICK_THRESHOLD_MS) {
+        dlog("ext", "metric.click.early", { adId: attr.adId,
+          surface, visibleMs, thresholdMs: CLICK_THRESHOLD_MS, eventUuid },
+          { corr });
+        return;
+      }
+      dlog("ext", "metric.send", { event: "click", adId: attr.adId,
+        surface, visibleMs, eventUuid }, { corr });
+      metrics.send("click", { adId: attr.adId, campaignId: attr.campaignId,
+        ccVersion, corr, sessionToken: attr.sessionToken,
+        eventUuid, ...(surface ? { surface } : {}) });
+      scheduleEarningsRefresh();
+    },
+    getActivity: () => logTail.current() ?? {},
+    // Gate /ad (wave 2, audit #3): a confirmed kill or a deliberate disable
+    // stops handing out NEW ads. NOTE: an already-shown overlay is NOT
+    // dropped by this — pollAd ignores the empty payload and keeps the last
+    // creative painted until idle/reload. That is why onEvent/onClick above
+    // are also gated: the stale overlay can render but never bill.
+    // /activity and /log relay stay untouched.
+    getCurrentAd: () => activeAd && canServeAds() ? {
+      adText: activeAd.adText, clickUrl: activeAd.clickUrl,
+      iconUrl: activeAd.iconUrl, adId: activeAd.adId,
+      campaignId: activeAd.campaignId,
+    } : null,
+    onTestRoute: (n, p) => testHooks.handleTestRoute(n, p),
+    onWebviewLog: (raw) => {
+      try {
+        if (raw.includes('"block.start"') || raw.includes("block.start")) {
+          desyncState.lastBlockStartAt = Date.now();
+        }
+      } catch { /* best-effort */ }
+    },
+  });
+
+  const { port, token, base: lbBase } = await bootLoopback(actx.loopback, ctx);
+  const lbInfo = { port, base: lbBase };
+  dlog("ext", "loopback", { port, base: lbBase });
+
+  let patchParams: PatchParams = {
+    tier: 3, adText: activeAd.adText, iconRef: activeAd.iconRef,
+    iconUrl: activeAd.iconUrl, clickToken: "ck", clickUrl: activeAd.clickUrl,
+    corr, loopbackPort: port,
+    loopbackToken: token, loopbackBase: lbBase, debug: debugEnabled(),
+    bannerOn: resolveBannerOn(activeAd.bannerEnabled === true, bannerOverride()),
+    viewThresholdMs,
+  };
+
+  const claudeCompatible = deps.claudeCompatible ?? true;
+
+  // Honest status: a transient miss THIS cycle (a loopback port race on
+  // reload/self-update, or a single applyPatch failure) must NOT clobber a
+  // still-live, ad-serving block to a scary "incompatible" label. EITHER
+  // target counts — a serving Codex block keeps the label honest exactly
+  // like a serving Claude Code block.
+  const anyTargetPatched = (): boolean => {
+    try { if (adapter.isPatched?.() === true) return true; }
+    catch { /* fall through to codex */ }
+    try { return codexAdapter?.isPatched?.() === true; }
+    catch { return false; }
+  };
+  const setIncompatibleUnlessPatched = (): void => {
+    const s = applyMissStatus(anyTargetPatched(), ccVersion);
+    if (s) statusBar.set(s);
+    else void statusBarShowActive();
+  };
+
+  if (port < 0) {
+    // EADDRINUSE / port-exhaustion: skip the apply (but keep an existing patch's
+    // status honest — a stale loopback from the prior host doesn't mean the file
+    // is unpatched).
+    setIncompatibleUnlessPatched();
+    dlog("ext", "loopback.unavailable",
+      { port, patched: adapter.isPatched?.() === true });
+    return { lbInfo, reapplyCodex: null, cycleReassert: null,
+             refreshPortfolioNow: null };
+  }
+
+  // Serving gate (wave 2, audit #14/#19): a crash-canary suspension or a
+  // persisted kill must also stop THIS production-path write — pre-fix only
+  // the bootCanary's own debug-path calls were skipped, and the activation
+  // path re-patched seconds after the "skipping automatic patch" toast.
+  // A codex-only boot (claude incompatible) skips the doomed Claude write
+  // entirely: it would fail anchor validation and clobber the status bar.
+  if (!canPatch()) {
+    dlog("ext", "applyPatch.skip", { gate: servingVerdict() });
+  } else if (!claudeCompatible) {
+    dlog("ext", "applyPatch.skip", { reason: "claude-incompatible" });
+  } else {
+    const res = adapter.applyPatch(patchParams);
+    dlog("ext", "applyPatch", { ok: res.ok, reason: res.reason });
+    if (res.ok) {
+      desyncState.lastApplyAt = Date.now();
+      void statusBarShowActive();
+    }
+    else setIncompatibleUnlessPatched();
+  }
+
+  // S9: patch Codex with the SAME ad/loopback params. A Codex success drives
+  // the "active" status bar (it IS the serving surface on a codex-only boot)
+  // but must NEVER set desyncState.lastApplyAt — that would arm the CLAUDE
+  // webview-cache desync watchdog (cycle → reload → toast ladder) with no
+  // Claude apply to heal; lastApplyAt === 0 keeps it deliberately passive
+  // (reassert.ts::desyncDecision "no-apply").
+  const applyCodex = (): void => {
+    if (!codexAdapter) return;
+    if (!canPatch()) { dlog("ext", "codex.skip", { reason: "serving-gate" }); return; }
+    if (port < 0) { dlog("ext", "codex.skip", { reason: "no-loopback" }); return; }
+    try {
+      const cpf = codexAdapter.preflight();
+      if (!cpf.compatible) {
+        dlog("ext", "codex.skip", { reason: cpf.reason });
+        return;
+      }
+      const cr = codexAdapter.applyPatch(patchParams);
+      dlog("ext", "codex.applyPatch", { ok: cr.ok, reason: cr.reason });
+      if (cr.ok) void statusBarShowActive();
+    } catch (e) {
+      dlog("ext", "codex.error", { msg: errMsg(e) });
+    }
+  };
+  const reapplyCodex = applyCodex;
+  // Codex-only boot: apply NOW — the deferred call below would leave the
+  // first 10s of the session unserved (and the status bar unconfirmed).
+  // Idempotent: the 10s pass re-validates via isPatched/marker checks.
+  if (!claudeCompatible) applyCodex();
+  actx.timers.push(setTimeout(applyCodex, 10_000));
+
+  // Reassert the injection on a timer. The Claude branch is gated on the
+  // boot-time compatibility flag: a codex-only boot would otherwise retry a
+  // doomed anchor-validation failure every 60s (a mid-session CC install
+  // lands in a NEW versioned directory this adapter's fixed target can't
+  // see — only a reload re-preflights, same as today).
+  const reassertWebview = (): void => {
+    try {
+      // canPatch() folds in the serving gate (kill posture incl. the offline
+      // freeze, master toggle, canary suspension) — wave 2, audit #4/#9.
+      if (!canPatch() || !shouldReassert({
+          haveAd: !!adRef.current, killed: killedRef.current })) return;
+      if (claudeCompatible && adapter.isPatched?.() !== true) {
+        const r = adapter.applyPatch(patchParams);
+        if (!r.ok) dlog("ext", "reassert.skip", { reason: r.reason });
+      }
+      if (codexAdapter && codexAdapter.isPatched?.() !== true) {
+        applyCodex();
+      }
+    } catch { /* prime directive: never break activation */ }
+  };
+  actx.timers.push(setInterval(reassertWebview, 60_000));
+
+  // "Hard" reassert for the webview-cache desync (file is patched but the
+  // webview cached the pre-patch module, so isPatched()-gated reasserts can't
+  // see it). restore() + applyPatch() changes the file's identity, nudging VS
+  // Code to re-evaluate the module. Health-gated like reassertWebview; only
+  // ever invoked by the desync watchdog after sustained, CC-active silence.
+  const cycleReassert = (): void => {
+    try {
+      // Same gate as reassertWebview: the restore+re-apply cycle must never
+      // fire on a killed / frozen / disabled / suspended install (wave 2).
+      if (!canPatch() || !shouldReassert({
+          haveAd: !!adRef.current, killed: killedRef.current })) return;
+      if (claudeCompatible) {
+        adapter.restore();
+        const r = adapter.applyPatch(patchParams);
+        if (r.ok) desyncState.lastApplyAt = Date.now();
+        dlog("ext", "reassert.cycle", { ok: r.ok, reason: r.reason });
+      }
+      applyCodex();
+    } catch { /* prime directive: never break activation */ }
+  };
+
+  // Ad rotation subsystem.
+  const rotation = setupAdRotation({
+    adapter, portfolio, auth, debugCtl, session, ccVersion, port,
+    patchParams,
+    activeAdRef: { get current() { return activeAd; }, set current(v) { activeAd = v; } },
+    corrRef: { get current() { return corr; }, set current(v) { corr = v; } },
+    adRef,
+    impDedupe,
+    reapplyCodex,
+    onAdApplied: deps.onAdApplied,
+    timers: actx.timers,
+  } as AdRotationDeps, portfolioResp);
+
+  return { lbInfo, reapplyCodex, cycleReassert,
+           refreshPortfolioNow: rotation.refreshNow };
+}
diff --git a/src/activity/logTail.ts b/src/activity/logTail.ts
new file mode 100644
index 0000000..454b65c
--- /dev/null
+++ b/src/activity/logTail.ts
@@ -0,0 +1,153 @@
+import { existsSync, statSync, readSync, openSync, closeSync } from "node:fs";
+
+export interface Activity {
+  tool: string;
+  elapsedMs: number;
+  ts: number;
+  /** True when the latest assistant turn has finished (stop_reason set and
+   *  not "tool_use") — drives the in-slot completion freeze. */
+  done: boolean;
+}
+
+/** Best-effort, read-only tail of Claude Code's live JSONL session transcript
+ *  (`~/.claude/projects/<sanitized-cwd>/<session>.jsonl`). Each line is a JSON
+ *  record; assistant lines carry `message.content[]` (with `tool_use` blocks),
+ *  a `timestamp`, and `message.stop_reason`. Any error/miss yields null so the
+ *  injected block self-simulates. NEVER throws. The format is version-fragile
+ *  and deliberately non-load-bearing (spec §4.5) — the S5 matrix flags drift. */
+const IDLE_STALE_MS = 90_000;
+/** Min gap between resolver re-runs while the pinned file sits idle-stale, so
+ *  a simply-idle user doesn't re-glob the transcript tree on every poll. */
+const RERESOLVE_MIN_MS = 15_000;
+
+export class LogTail {
+  private firstSeen = Date.now();
+  private lastTool = "";
+  private path: string;
+  private lastReresolveAt = 0;
+  private readonly resolvePath?: () => string;
+
+  constructor(pathOrResolver: string | (() => string)) {
+    if (typeof pathOrResolver === "function") {
+      this.path = "";
+      this.resolvePath = pathOrResolver;
+    } else {
+      this.path = pathOrResolver;
+    }
+  }
+
+  private currentPath(): string {
+    try {
+      if (this.path && existsSync(this.path)) {
+        if (!this.resolvePath) return this.path;
+        // Transcripts are never deleted: a NEW chat session writes a NEW
+        // .jsonl while the pinned one just goes quiet. Once the pinned file
+        // is idle-stale, re-resolve (throttled) and adopt any newer
+        // transcript — otherwise the pin is forever (audit #2: dead statusbar
+        // ad, overlay txnIdle suppression, blind desync watchdog).
+        const age = Date.now() - statSync(this.path).mtimeMs;
+        if (age <= IDLE_STALE_MS) return this.path;
+        if (Date.now() - this.lastReresolveAt < RERESOLVE_MIN_MS) {
+          return this.path;
+        }
+        this.lastReresolveAt = Date.now();
+      }
+      const next = this.resolvePath?.() || "";
+      if (next && next !== this.path) {
+        this.path = next;
+        this.lastTool = "";
+        this.firstSeen = Date.now();
+      }
+      return this.path;
+    } catch { return this.path; }
+  }
+
+  /** Age in ms since Claude Code last wrote its session transcript, or null
+   *  when there's no readable transcript. An INDEPENDENT activity signal: it
+   *  reflects real CC usage (the user firing sessions), NOT our injected
+   *  overlay. The desync watchdog uses it to tell "user is using CC but our
+   *  ads aren't rendering" (heal) from "user is simply idle" (leave alone).
+   *  Cheaper than current() (a single stat, no read). Never throws. */
+  activityAgeMs(): number | null {
+    try {
+      const path = this.currentPath();
+      if (!path || !existsSync(path)) return null;
+      // Clamp: a just-written file's mtime can be a hair ahead of Date.now()
+      // (fs timestamp precision / clock skew), which would otherwise yield a
+      // nonsensical negative age.
+      return Math.max(0, Date.now() - statSync(path).mtimeMs);
+    } catch { return null; }
+  }
+
+  current(): Activity | null {
+    try {
+      const path = this.currentPath();
+      if (!path || !existsSync(path)) return null;
+      const st = statSync(path);
+      const size = st.size;
+      const staleMs = Date.now() - st.mtimeMs;
+      const want = Math.min(size, 128 * 1024);
+      if (want === 0) return null;
+      const fd = openSync(path, "r");
+      let text: string;
+      try {
+        const buf = Buffer.alloc(want);
+        readSync(fd, buf, 0, want, size - want);
+        text = buf.toString("utf8");
+      } finally { closeSync(fd); }
+
+      // A *partial* (mid-file) read may slice the first line — drop it.
+      // A full read (want === size, offset 0) has no partial line: keep all.
+      const lines = text.split("\n");
+      if (want < size) lines.shift();
+
+      let tool = "";
+      let done: boolean | null = null;   // null until we see an assistant line
+      let pendingUserAfterAssistant = false;
+      // Walk newest → oldest: first assistant line decides `done`; first
+      // tool_use block is the current/most-recent tool.
+      for (let i = lines.length - 1; i >= 0; i--) {
+        const ln = lines[i];
+        if (!ln) continue;
+        let o: Record<string, unknown>;
+        try { o = JSON.parse(ln); } catch { continue; }
+        const msg = o.message as Record<string, unknown> | undefined;
+        if (done === null && o.type === "user") {
+          pendingUserAfterAssistant = true;
+          continue;
+        }
+        if (o.type === "assistant" && msg) {
+          if (done === null) {
+            if (pendingUserAfterAssistant) {
+              // A fresh user prompt after the latest assistant line means the
+              // next assistant response has not landed yet. Treat it as active
+              // instead of inheriting the prior completed turn.
+              done = false;
+            } else {
+              const sr = msg.stop_reason as string | null | undefined;
+              // Set + not "tool_use" => the turn ended (end_turn/stop_sequence).
+              done = !!sr && sr !== "tool_use";
+            }
+          }
+          if (!tool && Array.isArray(msg.content)) {
+            for (const b of msg.content as Array<Record<string, unknown>>) {
+              if (b && b.type === "tool_use" && typeof b.name === "string") {
+                tool = b.name; break;
+              }
+            }
+          }
+        }
+        if (tool && done !== null) break;
+      }
+
+      if (done === null && pendingUserAfterAssistant) done = false;
+      if (!tool && done === null) return null;   // nothing usable in the tail
+      if (tool && tool !== this.lastTool) {
+        this.lastTool = tool; this.firstSeen = Date.now();
+      }
+      const isDone = done === true || staleMs > IDLE_STALE_MS;
+      return { tool, elapsedMs: Date.now() - this.firstSeen,
+               ts: Date.now(), done: isDone };
+    } catch { return null; }
+  }
+}
diff --git a/src/adapters/claude-cli/adapter.ts b/src/adapters/claude-cli/adapter.ts
new file mode 100644
index 0000000..5bd22e1
--- /dev/null
+++ b/src/adapters/claude-cli/adapter.ts
@@ -0,0 +1,258 @@
+import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync }
+  from "node:fs";
+import { resolve, dirname, join } from "node:path";
+import type { TargetAdapter, PreflightResult, OpResult, RestoreResult,
+              PatchParams } from "../types";
+import { sha256 } from "../../util/crypto";
+import { resolveAsset } from "../../util/asset";
+import { parseable, readTopLevel, upsertStatusLine, upsertSpinnerVerbs,
+         removeSpinnerVerbs, removeTopLevel }
+  from "./settingsEdit";
+
+const ABSENT = " VIBE-ADS-ABSENT";
+const SCRIPT_NAME = "vibe-ads-statusline.mjs";
+const PREV_NAME = "cli-prev-statusline.json";
+const FRESH_MS = 10 * 60 * 1000;
+/** Hard exit deadline for the chained pre-existing statusLine command — a
+ *  wedged user HUD must never hang CC's status line. */
+const CHAIN_TIMEOUT_MS = 5000;
+
+/** A chain-capturable statusLine: a command-type entry that is not our own
+ *  script (re-applying over ourselves must never capture ourselves). */
+function isForeignStatusLine(v: unknown): v is { type: string; command: string } {
+  return typeof v === "object" && v !== null
+    && (v as { type?: unknown }).type === "command"
+    && typeof (v as { command?: unknown }).command === "string"
+    && !(v as { command: string }).command.includes(SCRIPT_NAME);
+}
+
+/** Resolve the shipped asset in BOTH unbundled (co-located src) and
+ *  esbuild-bundled (dist/adapters/claude-cli/) layouts — mirrors the
+ *  webview adapter's resolveBlockAsset contract. */
+export function resolveStatuslineAsset(baseDir: string): string {
+  return resolveAsset(baseDir, "adapters/claude-cli", "statusline.asset.mjs");
+}
+
+export class ClaudeCliStatuslineAdapter implements TargetAdapter {
+  readonly name = "claude-cli-statusline";
+  private readonly settings: string;
+  private readonly home: string;
+
+  /** Whether to write the `spinnerVerbs` override. Gated on the terminal CLI
+   *  honouring the key (CC >= 2.1.143). Defaults to true (fail-open) so the
+   *  surface works before async version detection resolves; cliSync flips it
+   *  off only when it positively detects an older CLI. See cliVersion.ts. */
+  spinnerVerbsSupported = true;
+
+  /** @param settingsPath absolute path to ~/.claude/settings.json. The home
+   *  dir (for ~/.vibe-ads) is its grandparent (<home>/.claude/settings.json). */
+  constructor(settingsPath: string) {
+    this.settings = resolve(settingsPath);
+    this.home = dirname(dirname(this.settings));
+  }
+
+  private backupPath(): string { return this.settings + ".vibe-ads-backup"; }
+  private vibeDir(): string { return join(this.home, ".vibe-ads"); }
+  private scriptPath(): string { return join(this.vibeDir(), SCRIPT_NAME); }
+  private cachePath(): string { return join(this.vibeDir(), "cli-ad.json"); }
+  private prevPath(): string { return join(this.vibeDir(), PREV_NAME); }
+
+  /** The user's pre-install statusLine captured by applyPatch (chain-capture),
+   *  or undefined when none was captured / the file is unreadable. */
+  private readPrevStatusLine(): unknown {
+    try {
+      const v = JSON.parse(readFileSync(this.prevPath(), "utf8")).statusLine;
+      return isForeignStatusLine(v) ? v : undefined;
+    } catch { return undefined; }
+  }
+
+  /** restore()'s fallback capture source: the statusLine inside the
+   *  first-apply snapshot. Used when the capture file is missing/corrupt
+   *  (cleared ~/.vibe-ads, AV tooling, disk error) so the user's entry is
+   *  still put back. Can be stale if they swapped HUDs while installed —
+   *  stale beats deleted. */
+  private savedStatusLine(saved: string): unknown {
+    if (saved === ABSENT) return undefined;
+    const v = readTopLevel(saved, "statusLine");
+    return isForeignStatusLine(v) ? v : undefined;
+  }
+
+  version(): string | null { return "cli"; }
+
+  preflight(): PreflightResult {
+    try {
+      if (!existsSync(this.settings))
+        return { ok: true, compatible: true, version: "cli" };
+      const src = readFileSync(this.settings, "utf8");
+      if (!parseable(src))
+        return { ok: true, compatible: false, version: "cli",
+                 reason: "settings.json not parseable" };
+      return { ok: true, compatible: true, version: "cli" };
+    } catch (e) {
+      return { ok: false, compatible: false, version: null, reason: String(e) };
+    }
+  }
+
+  private renderScript(): string {
+    const tplPath = resolveStatuslineAsset(dirname(__filename));
+    const tpl = readFileSync(tplPath, "utf8");
+    return tpl
+      .split("__VIBE_ADS_CLI_AD_PATH__").join(JSON.stringify(this.cachePath()))
+      .split("__VIBE_ADS_CLI_PREV_PATH__").join(JSON.stringify(this.prevPath()))
+      .split("__VIBE_ADS_FRESH_MS__").join(String(FRESH_MS))
+      .split("__VIBE_ADS_SCRIPT_NAME__").join(JSON.stringify(SCRIPT_NAME))
+      .split("__VIBE_ADS_CHAIN_TIMEOUT_MS__").join(String(CHAIN_TIMEOUT_MS));
+  }
+
+  private statusLineValue(): string {
+    const cmd = `node ${JSON.stringify(this.scriptPath())}`;
+    return JSON.stringify({ type: "command", command: cmd, padding: 0 });
+  }
+
+  /** The spinnerVerbs override value: replace CC's stock verb dictionary with
+   *  the single ad line so the thinking-shimmer verb shows the ad. */
+  private spinnerVerbsValue(adText: string): string {
+    return JSON.stringify({ mode: "replace", verbs: [adText] });
+  }
+
+  // The CLI adapter writes TWO surfaces into ~/.claude/settings.json:
+  //   1. `statusLine` — an OSC 8 clickable hyperlink rendered at the bottom
+  //      of the terminal on every status-line refresh (the click surface).
+  //   2. `spinnerVerbs` — the ad text in the thinking-shimmer verb slot,
+  //      replacing CC's stock "Discombobulating…"/"Baking…" pool (a
+  //      brand-impression surface; the terminal verb is not clickable).
+  // spinnerVerbs is gated on `spinnerVerbsSupported` (CC >= 2.1.143; older
+  // CLIs silently ignore the key). History: an earlier adapter dropped
+  // spinnerVerbs because, when the SAME settings.json was read by the VS
+  // Code webview, the plain-text verb masked block.desync failures (rich
+  // anchor missing but a plain-text ad still showed → broken click telemetry
+  // looked fine). That risk is unchanged but accepted: the desync detector
+  // (desyncDetector.ts) is timestamp-based and fires + auto-reloads
+  // regardless of the spinner verb, and the webview overlay is the dominant
+  // surface there. CC reads spinnerVerbs at boot, so the verb only rotates
+  // on the next CC session; the statusLine ad updates live.
+
+  applyPatch(p: PatchParams): OpResult {
+    try {
+      const existed = existsSync(this.settings);
+      const pristine = existed
+        ? readFileSync(this.settings, "utf8") : null;
+      if (pristine !== null && !parseable(pristine))
+        return { ok: false, reason: "settings.json not parseable" };
+
+      mkdirSync(dirname(this.settings), { recursive: true });
+
+      if (!existsSync(this.backupPath()))
+        writeFileSync(this.backupPath(),
+          pristine === null ? ABSENT : pristine, "utf8");
+      mkdirSync(this.vibeDir(), { recursive: true });
+
+      // Chain-capture: when settings.json carries a statusLine that is not
+      // ours (e.g. a user HUD like claude-hud), persist it so (a) the
+      // statusline script renders it on the lines BELOW the ad instead of
+      // replacing it, and (b) restore() puts the entry back rather than
+      // dropping the key. Idempotent across the 60s cliSync re-apply: once
+      // the slot holds OUR command the capture is left untouched.
+      const prevSl = pristine !== null
+        ? readTopLevel(pristine, "statusLine") : undefined;
+      if (isForeignStatusLine(prevSl)) {
+        const json = JSON.stringify({ statusLine: prevSl });
+        if (!existsSync(this.prevPath())
+            || readFileSync(this.prevPath(), "utf8") !== json)
+          writeFileSync(this.prevPath(), json, "utf8");
+      }
+      // NEVER auto-delete the capture. While installed the live slot holds
+      // OUR command, so a vanished statusLine key means the user deleted the
+      // AD entry (or settings.json was transiently absent mid-rewrite by CC
+      // or a dotfile-sync tool) — neither is "the user deleted THEIR
+      // statusLine". The capture is the only copy of their HUD; restore()
+      // owns its cleanup and puts the entry back.
+      const script = this.renderScript();
+      // Idempotent: cliSync re-applies every 60s — skip the write when the
+      // on-disk script is already byte-identical (no per-tick disk churn).
+      if (!existsSync(this.scriptPath())
+          || readFileSync(this.scriptPath(), "utf8") !== script)
+        writeFileSync(this.scriptPath(), script, "utf8");
+
+      const base = pristine ?? "{\n}\n";
+      let next = upsertStatusLine(base, this.statusLineValue());
+      // Gate the spinnerVerbs surface on CLI support. When supported, write
+      // the ad as the replacement verb; otherwise REMOVE any spinnerVerbs
+      // entry so an unsupported CLI keeps a clean settings.json and any
+      // stale entry from a prior session heals on activation.
+      next = this.spinnerVerbsSupported
+        ? upsertSpinnerVerbs(next, this.spinnerVerbsValue(p.adText))
+        : removeSpinnerVerbs(next);
+      if (!existed || next !== pristine)
+        writeFileSync(this.settings, next, "utf8");
+      return { ok: true };
+    } catch (e) {
+      return { ok: false, reason: String(e) };
+    }
+  }
+
+  restore(): RestoreResult {
+    try {
+      const bak = this.backupPath();
+      if (!existsSync(bak))
+        return { ok: true, restored: false, reason: "no backup present" };
+      const saved = readFileSync(bak, "utf8");
+      // KEY-SCOPED restore — never a whole-file rollback. The backup is a
+      // point-in-time snapshot from FIRST apply; the user may have edited
+      // settings.json since (hooks, permissions, model config), and any
+      // restore trigger (offline blip → killswitch fail-safe, sign-out,
+      // deactivate) would silently destroy those edits. Instead remove ONLY
+      // the keys we own from the CURRENT file; everything else survives
+      // byte-for-byte (settingsEdit raw-text edits). The snapshot is kept
+      // solely as the ABSENT sentinel: when the file didn't exist before us
+      // and nothing but our keys was ever added, delete the shell we created.
+      if (existsSync(this.settings)) {
+        const cur = readFileSync(this.settings, "utf8");
+        if (!parseable(cur))
+          // User-edited into unparseable JSONC — we can't edit it safely, and
+          // overwriting with the stale snapshot would destroy their edits.
+          // Leave everything (incl. the backup) so a later restore can finish.
+          return { ok: false, restored: false,
+                   reason: "settings.json not parseable" };
+        // Put the user's pre-install statusLine back when chain-capture
+        // saved one (falling back to the first-apply snapshot when the
+        // capture file is gone); otherwise remove the key we own
+        // (pre-capture behavior: the slot was empty before us).
+        // Re-serialized compactly — raw-text formatting of the original
+        // entry is not preserved, its value is. ONLY when the slot is still
+        // ours or absent: a foreign entry means the user hand-installed a
+        // NEW statusLine after capture, and their newer edit beats the
+        // stale capture — touch nothing.
+        const prevSl = this.readPrevStatusLine()
+          ?? this.savedStatusLine(saved);
+        const curSl = readTopLevel(cur, "statusLine");
+        let next = cur;
+        if (!isForeignStatusLine(curSl)) {
+          next = prevSl !== undefined
+            ? upsertStatusLine(cur, JSON.stringify(prevSl))
+            : removeTopLevel(cur, "statusLine");
+        }
+        next = removeTopLevel(next, "spinnerVerbs");
+        // The shell we created is `{}` plus whitespace; anything else left
+        // (user keys, even bare comments) means the file is now theirs.
+        const emptyShell = /^[\s{}]*$/.test(next);
+        if (saved === ABSENT && emptyShell) {
+          rmSync(this.settings);
+        } else if (next !== cur) {
+          writeFileSync(this.settings, next, "utf8");
+          if (sha256(readFileSync(this.settings))
+              !== sha256(Buffer.from(next, "utf8")))
+            return { ok: false, restored: false,
+                     reason: "sha256 mismatch after restore" };
+        }
+      }
+      if (existsSync(this.scriptPath())) rmSync(this.scriptPath());
+      if (existsSync(this.cachePath())) rmSync(this.cachePath());
+      if (existsSync(this.prevPath())) rmSync(this.prevPath());
+      rmSync(bak);
+      return { ok: true, restored: true };
+    } catch (e) {
+      return { ok: false, restored: false, reason: String(e) };
+    }
+  }
+}
diff --git a/src/adapters/claude-cli/cliAd.ts b/src/adapters/claude-cli/cliAd.ts
new file mode 100644
index 0000000..838b216
--- /dev/null
+++ b/src/adapters/claude-cli/cliAd.ts
@@ -0,0 +1,114 @@
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync,
+         statSync } from "node:fs";
+import { transcriptEntrypoint } from "../../locate";
+
+/** Ad considered fresh for 10 minutes after the extension last wrote it. */
+export const FRESH_MS = 10 * 60 * 1000;
+
+export interface CliAd { adText: string; iconRef: string; iconUrl: string;
+                         clickUrl: string; ts: number; }
+
+export function vibeAdsDir(home = homedir()): string {
+  return join(home, ".vibe-ads");
+}
+export function cliAdPath(home = homedir()): string {
+  return join(vibeAdsDir(home), "cli-ad.json");
+}
+
+/** Terminal esc()-analog: strip control chars (C0 + DEL + C1) — and ONLY
+ *  those — so cached fields can never smuggle ANSI/OSC bytes into a terminal
+ *  surface. Emoji / pipes / unicode / URLs pass through untouched. */
+export function stripControlChars(s: string): string {
+  return s.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
+}
+
+export function writeCliAdCache(
+  home: string,
+  ad: { adText: string; iconRef: string; iconUrl: string; clickUrl: string },
+): void {
+  const dir = vibeAdsDir(home);
+  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
+  const rec: CliAd = {
+    adText: stripControlChars(ad.adText),
+    iconRef: stripControlChars(ad.iconRef),
+    iconUrl: stripControlChars(ad.iconUrl),
+    clickUrl: stripControlChars(ad.clickUrl),
+    ts: Date.now(),
+  };
+  writeFileSync(cliAdPath(home), JSON.stringify(rec), "utf8");
+}
+
+export function readCliAdCache(home: string): CliAd | null {
+  try {
+    const raw = readFileSync(cliAdPath(home), "utf8");
+    const o = JSON.parse(raw) as CliAd;
+    if (typeof o.adText === "string" && typeof o.ts === "number") return o;
+    return null;
+  } catch { return null; }
+}
+
+/** Evidence that a `claude` CLI session is plausibly live: a
+ *  ~/.claude/projects/ ** /*.jsonl transcript was modified within `windowMs`
+ *  AND is tagged entrypoint:"cli" or untagged (older CC builds stay fail-open
+ *  as CLI-plausible). Any OTHER positive tag is excluded — the VS Code
+ *  panel's "claude-vscode" (editor turns write the same tree; counting them
+ *  inflated statusline/spinner impression counts for a surface that never
+ *  rendered, audit #30) and equally sdk/desktop/agent tags (2026-06-11:
+ *  a `!== "claude-vscode"` filter counted headless-SDK activity as a live
+ *  TUI, emitting statusline impressions nobody saw while the tick loop —
+ *  strictly "cli"-tagged — stayed silent; the two signals must accept the
+ *  SAME set: "cli" | untagged, mirroring locateClaudeCliLog).
+ *  `root` overridable for tests. Never throws. */
+export function cliSessionActive(
+  now: number, windowMs: number, root = join(homedir(), ".claude", "projects"),
+): boolean {
+  try {
+    if (!existsSync(root)) return false;
+    const recent: { p: string; m: number }[] = [];
+    for (const proj of readdirSync(root)) {
+      let entries: string[];
+      try { entries = readdirSync(join(root, proj)); } catch { continue; }
+      for (const f of entries) {
+        if (!f.endsWith(".jsonl")) continue;
+        const p = join(root, proj, f);
+        try {
+          const m = statSync(p).mtimeMs;
+          if (m > 0 && (now - m) <= windowMs) recent.push({ p, m });
+        } catch { /* ignore */ }
+      }
+    }
+    // Newest-first so the common case (one live transcript) probes one head.
+    recent.sort((a, b) => b.m - a.m);
+    return recent.some((c) => {
+      const tag = transcriptEntrypoint(c.p);
+      return tag === "cli" || tag === null;
+    });
+  } catch { return false; }
+}
+
+export function shouldCountCliImpression(s: {
+  signedIn: boolean; haveAd: boolean; sessionActive: boolean;
+  adId: string; lastCountedAdId: string | null;
+}): boolean {
+  return s.signedIn && s.haveAd && s.sessionActive
+    && s.adId.length > 0 && s.adId !== s.lastCountedAdId;
+}
+
+/** Pure predicate: should we count the CLI spinner-verb impression this tick?
+ *  Same dedup-per-adId rule as the statusline (`shouldCountCliImpression`),
+ *  gated ADDITIONALLY on `supportConfirmed` — true only once `claude --version`
+ *  has positively confirmed the CLI honours `spinnerVerbs` (CC >= 2.1.143).
+ *  This is distinct from the adapter's fail-open `spinnerVerbsSupported` RENDER
+ *  flag (which defaults true so the verb is written optimistically before
+ *  detection resolves): billing must wait for confirmation, else the first
+ *  synchronous activation sync on an old CLI bills for a verb that never
+ *  renders. */
+export function shouldCountSpinnerImpression(s: {
+  supportConfirmed: boolean;
+  signedIn: boolean; haveAd: boolean; sessionActive: boolean;
+  adId: string; lastCountedAdId: string | null;
+}): boolean {
+  return s.supportConfirmed && shouldCountCliImpression(s);
+}
diff --git a/src/adapters/claude-cli/cliVersion.ts b/src/adapters/claude-cli/cliVersion.ts
new file mode 100644
index 0000000..87262ec
--- /dev/null
+++ b/src/adapters/claude-cli/cliVersion.ts
@@ -0,0 +1,92 @@
+/** Detect the terminal `claude` CLI version so we only write the
+ *  `spinnerVerbs` settings.json override on builds that actually honour it.
+ *
+ *  spinnerVerbs support landed in CC 2.1.143 (decoded from the Bun-compiled
+ *  binary: a Zod schema read via the iTH() selector). On older builds the
+ *  key is silently ignored and CC shows its stock verb pool — writing it
+ *  there is harmless but leaves a dead key in the user's settings.json AND
+ *  would make us count a "spinner" impression for an ad that never rendered,
+ *  so we gate on it.
+ *
+ *  NOTE: this is the CLI version, NOT the VS Code Claude Code webview
+ *  extension version (the `ccVersion` from the webview adapter preflight).
+ *  The two installs are independent and can differ — the spinnerVerbs key
+ *  is read by the terminal CLI, so the terminal CLI's version is the one
+ *  that matters. */
+import { execFile } from "node:child_process";
+
+export type SemVer = [number, number, number];
+
+/** spinnerVerbs support floor: Claude Code 2.1.143. */
+export const SPINNER_VERBS_FLOOR: SemVer = [2, 1, 143];
+
+/** Parse a `claude --version` line like "2.1.158 (Claude Code)" into a
+ *  [major, minor, patch] tuple, or null if no semver is present. */
+export function parseClaudeCliVersion(stdout: string): SemVer | null {
+  const m = /(\d+)\.(\d+)\.(\d+)/.exec(stdout);
+  if (!m) return null;
+  return [Number(m[1]), Number(m[2]), Number(m[3])];
+}
+
+/** True iff `a` >= `b` under semver ordering (major, then minor, then patch). */
+export function gte(a: SemVer, b: SemVer): boolean {
+  for (let i = 0; i < 3; i++) {
+    if (a[i] !== b[i]) return a[i] > b[i];
+  }
+  return true;
+}
+
+/** True iff this CLI version honours the spinnerVerbs settings key. A null
+ *  version (unparseable output) is treated as UNsupported here; callers that
+ *  want fail-open behaviour on a failed *spawn* apply that policy themselves
+ *  (see detectClaudeCliSpinnerSupport). */
+export function supportsSpinnerVerbs(v: SemVer | null): boolean {
+  return v ? gte(v, SPINNER_VERBS_FLOOR) : false;
+}
+
+/** Spawn `claude --version` (PATH-resolved) and return its parsed semver, or
+ *  null if claude is absent / errors / times out / prints something we can't
+ *  parse. Never throws. */
+export function detectClaudeCliVersion(): Promise<SemVer | null> {
+  return new Promise((res) => {
+    try {
+      execFile("claude", ["--version"],
+        { timeout: 3000, windowsHide: true },
+        (err, stdout) => {
+          if (err) return res(null);
+          res(parseClaudeCliVersion(String(stdout ?? "")));
+        });
+    } catch { res(null); }
+  });
+}
+
+/** Result of resolving local-CLI spinnerVerbs support. */
+export interface SpinnerSupport {
+  /** Whether to write/count the spinner verb. Fail-OPEN (see below). */
+  ok: boolean;
+  /** The positively-detected CLI version, or null if detection failed/absent. */
+  version: SemVer | null;
+  /** True iff a version was POSITIVELY detected AND it is below the floor —
+   *  i.e. the case worth warning the user about. Distinct from `!ok`, which is
+   *  also false on a failed detection (the fail-open path). */
+  outdated: boolean;
+}
+
+/** Resolve whether to write spinnerVerbs for the local CLI. Fail-OPEN: if we
+ *  cannot detect a version (claude not on the extension host's PATH, spawn
+ *  error, unparseable output) we assume support, because writing the key is
+ *  harmless on a build that ignores it and we'd rather show the ad on a
+ *  supported build that flaked detection than silently suppress it. Only a
+ *  POSITIVELY detected pre-2.1.143 version turns the surface off — and the
+ *  impression counter is guarded on the same flag, so a flaked detection
+ *  never bills for a verb that didn't render on a genuinely old CLI (we just
+ *  accept the small risk on the rare unknown-version case).
+ *
+ *  Returns the detected `version` and an `outdated` flag too, so callers can
+ *  warn the user ONLY on a positively-detected old CLI — never on the
+ *  fail-open (null) path, which would nag installs we can't even probe. */
+export async function detectClaudeCliSpinnerSupport(): Promise<SpinnerSupport> {
+  const version = await detectClaudeCliVersion();
+  const ok = version === null ? true : supportsSpinnerVerbs(version);
+  return { ok, version, outdated: version !== null && !ok };
+}
diff --git a/src/adapters/claude-cli/settingsEdit.ts b/src/adapters/claude-cli/settingsEdit.ts
new file mode 100644
index 0000000..5052274
--- /dev/null
+++ b/src/adapters/claude-cli/settingsEdit.ts
@@ -0,0 +1,212 @@
+/** Minimal-edit, JSONC-tolerant editing of ~/.claude/settings.json.
+ *  We mutate RAW TEXT (never re-serialize) so the user's comments,
+ *  whitespace and key order survive. A tiny state machine walks the raw
+ *  bytes tracking string/line-comment/block-comment context. */
+
+type Ctx = "code" | "str" | "line" | "block";
+
+/** Strip JSONC comments + a single trailing comma before } or ] so we can
+ *  validate with JSON.parse. Used ONLY for parse/idempotency checks, never
+ *  for the emitted text. */
+function stripJsonc(src: string): string {
+  let out = "", ctx: Ctx = "code", i = 0;
+  while (i < src.length) {
+    const c = src[i], n = src[i + 1];
+    if (ctx === "code") {
+      if (c === '"') { ctx = "str"; out += c; i++; continue; }
+      if (c === "/" && n === "/") { ctx = "line"; i += 2; continue; }
+      if (c === "/" && n === "*") { ctx = "block"; i += 2; continue; }
+      out += c; i++; continue;
+    }
+    if (ctx === "str") {
+      out += c;
+      if (c === "\\") { out += src[i + 1] ?? ""; i += 2; continue; }
+      if (c === '"') ctx = "code";
+      i++; continue;
+    }
+    if (ctx === "line") { if (c === "\n") { ctx = "code"; out += c; } i++; continue; }
+    // block
+    if (c === "*" && n === "/") { ctx = "code"; i += 2; continue; }
+    i++;
+  }
+  return out.replace(/,(\s*[}\]])/g, "$1");
+}
+
+export function parseable(src: string): boolean {
+  try { JSON.parse(stripJsonc(src)); return true; } catch { return false; }
+}
+
+/** Find the [start,end) raw-text span of the VALUE of a top-level `key`, or
+ *  null if the key is absent. Comment/string aware; only depth-1 keys (direct
+ *  children of the root object) match. Throws if the text is not parseable
+ *  as JSONC. */
+function findTopLevelValueSpan(src: string, key: string): [number, number] | null {
+  if (!parseable(src)) throw new Error("settings.json not parseable");
+  let ctx: Ctx = "code", depth = 0, i = 0;
+  let pendingKey: string | null = null, keyStart = -1;
+  const skipWs = (j: number): number => {
+    let c2: Ctx = "code";
+    while (j < src.length) {
+      const c = src[j], n = src[j + 1];
+      if (c2 === "code") {
+        if (c === "/" && n === "/") { c2 = "line"; j += 2; continue; }
+        if (c === "/" && n === "*") { c2 = "block"; j += 2; continue; }
+        if (/\s/.test(c) || c === ":") { j++; continue; }
+        return j;
+      }
+      if (c2 === "line") { if (c === "\n") c2 = "code"; j++; continue; }
+      if (c === "*" && n === "/") { c2 = "code"; j += 2; continue; }
+      j++;
+    }
+    return j;
+  };
+  const valueEnd = (j: number): number => {
+    // j points at first char of the value; consume one JSON value.
+    let c2: Ctx = "code", d = 0;
+    for (; j < src.length; j++) {
+      const c = src[j], n = src[j + 1];
+      if (c2 === "str") {
+        if (c === "\\") { j++; continue; }
+        if (c === '"') c2 = "code";
+        continue;
+      }
+      if (c2 === "line") { if (c === "\n") c2 = "code"; continue; }
+      if (c2 === "block") { if (c === "*" && n === "/") { c2 = "code"; j++; } continue; }
+      if (c === '"') { c2 = "str"; continue; }
+      if (c === "/" && n === "/") { c2 = "line"; j++; continue; }
+      if (c === "/" && n === "*") { c2 = "block"; j++; continue; }
+      if (c === "{" || c === "[") d++;
+      else if (c === "}" || c === "]") { if (d === 0) return j; d--; }
+      else if ((c === "," ) && d === 0) return j;
+    }
+    return j;
+  };
+  while (i < src.length) {
+    const c = src[i], n = src[i + 1];
+    if (ctx === "str") {
+      if (c === "\\") { i += 2; continue; }
+      if (c === '"') {
+        ctx = "code";
+        if (depth === 1) pendingKey = src.slice(keyStart + 1, i);
+      }
+      i++; continue;
+    }
+    if (ctx === "line") { if (c === "\n") ctx = "code"; i++; continue; }
+    if (ctx === "block") { if (c === "*" && n === "/") { ctx = "code"; i += 2; continue; } i++; continue; }
+    if (c === "/" && n === "/") { ctx = "line"; i += 2; continue; }
+    if (c === "/" && n === "*") { ctx = "block"; i += 2; continue; }
+    if (c === '"') { ctx = "str"; keyStart = i; i++; continue; }
+    if (c === "{" || c === "[") { depth++; i++; continue; }
+    if (c === "}" || c === "]") { depth--; i++; continue; }
+    if (c === ":" && depth === 1 && pendingKey === key) {
+      const vs = skipWs(i + 1);
+      return [vs, valueEnd(vs)];
+    }
+    if (c === "," ) pendingKey = null;
+    i++;
+  }
+  return null;
+}
+
+/** Parse and return the VALUE of a top-level `key`, or undefined when the
+ *  key is absent or the text is not parseable. Read-only companion to
+ *  `upsertTopLevel` — the CLI adapter uses it to capture a pre-existing
+ *  user statusLine before taking the slot (chain-capture). */
+export function readTopLevel(src: string, key: string): unknown {
+  try {
+    const span = findTopLevelValueSpan(src, key);
+    if (!span) return undefined;
+    return JSON.parse(stripJsonc(src.slice(span[0], span[1])));
+  } catch { return undefined; }
+}
+
+/** Set the top-level `key` to `valueJson` (a JSON value string), editing only
+ *  that span. Inserts the key right after the root `{` when absent.
+ *  Idempotent. Throws if `src` is not parseable JSONC. */
+function upsertTopLevel(src: string, key: string, valueJson: string): string {
+  const span = findTopLevelValueSpan(src, key);
+  if (span) {
+    const next = src.slice(0, span[0]) + valueJson + src.slice(span[1]);
+    return next;
+  }
+  const brace = src.indexOf("{");
+  if (brace < 0) throw new Error("settings.json not parseable");
+  const after = src.slice(brace + 1);
+  const hasKeys = parseable(src) && /\S/.test(stripJsonc(after).replace(/[}\s]/g, ""));
+  const insert = `\n  ${JSON.stringify(key)}: ${valueJson}${hasKeys ? "," : ""}`;
+  return src.slice(0, brace + 1) + insert + after;
+}
+
+/** Set top-level "statusLine" to `valueJson`. See `upsertTopLevel`. */
+export function upsertStatusLine(src: string, valueJson: string): string {
+  return upsertTopLevel(src, "statusLine", valueJson);
+}
+
+/** Set top-level "spinnerVerbs" to `valueJson`. CC 2.1.143+ reads this at boot
+ *  via the iTH() selector to override the default verb dictionary in the
+ *  thinking-shimmer. Schema (from claude.exe at offset 217693700):
+ *  `{ mode: "append" | "replace", verbs: string[] }`. See `upsertTopLevel`. */
+export function upsertSpinnerVerbs(src: string, valueJson: string): string {
+  return upsertTopLevel(src, "spinnerVerbs", valueJson);
+}
+
+/** Remove a top-level key (and its preceding comma, if any) from a JSONC
+ *  settings file. Idempotent — returns `src` unchanged when the key is
+ *  absent. Comment/string aware; preserves the user's other keys, their
+ *  ordering, and their comments. Used to evict the spinnerVerbs entry
+ *  the older CLI adapter wrote into settings.json — once it's gone CC
+ *  falls back to its built-in verb dictionary and our webview block
+ *  becomes the SINGLE ad-rendering surface (no plain-text fallback that
+ *  silently masks a block.desync). */
+export function removeTopLevel(src: string, key: string): string {
+  let span: [number, number] | null;
+  try { span = findTopLevelValueSpan(src, key); }
+  catch { return src; }                            // not parseable → leave alone
+  if (!span) return src;
+  // Walk backward from the value span to consume the key, its colon,
+  // and any leading whitespace + a single preceding comma; walk forward
+  // to swallow a trailing comma (so removing a middle key doesn't leave
+  // `{ "a": 1,, "b": 2 }`).
+  let s = span[0];
+  // Step back past whitespace + colon + whitespace + the key string.
+  while (s > 0 && /\s/.test(src[s - 1])) s--;
+  if (s > 0 && src[s - 1] === ":") s--;
+  while (s > 0 && /\s/.test(src[s - 1])) s--;
+  if (s > 0 && src[s - 1] === '"') {
+    // Find the matching opening quote, respecting backslash escapes.
+    let q = s - 2;
+    while (q > 0) {
+      if (src[q] === '"' && src[q - 1] !== "\\") break;
+      q--;
+    }
+    s = q;
+  }
+  // Trailing comma + whitespace (or leading comma if no trailing one).
+  let e = span[1];
+  let trailingCommaConsumed = false;
+  let j = e;
+  while (j < src.length && /\s/.test(src[j])) j++;
+  if (src[j] === ",") { e = j + 1; trailingCommaConsumed = true; }
+  if (trailingCommaConsumed) {
+    // Also consume the whitespace run immediately before the key so removing
+    // an entry upsertTopLevel inserted (`\n  "key": value,` right after the
+    // root `{`) round-trips byte-exact — the CLI adapter's key-scoped
+    // restore() relies on this to leave a never-edited settings.json exactly
+    // as it was before applyPatch. Whitespace only: a comment between the
+    // previous token and the key is never consumed.
+    while (s > 0 && /\s/.test(src[s - 1])) s--;
+  }
+  if (!trailingCommaConsumed) {
+    // No trailing comma → consume any leading comma + ws so the result
+    // doesn't end with a dangling `, }`.
+    let k = s;
+    while (k > 0 && /\s/.test(src[k - 1])) k--;
+    if (k > 0 && src[k - 1] === ",") s = k - 1;
+  }
+  return src.slice(0, s) + src.slice(e);
+}
+
+/** Remove the top-level "spinnerVerbs" entry. Convenience wrapper. */
+export function removeSpinnerVerbs(src: string): string {
+  return removeTopLevel(src, "spinnerVerbs");
+}
diff --git a/src/adapters/claude-cli/statusline.asset.mjs b/src/adapters/claude-cli/statusline.asset.mjs
new file mode 100644
index 0000000..e6486a1
--- /dev/null
+++ b/src/adapters/claude-cli/statusline.asset.mjs
@@ -0,0 +1,94 @@
+// Vibe-Ads CLI status line. Shipped raw (placeholders substituted at install).
+// Prints the ad line and, when the adapter captured a pre-existing user
+// statusLine (chain-capture), runs that command too and prints its output
+// on the lines BELOW the ad — CC renders every stdout line, so the
+// user's own status line stacks under the ad instead of being replaced.
+// Never throws, and a hard exit deadline bounds the chained command — a
+// wedged HUD, or a grandchild squatting on the stdout pipe (which no
+// child-kill can unstick), can never hang CC's status line.
+import { readFileSync, writeSync } from "node:fs";
+import { spawn } from "node:child_process";
+
+// writeSync(1): stdout-pipe writes must survive the process.exit() below
+// (process.stdout.write is async on pipes; exit() drops pending chunks).
+let wrote = false;
+const put = (s) => {
+  try { writeSync(1, s); wrote = true; } catch { /* never throw */ }
+};
+try {
+  const CACHE = __VIBE_ADS_CLI_AD_PATH__;
+  const FRESH_MS = __VIBE_ADS_FRESH_MS__;
+  const o = JSON.parse(readFileSync(CACHE, "utf8"));
+  const fresh = o && typeof o.ts === "number"
+    && (Date.now() - o.ts) <= FRESH_MS
+    && typeof o.adText === "string" && o.adText.length > 0;
+  if (fresh) {
+    // Terminal esc()-analog: strip control chars (C0 + DEL + C1) — and ONLY
+    // those — so adText/clickUrl can never emit ANSI/OSC sequences of their
+    // own (the OSC 8 framing below is the only escape this script prints).
+    // Emoji / pipes / unicode / URLs pass through untouched.
+    const strip = (s) => s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
+    const text = "ad· " + strip(o.adText);
+    const url = typeof o.clickUrl === "string" ? strip(o.clickUrl) : "";
+    const ESC = "";
+    // OSC 8 hyperlink: ESC ]8;; URL ESC \  TEXT  ESC ]8;; ESC \
+    put(url
+      ? ESC + "]8;;" + url + ESC + "\\" + text + ESC + "]8;;" + ESC + "\\"
+      : text);
+  }
+} catch { /* prime directive: never break the CLI */ }
+try {
+  // Chain-capture file (written by the adapter when the user already had
+  // a statusLine of their own before install — e.g. a HUD like
+  // claude-hud): run their command and stack its output on the lines below
+  // the ad instead of replacing it.
+  const PREV = __VIBE_ADS_CLI_PREV_PATH__;
+  const sl = JSON.parse(readFileSync(PREV, "utf8")).statusLine;
+  const cmd = sl && sl.type === "command" && typeof sl.command === "string"
+    ? sl.command : "";
+  // Self-spawn guard: the adapter never captures our own entry, but a
+  // stale or hand-edited file must not make this script fork itself. The
+  // name is substituted from the adapter's SCRIPT_NAME — one source of truth.
+  if (cmd && !cmd.includes(__VIBE_ADS_SCRIPT_NAME__)) {
+    // CC pipes the session JSON to the status line's stdin and the chained
+    // command (claude-hud etc.) needs it to render — hand it our stdin fd
+    // DIRECTLY rather than slurping it here: a readFileSync(0) on a pipe an
+    // invoker forgets to close would block this process forever. TTY stdin
+    // is dropped so a manual un-piped run can't leave the HUD at a keyboard.
+    const stdinMode = process.stdin.isTTY ? "ignore" : "inherit";
+    const CHAIN_TIMEOUT_MS = __VIBE_ADS_CHAIN_TIMEOUT_MS__;
+    const DRAIN_MS = 150;
+    const child = spawn(cmd, { shell: true, windowsHide: true,
+                               stdio: [stdinMode, "pipe", "ignore"] });
+    let out = "";
+    let done = false;
+    const finish = () => {
+      if (done) return;
+      done = true;
+      // The chained command is the user's own config and previously ran
+      // directly under CC, so its output (colors/escapes) passes through
+      // verbatim — only trailing newlines are trimmed before stacking.
+      const text = out.replace(/[\r\n]+$/, "");
+      if (text) put((wrote ? "\n" : "") + text);
+      process.exit(0);
+    };
+    child.stdout.on("data", (d) => { out += d; });
+    child.stdout.on("error", () => { /* degrade to whatever drained */ });
+    child.on("error", finish);
+    // Normal path: 'close' (exit + stdio drained) finishes immediately. A
+    // grandchild that inherited the stdout pipe keeps 'close' from EVER
+    // firing — and a kill can't unstick it — so 'exit' arms a short drain
+    // grace, and the hard deadline below bounds even a never-exiting shell.
+    // process.exit() cannot be held hostage by an open pipe; this is why the
+    // chain is an async spawn and not spawnSync (whose timeout kills only
+    // the shell, then keeps reading the grandchild's pipe forever).
+    child.on("close", finish);
+    child.on("exit", () => { setTimeout(finish, DRAIN_MS); });
+    setTimeout(() => {
+      try { child.kill(); } catch { /* best-effort */ }
+      finish();
+    }, CHAIN_TIMEOUT_MS);
+  } else {
+    process.exit(0);
+  }
+} catch { process.exit(0); /* no capture → ad-only, as before */ }
diff --git a/src/adapters/claude-code/adapter.ts b/src/adapters/claude-code/adapter.ts
new file mode 100644
index 0000000..1d566bd
--- /dev/null
+++ b/src/adapters/claude-code/adapter.ts
@@ -0,0 +1,454 @@
+import { readFileSync, writeFileSync, existsSync, rmSync,
+  renameSync, unlinkSync, statSync } from "node:fs";
+import { resolve, dirname, join } from "node:path";
+import type { TargetAdapter, PreflightResult, OpResult, RestoreResult, PatchParams,
+  AdapterDiagnostics } from "../types";
+import { dlog } from "../../log";
+import { sha256 } from "../../util/crypto";
+import { resolveAsset } from "../../util/asset";
+
+// Distinctive nonsense/brand verbs from Claude Code's action-verb array. We
+// anchor on the SET, not a single literal, so renaming or removing any ONE verb
+// in a future CC release doesn't break detection — the array is still located
+// as long as ANY of these survive. All verified present (once each) in CC
+// 2.1.161's 84-verb array; they're made-up/brand words extremely unlikely to
+// appear elsewhere in the bundle, so a false match on a different array is
+// implausible. (Deep dive 2026-06-03; see [[cc-platform-specific-ext-dir]].)
+const ANCHORS = [
+  '"Discombobulating"', '"Flibbertigibbeting"', '"Combobulating"',
+  '"Clauding"', '"Reticulating"', '"Smooshing"', '"Wibbling"', '"Booping"',
+];
+// >=2 comma-separated double-quoted tokens (the S0 multi-element requirement).
+const ARRAY_RE = /\[(?:"[^"\\]*"\s*,\s*)+"[^"\\]*"\]/g;
+const BLOCK_START = "/* VIBE-ADS-START */";
+// Strip a previously-injected block delimited by EITHER the current markers
+// or the legacy /* VIBADS-START/END */ markers. An install patched before the
+// Vibe-Ads rename still carries the old markers; re-apply must not stack the
+// block and restore-via-Tier-0 must still find it (reversibility contract).
+const BLOCK_RE = /\/\* VIB(?:E-)?ADS-START \*\/[\s\S]*?\/\* VIB(?:E-)?ADS-END \*\//g;
+
+/** Resolve the shipped block asset relative to `baseDir` (= dirname of the
+ *  running adapter file). In the esbuild-bundled VSIX the adapter is inlined
+ *  into dist/extension.js so baseDir=<dist> and the asset lives at
+ *  dist/adapters/claude-code/block.asset.js (per esbuild.mjs). In unbundled
+ *  vitest the adapter is src/adapters/claude-code/adapter.ts so the asset is
+ *  co-located. Try both; first existing wins. (S3 Wave 1 review CRIT #1.) */
+export function resolveBlockAsset(baseDir: string): string {
+  return resolveAsset(baseDir, "adapters/claude-code", "block.asset.js");
+}
+
+// Atomic file replacement: write the new bytes to a temp sibling, then
+// `rename` the temp over the target. POSIX `rename` is atomic; on Win32
+// it is "best-effort atomic" but still crash-safe in practice — the
+// target either holds the OLD bytes or the NEW bytes, never a partial
+// write. This closes the wave-2A race where a power loss / CC self-
+// update collision mid-`writeFileSync` could leave webview/index.js
+// truncated. Falls back to a direct write if rename fails (e.g. on
+// some Windows filesystems where ENOENT can race the temp creation),
+// so the worst case stays the prior behavior, not a refusal to write.
+function atomicWriteFile(target: string, data: Buffer): void {
+  const tmp = target + ".kickbacks-tmp-" + process.pid + "-" + Date.now();
+  try {
+    writeFileSync(tmp, data);
+    renameSync(tmp, target);
+  } catch {
+    // best-effort cleanup of the temp; then fall back to a direct write
+    // so we don't fail-closed on systems where rename semantics differ.
+    try { unlinkSync(tmp); } catch { /* ignore */ }
+    writeFileSync(target, data);
+  }
+}
+
+export class ClaudeCodeAdapter implements TargetAdapter {
+  readonly name = "claude-code";
+  private readonly target: string;
+  constructor(target: string) { this.target = resolve(target); }
+
+  // --- Approach C: relax the Claude Code chat-webview CSP ----------------
+  // CC ships that webview with `default-src 'none'` and NO connect-src
+  // (getHtmlForWebview in extension.js), so the injected block's loopback
+  // fetch — clicks-metric, impressions, activity, debug log — is silently
+  // CSP-blocked. The real click-through is handled by the anchor's http(s)
+  // href (host-opened, CSP-exempt); this patch additionally revives the
+  // owned loopback for billing/telemetry by inserting a connect-src into
+  // that one dynamic CSP template. Sibling file (../extension.js), its own
+  // one-time pristine backup, byte-exact restore, idempotent — same
+  // reversibility contract as the index.js patch. Best-effort: a missing
+  // sibling / absent anchor never fails applyPatch (the href still works).
+  //
+  // NOTE (known gap): only local loopback (127.0.0.1/localhost) is opened.
+  // VS Code Remote/tunnels reach the loopback via an https asExternalUri
+  // host that this literal does not cover — out of scope for the local trial.
+  // The dynamic CSP template lives inside a JS template literal in
+  // CC's extension.js. Across CC releases the first template variable after
+  // `default-src 'none';` has been renamed (`${q}` in 2.1.143 → `${U}` in
+  // 2.1.145), so a literal anchor silently bails on every CC upgrade and
+  // billing telemetry quietly stops. Match the SHAPE instead — any single
+  // template-variable token following `default-src 'none';` — and preserve
+  // whatever variable was there via a capture group. The static (non-templated)
+  // CSP that uses `{{NONCE}}` is intentionally NOT matched here so we don't
+  // touch a CSP shape that's used in a different code path.
+  private readonly CSP_ANCHOR_RE = /default-src 'none'; (\$\{[a-zA-Z_]\w*\})/;
+  private readonly CSP_MARK = "connect-src http://127.0.0.1:*";
+  // H3 (prime-directive): only emit the ADDITIVE connect-src that the loopback
+  // needs (CC ships no connect-src, so there's no duplicate). We must NOT emit
+  // a second `img-src`: CC's own template var (e.g. `${M}` = `img-src
+  // ${cspSource} data:`) follows our insertion, and per CSP3 only the FIRST
+  // `img-src` is honored — so a narrow ad-icon `img-src` here would silently
+  // override CC's image policy and break CC's markdown/data:/webview images.
+  // The ad favicon degrades to the inline SVG "K" badge, which is harmless.
+  private readonly CSP_INSERT_PREFIX =
+    "default-src 'none'; connect-src http://127.0.0.1:* http://localhost:*; ";
+
+  private extTarget(): string {
+    // target = <ext>/anthropic.claude-code-X/webview/index.js
+    // sibling = <ext>/anthropic.claude-code-X/extension.js
+    return join(dirname(dirname(this.target)), "extension.js");
+  }
+  private extBackupPath(): string { return this.extTarget() + ".vibe-ads-backup"; }
+
+  /** Idempotent, reversible connect-src insertion. Never throws.
+   *
+   *  Returns the patch outcome so callers (debug.log) can surface a silent
+   *  miss — `anchor missing` historically meant a CC version bumped the
+   *  template variable name (e.g. ${q} → ${U} between 2.1.143 and 2.1.145),
+   *  which silently broke every webview→loopback fetch (clicks, impressions,
+   *  view-tracking ping-back) and was only detectable by a missing-line audit
+   *  in debug.log. */
+  private patchCspWithReason():
+    { ok: boolean; reason?: "no-sibling" | "already" | "anchor-missing" | "io-err" } {
+    try {
+      const ext = this.extTarget();
+      if (!existsSync(ext)) return { ok: false, reason: "no-sibling" };
+      const src = readFileSync(ext, "utf8");
+      if (src.includes(this.CSP_MARK)) return { ok: true, reason: "already" };
+      const m = this.CSP_ANCHOR_RE.exec(src);
+      if (!m) return { ok: false, reason: "anchor-missing" };
+      if (!existsSync(this.extBackupPath()))
+        writeFileSync(this.extBackupPath(), Buffer.from(src, "utf8")); // pristine
+      // m[1] is the template variable token (e.g. "${q}" or "${U}"); preserve
+      // it so the rest of CC's CSP template renders identically.
+      const replaced = src.replace(this.CSP_ANCHOR_RE,
+        this.CSP_INSERT_PREFIX + m[1]);
+      writeFileSync(ext, Buffer.from(replaced, "utf8"));
+      return { ok: true };
+    } catch { return { ok: false, reason: "io-err" }; }
+  }
+
+  /** Back-compat shim. The href click-out never depended on this — but its
+   *  silent failure DID hide a hard CC-upgrade regression for a release, so
+   *  every applyPatch now also calls patchCspWithReason() and surfaces a dlog
+   *  line (see applyPatch in this file). */
+  private patchCsp(): void { this.patchCspWithReason(); }
+
+  /** Prime the structural connect-src CSP relaxation WITHOUT injecting an ad
+   *  block. See TargetAdapter.prime. Reuses the exact idempotent insertion
+   *  applyPatch performs, so a later applyPatch is a no-op on the CSP and a
+   *  routine restore({keepCsp:true}) keeps it. */
+  prime(): OpResult {
+    const r = this.patchCspWithReason();
+    try {
+      dlog("ext", "csp.prime", { ok: r.ok, reason: r.reason || "ok" });
+    } catch { /* dlog must never break prime */ }
+    return { ok: r.ok, reason: r.reason };
+  }
+
+  /** Byte-exact revert of the CSP patch from its pristine backup. Never throws. */
+  private restoreCsp(): void {
+    try {
+      const bak = this.extBackupPath();
+      if (!existsSync(bak)) return;
+      const pristine = readFileSync(bak);
+      writeFileSync(this.extTarget(), pristine);
+      if (sha256(readFileSync(this.extTarget())) === sha256(pristine))
+        rmSync(bak);
+    } catch { /* best-effort */ }
+  }
+
+  private backupPath(): string { return this.target + ".kickbacks-backup"; }
+  // Pre-rename installs wrote the pristine backup under earlier names; prefer
+  // any existing backup (new OR legacy) so we never (a) lose the real pristine
+  // by overwriting it with an already-patched file, nor (b) report "no backup"
+  // when a legacy one exists. New backups use the .kickbacks-backup name.
+  //   W1 (this rename):   .vibe-ads-backup   (the Vibe-Ads era)
+  //   pre-S3 install:     .vibads-backup     (the original spelling)
+  private legacyBackupPaths(): string[] {
+    return [this.target + ".vibe-ads-backup",
+            this.target + ".vibads-backup"];
+  }
+  private existingBackupPath(): string | null {
+    if (existsSync(this.backupPath())) return this.backupPath();
+    for (const p of this.legacyBackupPaths()) if (existsSync(p)) return p;
+    return null;
+  }
+
+  private findArray(src: string): [number, number] | null {
+    for (const m of src.matchAll(ARRAY_RE)) {
+      if (ANCHORS.some((a) => m[0].includes(a)))
+        return [m.index!, m.index! + m[0].length];
+    }
+    return null;
+  }
+
+  version(): string | null {
+    // Version is the parent extension dir name segment, e.g.
+    // .../anthropic.claude-code-2.1.143/webview/index.js — or, since CC moved to
+    // platform-specific packages, .../anthropic.claude-code-2.1.161-win32-x64/…
+    // Capture just the semver core so neither the status bar nor telemetry ever
+    // shows the "-win32-x64" packaging suffix. Fall back to the raw tail if the
+    // shape is unexpected, so a locatable dir never regresses to "unknown".
+    const core = /anthropic\.claude-code-(\d+\.\d+\.\d+)/.exec(this.target);
+    if (core) return core[1];
+    const loose = /anthropic\.claude-code-([0-9][^/\\]*)/.exec(this.target);
+    return loose ? loose[1] : "unknown";
+  }
+
+  /** True iff the live target currently carries our injected block. One
+   *  cheap read; the debug reassert tick uses it so it only re-applies when
+   *  the patch actually drifted (CC self-update / fresh load), never churning
+   *  the loopback when already healthy. */
+  isPatched(): boolean {
+    try {
+      return existsSync(this.target) &&
+        readFileSync(this.target, "utf8").includes(BLOCK_START);
+    } catch {
+      return false;
+    }
+  }
+
+  /** Ground-truth snapshot for the diagnose command. Never throws — every
+   *  read is guarded so the report renders even on a half-broken install. */
+  diagnose(): AdapterDiagnostics {
+    const out: AdapterDiagnostics = {
+      name: this.name, target: this.target, targetExists: existsSync(this.target),
+      version: this.version(), compatible: false, isPatched: false,
+      backup: { exists: false, path: null, hasArray: false, hasBlock: false },
+      live: { hasArray: false, bareVerbPresent: false },
+    };
+    try {
+      const pf = this.preflight();
+      out.compatible = pf.compatible;
+      out.reason = pf.reason;
+      out.isPatched = this.isPatched();
+      const bakPath = this.existingBackupPath();
+      if (bakPath) {
+        out.backup.exists = true;
+        out.backup.path = bakPath;
+        try {
+          const b = readFileSync(bakPath, "utf8");
+          out.backup.hasArray = this.findArray(b) !== null;
+          out.backup.hasBlock = b.includes(BLOCK_START);
+        } catch { /* leave defaults */ }
+      }
+      if (out.targetExists) {
+        try {
+          const live = readFileSync(this.target, "utf8");
+          out.live.hasArray = this.findArray(live) !== null;
+          // bare verb word present even if NOT inside a matchable array — the
+          // tell that distinguishes "bundle format changed" from "file stripped".
+          out.live.bareVerbPresent =
+            ANCHORS.some((a) => live.includes(a.replace(/"/g, "")));
+        } catch { /* leave defaults */ }
+      }
+    } catch { /* never throw */ }
+    return out;
+  }
+
+  preflight(): PreflightResult {
+    try {
+      if (!existsSync(this.target))
+        return { ok: true, compatible: false, version: null, reason: "target not found" };
+      // Compatibility = the verb array is present in EITHER the pristine backup
+      // OR the live file. We prefer the backup (an older Tier-0 swap could strip
+      // the live anchor), but a STALE/TAINTED backup — a truncated capture, or a
+      // crash / self-update race mid-write — must NOT dead-end activation: fall
+      // back to the live file. The current patch only APPENDS a block and leaves
+      // the verb array intact, so the live file is a safe fallback. Only when
+      // NEITHER source carries the array is the build genuinely incompatible.
+      // This breaks the "bad backup ⇒ permanent incompatible" trap, where
+      // applyPatch's backup recapture never runs because preflight early-returns
+      // first. The reason + source are logged so a field miss is diagnosable.
+      const bak = this.existingBackupPath();
+      const inBackup = bak !== null
+        && this.findArray(readFileSync(bak, "utf8")) !== null;
+      const inLive = inBackup
+        || this.findArray(readFileSync(this.target, "utf8")) !== null;
+      if (!inBackup && !inLive) {
+        try {
+          dlog("ext", "preflight.miss",
+            { hadBackup: bak !== null, version: this.version() });
+        } catch { /* dlog must never break preflight */ }
+        return { ok: true, compatible: false, version: this.version(),
+                 reason: bak !== null
+                   ? "verb array not found (backup+live both stale)"
+                   : "verb array not found (incompatible build)" };
+      }
+      return { ok: true, compatible: true, version: this.version() };
+    } catch (e) {
+      return { ok: false, compatible: false, version: null, reason: String(e) };
+    }
+  }
+
+  private ensureBackup(): Buffer | null {
+    const existing = this.existingBackupPath();
+    if (existing) {
+      const buf = readFileSync(existing);
+      // Backup-integrity check. A backup is a valid pristine source only if it
+      // (a) does NOT already contain our injected block AND (b) actually carries
+      // the verb array. (a) guards a backup captured AFTER an earlier patch
+      // leaked in (CC self-update mid-apply race) — reusing it would compound
+      // the damage. (b) guards a truncated/mangled capture (the stale backup
+      // that makes preflight read no array and dead-end) — patching from it
+      // would inject into broken content and keep failing. Either defect ⇒
+      // delete and recapture from the live file (the taint guard below refuses
+      // the recapture when `target` itself is patched, so a poisoned backup is
+      // never re-minted from a poisoned live file).
+      const tainted = buf.indexOf(BLOCK_START) !== -1;
+      const stale = this.findArray(buf.toString("utf8")) === null;
+      if (tainted || stale) {
+        try { dlog("ext", "backup.recapture",
+          { path: existing, tainted, stale }); } catch { /* ignore */ }
+        try { unlinkSync(existing); } catch { /* fall through */ }
+      } else {
+        return buf;
+      }
+    }
+    const raw = readFileSync(this.target);
+    // Taint guard (cross-window interleave): if the live file ALREADY carries
+    // our block — e.g. another window re-patched right after this window's
+    // restore() deleted the backup — capturing it would enshrine PATCHED bytes
+    // as "pristine": a later restore() would write the ad block back, pass its
+    // own sha check, delete the backup, and leave CC permanently patched even
+    // after opt-out/kill. The patch is not byte-exactly strippable (applyPatch
+    // collapses trailing whitespace before appending), so REFUSE to capture:
+    // no backup is written, applyPatch treats it as apply-success-no-write,
+    // and the next CC self-update delivers a fresh pristine file to capture.
+    if (raw.indexOf(BLOCK_START) !== -1) {
+      try { dlog("ext", "backup.refused",
+        { reason: "live file already patched" }); } catch { /* ignore */ }
+      return null;
+    }
+    writeFileSync(this.backupPath(), raw);
+    return raw; // pristine
+  }
+
+  private renderBlock(p: PatchParams): string {
+    const assetPath = resolveBlockAsset(dirname(__filename));
+    let src = readFileSync(assetPath, "utf8");
+    const subs: Record<string, string> = {
+      __VIBE_ADS_TIER__: String(p.tier),
+      __VIBE_ADS_AD__: JSON.stringify(p.adText),
+      __VIBE_ADS_ICON__: JSON.stringify(p.iconRef),
+      __VIBE_ADS_ICON_URL__: JSON.stringify(p.iconUrl),
+      __VIBE_ADS_PORT__: String(p.loopbackPort),
+      __VIBE_ADS_LBTOKEN__: JSON.stringify(p.loopbackToken),
+      __VIBE_ADS_BASE__: JSON.stringify(p.loopbackBase ?? ""),
+      __VIBE_ADS_DEBUG__: p.debug ? "true" : "false",
+      __VIBE_ADS_CLICKTOKEN__: JSON.stringify(p.clickToken),
+      __VIBE_ADS_CLICKURL__: JSON.stringify(p.clickUrl),
+      __VIBE_ADS_CORR__: JSON.stringify(p.corr),
+      // Mirror-of-spinner usage-banner ad gate (spec §4.1).
+      __VIBE_ADS_BANNER_ON__: p.bannerOn ? "true" : "false",
+      // W3: server-authoritative visible-time threshold (15 s default).
+      __VIBE_ADS_VIEW_THRESHOLD_MS__:
+        String(typeof p.viewThresholdMs === "number"
+          && p.viewThresholdMs > 0 ? p.viewThresholdMs : 15000),
+    };
+    for (const [k, v] of Object.entries(subs))
+      src = src.split(k).join(v);
+    return src.trim();
+  }
+
+  applyPatch(p: PatchParams): OpResult {
+    try {
+      if (!existsSync(this.target)) return { ok: false, reason: "target not found" };
+      const pristineBuf = this.ensureBackup();
+      // Taint-guard refusal: target already patched and no pristine backup
+      // exists to strip-and-reapply from. Do NOT write and do NOT capture —
+      // serving continues off the on-disk block until a CC self-update
+      // supplies a fresh pristine file. See ensureBackup.
+      if (pristineBuf === null)
+        return { ok: true, reason: "already patched; no pristine backup" };
+      const pristine = pristineBuf.toString("utf8");
+      // We still call findArray as a compatibility GATE — if the verb
+      // array literal isn't where we expect, this CC build's spinner
+      // layout has changed and our block's DOM selectors are likely
+      // stale too. Refuse rather than silently inject into a build
+      // we can't reliably target.
+      if (this.findArray(pristine) === null)
+        return { ok: false, reason: "verb array not found" };
+      // We used to ALSO collapse the verb array to `[adText, ""]` so
+      // a degraded run (block fails to load) would at least flash the
+      // ad as plain text in the spinner. That backfired: it MASKED
+      // block.desync — the user couldn't tell when our overlay
+      // failed because the underlying spinner just rendered the ad
+      // text directly. Leaving the array intact means a failed block
+      // shows CC's normal "Discombobulating…" / "Baking…" verbs, a
+      // clear signal that something is wrong. The overlay still
+      // covers the verb via DOM when the block runs.
+      let out = pristine.replace(BLOCK_RE, "").replace(/\s+$/, "");
+      out = out + "\n" + this.renderBlock(p) + "\n";
+      const outBuf = Buffer.from(out, "utf8");
+      if (sha256(outBuf) !== sha256(readFileSync(this.target)))
+        atomicWriteFile(this.target, outBuf);
+      // approach C: revive the loopback (best-effort). Surface the outcome to
+      // debug.log so a future CC CSP-template rename can't silently kill
+      // billing telemetry again (see CSP_ANCHOR_RE comment).
+      const cspResult = this.patchCspWithReason();
+      try {
+        dlog("ext", "csp.patch",
+          { ok: cspResult.ok, reason: cspResult.reason || "ok" });
+      } catch { /* dlog must never break applyPatch */ }
+      return { ok: true };
+    } catch (e) {
+      return { ok: false, reason: String(e) };
+    }
+  }
+
+  // `keepCsp` keeps the sibling extension.js connect-src relaxation in place
+  // while still byte-exact reverting the VISIBLE verb/block change in index.js.
+  // Routine deactivate() passes this: Claude Code captures its webview CSP
+  // template into memory at extension-host load (before our re-patch), so a
+  // CSP reverted on every deactivate would NEVER be effective for the running
+  // session and loopback telemetry (billing) stays CSP-blocked forever. The
+  // relaxation is invisible and loopback-scoped; explicit restore / kill-
+  // switch / sign-out still pass no opts and fully revert it (prime directive
+  // for any user-initiated teardown).
+  restore(opts?: { keepCsp?: boolean }): RestoreResult {
+    try {
+      const bak = this.existingBackupPath();
+      if (bak === null) {
+        // No visible-block backup — e.g. prime() relaxed the CSP on boot but
+        // an ad never arrived to applyPatch a block. An explicit restore (no
+        // keepCsp: kill-switch / sign-out / "Restore Claude Code") must STILL
+        // revert the primed sibling CSP, or a primed-but-never-patched install
+        // would leave CC's extension.js modified with no way back. Mirrors the
+        // codex adapter's restore() (prime directive: opt-out fully reverts).
+        if (!opts?.keepCsp) this.restoreCsp();
+        return { ok: true, restored: false, reason: "no backup present" };
+      }
+      const pristine = readFileSync(bak);
+      // Taint guard: a backup captured from an already-patched live file (a
+      // pre-guard ensureBackup could mint one) carries our block — writing it
+      // verbatim would REINSTATE the ad, pass the sha check below, and delete
+      // the only backup. Strip our own block first so restore always removes
+      // the ad (the stripped bytes are the closest-to-pristine we hold).
+      let out = pristine;
+      if (pristine.indexOf(BLOCK_START) !== -1) {
+        try { dlog("ext", "restore.strip-tainted-backup", { path: bak }); }
+        catch { /* ignore */ }
+        out = Buffer.from(
+          pristine.toString("utf8").replace(BLOCK_RE, ""), "utf8");
+      }
+      writeFileSync(this.target, out);
+      const now = sha256(readFileSync(this.target));
+      if (now !== sha256(out))
+        return { ok: false, restored: false, reason: "sha256 mismatch after restore" };
+      rmSync(bak);
+      if (!opts?.keepCsp) this.restoreCsp(); // approach C: revert sibling CSP
+      return { ok: true, restored: true };
+    } catch (e) {
+      return { ok: false, restored: false, reason: String(e) };
+    }
+  }
+}
diff --git a/src/adapters/claude-code/block.asset.js b/src/adapters/claude-code/block.asset.js
new file mode 100644
index 0000000..6a40888
--- /dev/null
+++ b/src/adapters/claude-code/block.asset.js
@@ -0,0 +1,1268 @@
+/* VIBE-ADS-START */
+(function () {
+  "use strict";
+  var TIER = __VIBE_ADS_TIER__;
+  var AD = __VIBE_ADS_AD__;
+  var ICON_REF = __VIBE_ADS_ICON__;
+  var ICON_URL = __VIBE_ADS_ICON_URL__;
+  var PORT = __VIBE_ADS_PORT__;
+  var LBTOKEN = __VIBE_ADS_LBTOKEN__;
+  var CLICKTOKEN = __VIBE_ADS_CLICKTOKEN__;
+  // Advertiser landing URL, rendered as the anchor's REAL href. Claude Code's
+  // webview ships `default-src 'none'` with no connect-src, so an in-page
+  // fetch/beacon to the loopback is CSP-blocked and postMessage only reaches
+  // CC's own extension (not ours). A genuine http(s) href is the one click-out
+  // that survives: the VS Code webview host itself opens it externally. The
+  // loopback /click ping below is now ONLY the (best-effort) billing metric.
+  var CLICKURL = __VIBE_ADS_CLICKURL__;
+  // When true the injected block also renders the ad in the usage-limit banner
+  // (mirror of the spinner ad; spec §3). False => no DOM scanning at all.
+  var BANNER_ON = __VIBE_ADS_BANNER_ON__;
+  // Correlation id (patch-time minted). Carried on the /click ping and every
+  // relayed dlog line so the merged debug stream is greppable end-to-end.
+  var CORR = __VIBE_ADS_CORR__;
+  var AD_ID = CORR.substring(0, CORR.lastIndexOf("."));
+  // Resolved by the extension via vscode.env.asExternalUri so the webview can
+  // reach the loopback on VS Code Remote/Server (raw 127.0.0.1 there is the
+  // CLIENT, not the extension host). Falls back to local 127.0.0.1.
+  var BASE = __VIBE_ADS_BASE__ || ("http://127.0.0.1:" + PORT + "/vibe-ads/" + LBTOKEN);
+  function fmtElapsed(ms) { return (ms / 1000).toFixed(1) + "s"; }
+  // 0..5 dots (6 frames). Advance is slowed via the render-loop cadence.
+  function ellipsis(frame) {
+    return ["", " .", " ..", " ...", " ....", " ....."][frame % 6];
+  }
+  function esc(s) {
+    return String(s).replace(/[&<>"]/g, function (c) {
+      return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
+    });
+  }
+  // --- Usage-banner ad: banner locator -----------------------------------
+  // The orange weekly/usage-limit banner ("You've used 71% of your weekly
+  // limit · resets in 4d · View usage") is server-data-rendered, so there is
+  // no static array literal to Tier-0 swap — the only lever is a DOM rewrite
+  // from this injected block, exactly like the spinner. This predicate is the
+  // locator anchor: it must be specific enough that NOTHING else in the chat
+  // webview ever matches (rewriting unrelated chrome would be a prime-directive
+  // violation). Require BOTH a usage-percentage clause AND a reset clause —
+  // either one alone is too generic (docs/prose can say "resets in …").
+  function looksLikeUsageBanner(text) {
+    var t = String(text || "");
+    if (!t) return false;
+    var hasLimit = /\b\d{1,3}%\s+of your\b[^]*\b(?:weekly|usage)\s+limit\b/i.test(t);
+    var hasReset = /\bresets?\s+in\s+\d/i.test(t);
+    return hasLimit && hasReset;
+  }
+  var FAVICON_FALLBACK =
+    '<svg width="13" height="13" viewBox="0 0 13 13" aria-hidden="true" ' +
+    'style="vertical-align:middle;border-radius:3px;flex:0 0 auto">' +
+    '<rect width="13" height="13" rx="3" fill="#188a45"/>' +
+    '<text x="6.5" y="9.6" font-size="9" font-family="monospace" ' +
+    'font-weight="700" text-anchor="middle" fill="#fff">K</text></svg>';
+  // The ad-icon <img>. Tagged data-va-icon="1" so the capture-phase error
+  // listener (see init) can swap it for the inline 'K' SVG if the image fails
+  // to load — covers a blocked/404 icon. NB the swap MUST be wired
+  // programmatically: CC's webview CSP is script-src 'nonce-...' with no
+  // 'unsafe-inline', so an inline onerror="" attribute would itself be blocked.
+  function faviconImg(url) {
+    return '<img src="' + esc(url) + '" width="13" height="13" ' +
+      'data-va-icon="1" aria-hidden="true" ' +
+      'style="vertical-align:middle;border-radius:3px;' +
+      'flex:0 0 auto;display:block;object-fit:contain" />';
+  }
+  var FAVICON = ICON_URL ? faviconImg(ICON_URL) : FAVICON_FALLBACK;
+
+  // The block renders ONLY the ad, and ONLY while a turn is active. There is
+  // no done-state and no Continue CTA (removed): when Claude Code is idle the
+  // block does nothing and leaves the last rendered frame in place (the render
+  // loop simply stops clobbering it).
+  function buildAdHtml(tier, s) {
+    var ad = esc(s.ad), dots = esc(s.dots || "");
+    // Real navigable href (the VS Code host opens http(s) externally on click,
+    // bypassing the webview CSP). `target=_blank rel=noopener` is belt-and-
+    // suspenders; falls back to "#" only when no URL was supplied (tests).
+    var href = s.href ? esc(s.href) : "#";
+    // text-decoration:underline => the ad reads as the clickable hyperlink it
+    // is (was `none`, looked like inert label text).
+    var A1 = '<a href="' + href + '" target="_blank" ' +
+      'rel="noopener noreferrer" data-vibe-ads-ad="1" style="color:';
+    var FG = "var(--vscode-foreground,currentColor)";
+    var DIM = "var(--vscode-descriptionForeground,currentColor)";
+    // Animated dots live in a FIXED-WIDTH slot so their changing length
+    // can't reflow the left block and shove the right-hand timer around.
+    // Monospace-ish + width reserved for the longest.
+    var DOTS = '<span data-va-dots="1" style="display:inline-block;width:3ch;'
+      + 'text-align:left;white-space:pre">' + dots + "</span>";
+    var anchor = A1 + FG + ';text-decoration:underline">' + ad + DOTS + "</a>";
+    if (tier <= 1) return anchor;
+    var fav = (tier >= 3) ? FAVICON : "";
+    var left = '<span style="display:flex;align-items:center;gap:7px;color:' +
+      FG + ';min-width:0">' + fav + A1 + FG +
+      ';text-decoration:underline;overflow:hidden;text-overflow:ellipsis">' +
+      ad + DOTS + "</a></span>";
+    // Right segment PINNED to the right edge (margin-left:auto) and rendered
+    // in a tabular monospace so digit-count changes (0.9s→1.2s) don't jitter.
+    var rt = esc(s.elapsed || "");
+    var right = '<span data-va-elapsed="1" style="font-size:11px;color:' + DIM +
+      ';flex:0 0 auto;margin-left:auto;padding-left:24px;' +
+      'font-family:var(--vscode-editor-font-family,ui-monospace,monospace);' +
+      'font-variant-numeric:tabular-nums">' + rt + "</span>";
+    // Left-justified favicon+ad (hugs the left edge); the timer is pinned
+    // right via margin-left:auto. box-sizing:border-box so the padding
+    // insets without 100% width overflowing the row.
+    return '<span style="display:flex;align-items:center;width:100%;' +
+      'box-sizing:border-box;padding:0 32px;' +
+      'justify-content:flex-start;white-space:nowrap">' + left + right +
+      "</span>";
+  }
+
+  // Usage-banner ad: a single-line clickable anchor mirroring the spinner
+  // creative. data-vibe-ads-ad="1" reuses the existing capture-phase click
+  // listener (no new click plumbing). Pure; unit-tested via module.exports.
+  function buildBannerHtml(ad, clickUrl) {
+    var href = /^https?:\/\//i.test(clickUrl || "") ? esc(clickUrl) : "#";
+    var FG = "var(--vscode-foreground,currentColor)";
+    // Wrap favicon+anchor in our own left-justified inline-flex so CC's
+    // banner container styling can't fling the text to the far right away
+    // from the logo; underline marks it as the clickable link it is.
+    return '<span style="display:inline-flex;align-items:center;gap:6px;' +
+      'justify-content:flex-start">' + FAVICON +
+      '<a href="' + href + '" target="_blank" rel="noopener noreferrer" ' +
+      'data-vibe-ads-ad="1" style="color:' + FG +
+      ';text-decoration:underline">' + esc(ad) + "</a></span>";
+  }
+
+  if (typeof module !== "undefined" && module.exports) {
+    module.exports = { fmtElapsed: fmtElapsed, ellipsis: ellipsis, esc: esc,
+      buildAdHtml: buildAdHtml, looksLikeUsageBanner: looksLikeUsageBanner,
+      buildBannerHtml: buildBannerHtml };
+    return;
+  }
+
+  try {
+    var st = { simStart: Date.now(), frame: 0, sentRender: false,
+               wasVisible: false };
+    // (Verb is now located by CC's `spinnerRow_` container class, telemetry-
+    // confirmed — NOT a glyph heuristic. The old glyph-prefix scan matched
+    // Monaco editor / **markdown** spans and clobbered the user's document,
+    // a prime-directive violation; class-scoping makes that impossible.)
+    var realAct = null;
+    // Cached refs to the volatile children of the overlay's anchor/right
+    // span. paint() updates only their textContent on the hot path so the
+    // anchor element itself is NEVER detached between mousedown/mouseup —
+    // the previous innerHTML-every-tick rewrite (~12×/sec) was racing the
+    // user's click and silently dropping it. Cleared on dropOverlay; refilled
+    // on a structural rebuild.
+    var _chromeSig = "", _dotsEl = null, _elapsedEl = null;
+    // Verb-pinned clobber model. The ad is written into Claude Code's OWN
+    // verb element; an observer replaces freshly-mounted verb nodes pre-paint
+    // (no flicker). A node is identified as the verb PURELY by current
+    // glyph-led content (NOT by an attribute) — an attribute marker poisoned
+    // the node forever once CC re-rendered its verb back into the same
+    // element in place, which is why the ad vanished mid-thinking and never
+    // returned. GRACE_MS bridges the (sometimes >1s) gaps between CC's
+    // intra-turn verb re-renders; only after a real GRACE of no verb at all
+    // do we clear (matches the long-proven loop). `verb.dom` dlog captures
+    // the real CC verb DOM (identity reuse / gap timings / structure) so the
+    // pin can be tightened from evidence next session.
+    var lastNode = null, lastSeenMs = 0;
+    // Idle debounce: how long the spinner may be stale before we drop the
+    // overlay. Empirically (e2e DOM dumps, CC 2.1.143): while thinking CC
+    // re-renders spinnerRow every animation frame so its textContent CHANGES
+    // continuously; at turn end CC empties spinnerRow to `<div></div>` but
+    // leaves the `.spinnerRow_` node mounted, and can leave a STALE prior node
+    // frozen mid-glyph. Glyph PRESENCE is therefore NOT a liveness signal — a
+    // frozen stale node kept rowActive() true forever, so paint() kept
+    // refreshing lastSeenMs and the ad animated at idle indefinitely (the
+    // row-03 regression, and why _spinnerActive never cleared so the banner
+    // ad never un-hid). The true signal is content FRESHNESS: active ==
+    // glyph-led AND the spinner content changed within GRACE_MS.
+    var GRACE_MS = 1500;
+    // Freshness: signature of the live spinner content + when it last
+    // changed. A frozen/stale node's signature never changes => idle after
+    // GRACE_MS even though a glyph char is still present.
+    var lastSig = null, lastSigMs = 0;
+    var _foundLogged = false, _shown = false;
+    var _verbSeq = 0, _lastVerbEl = null, _lastVerbMs = 0;
+
+    var DEBUG = __VIBE_ADS_DEBUG__;
+    var _seq = 0, _dlogFails = 0;
+    // Relay a timestamped lifecycle line to the loopback (→ server-side
+    // ~/.vibe-ads/debug.log) so a headless agent can diagnose without a
+    // screen. Gated; never disturbs the spinner. The first line arriving at
+    // all is itself the proof the loopback is reachable on Remote.
+    function dlog(evt, data) {
+      if (!DEBUG) return;
+      try {
+        var o = { n: ++_seq, evt: evt, corr: CORR };
+        if (data) for (var k in data) o[k] = data[k];
+        fetch(BASE + "/log", { method: "POST", keepalive: true,
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify(o) }).catch(function () { _dlogFails++; });
+      } catch (e) { _dlogFails++; }
+    }
+    function ping(kind) {
+      // Click pings have to race the VS Code webview host's external-
+      // navigation on the same tick: the moment the anchor's href fires,
+      // the document may be torn down. fetch+keepalive often makes it
+      // out, but sendBeacon was designed for exactly this lifecycle
+      // ("send this beacon during unload, browser, please") and is the
+      // most reliable path across Electron versions. Try sendBeacon
+      // first; fall back to fetch+keepalive if unavailable or rejected.
+      try {
+        var url = BASE + "/" + kind;
+        var sent = false;
+        try {
+          if (navigator && typeof navigator.sendBeacon === "function") {
+            sent = navigator.sendBeacon(url, new Blob([],
+              { type: "application/x-www-form-urlencoded" }));
+            if (sent) dlog("ping.ok", { kind: kind, via: "beacon" });
+          }
+        } catch (e) { sent = false; }
+        if (sent) return;
+        fetch(url, { method: "POST", keepalive: true })
+          .then(function () { dlog("ping.ok", { kind: kind, via: "fetch" }); })
+          .catch(function () { dlog("ping.fail", { kind: kind, via: "fetch" }); });
+      } catch (e) { /* metrics best-effort; never affect the spinner */ }
+    }
+    function newEventUuid() {
+      try {
+        if (typeof crypto !== "undefined"
+            && typeof crypto.randomUUID === "function") {
+          return crypto.randomUUID();
+        }
+      } catch (e) { /* fall through */ }
+      try {
+        return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,
+          function (c) {
+            var r = Math.random() * 16 | 0;
+            var v = c === "x" ? r : (r & 0x3 | 0x8);
+            return v.toString(16);
+          });
+      } catch (e) { return "evt-" + Date.now() + "-" + Math.random(); }
+    }
+
+    // ---- W3 view-time accumulator (absolute-epoch baseline) -------------
+    // An ad session's elapsed time is computed as `Date.now() -
+    // sessionStartedAt` on every 250 ms poll. No accumulator, no
+    // pause-on-hide. Rationale: the prior accumulator-with-pause model
+    // dropped time whenever setInterval skipped a tick (throttled tab,
+    // CPU pressure, the webview backgrounded) and produced "stuck session
+    // never bills" log signatures. Absolute wall clock is immune to poll
+    // cadence drift, at the cost of counting time while the webview is
+    // hidden — bounded in practice by the threshold-met/error_impression
+    // mutex (one bill per session) plus the server-side cooldown gate.
+    // Threshold is server-overridable via /v1/portfolio.view_threshold_seconds
+    // (baked into the block as __VIBE_ADS_VIEW_THRESHOLD_MS__; falls back
+    // to 15_000 ms when the server didn't specify). Pure best-effort:
+    // any throw is swallowed (prime directive).
+    var THRESHOLD_MS = (typeof __VIBE_ADS_VIEW_THRESHOLD_MS__ === "number"
+      && __VIBE_ADS_VIEW_THRESHOLD_MS__ > 0)
+      ? __VIBE_ADS_VIEW_THRESHOLD_MS__ : 15000;
+    var TICK_MS = 5000;
+    // MAX_SESSION_MS billing cap: fire `error_impression` at EVERY
+    // multiple of this elapsed mark. Default 5 s, so a 30 s stuck
+    // session fires 6 events. The backend cooldown gate decides which
+    // of those actually credit; with the matching 5 s cooldown default
+    // every fire credits. threshold_met is suppressed once any
+    // error_impression has fired this session (preserves the codex-
+    // rescue "one billing path per session" mutex while allowing the
+    // billing path itself to repeat).
+    var MAX_SESSION_MS = 5000;
+    var SESSION_NONCE = (function () {
+      try {
+        return (Math.random().toString(36).slice(2)
+          + Math.random().toString(36).slice(2)).slice(0, 16);
+      } catch (e) { return "s" + Date.now(); }
+    })();
+    var _vt = Object.create(null);  // key "surface:adId" -> session record
+    function vtKey(adId, surface) { return surface + ":" + adId; }
+    function viewShow(adId, surface) {
+      if (!adId) return;
+      var k = vtKey(adId, surface);
+      var s = _vt[k];
+      if (!s) {
+        // sessionStartedAt is set ONCE on session creation and is sticky
+        // for the lifetime of (adId, surface, sessionNonce). Subsequent
+        // viewShow() calls with the same nonce do NOT restart it.
+        // errorImpressionCount tracks how many error_impressions have
+        // fired so far; the next one fires at (count+1)*MAX_SESSION_MS.
+        _vt[k] = { adId: adId, surface: surface,
+          sessionNonce: SESSION_NONCE,
+          sessionStartedAt: Date.now(),
+          lastTickMs: 0, thresholdMet: false,
+          errorImpressionCount: 0,
+          // PERSIST-AT-IDLE billing pause. While `paused` no events emit
+          // and elapsed is frozen at `pausedAt`; the next active turn
+          // resumes by advancing sessionStartedAt past the idle gap so the
+          // idle time is NEVER billed. (overlay surface only — see
+          // viewPause/dockOverlay.)
+          paused: false, pausedAt: 0 };
+        return;
+      }
+      // Resume a paused (idle-frozen) session: shift the absolute baseline
+      // forward by the idle gap so the time the ad merely sat on screen is
+      // not counted, then un-pause. lastTickMs/errorImpressionCount are
+      // unchanged so emission cadence continues exactly where it left off.
+      if (s.paused) {
+        s.sessionStartedAt += Math.max(0, Date.now() - (s.pausedAt || Date.now()));
+        s.paused = false;
+        s.pausedAt = 0;
+      }
+      // No further anchor/baseline mutation — the absolute-epoch model uses
+      // a single sticky sessionStartedAt (adjusted only across idle pauses).
+    }
+    // Suspend billing for an ad+surface without dropping its session, so the
+    // overlay can persist on screen at idle while emitting NO view-time
+    // events (user request: do not induce billing during idle periods).
+    function viewPause(adId, surface) {
+      if (!adId) return;
+      var s = _vt[vtKey(adId, surface)];
+      if (s && !s.paused) { s.paused = true; s.pausedAt = Date.now(); }
+    }
+    // END a view session outright (ported from the codex phantom-billing
+    // fix — codex/block.asset.js viewEnd). Deleting the record stops the
+    // 250ms accumulator the moment the ad leaves the screen; a later
+    // viewShow() opens a FRESH session (new baseline, counters reset).
+    function viewEnd(adId, surface) {
+      try { delete _vt[vtKey(adId, surface)]; } catch (e) { /* best-effort */ }
+    }
+    function viewHide(adId, surface) {
+      // Was a documented no-op, which left every hide call site —
+      // dropOverlay, banner-hidden-during-turn, banner-gone — with an
+      // immortal session emitting view_tick/error_impression every 5s
+      // off-screen (the exact codex phantom-billing class). Hide now ENDS
+      // the session. PERSIST-AT-IDLE is unaffected: dockOverlay uses
+      // viewPause(), which keeps the session for the thaw to resume.
+      viewEnd(adId, surface);
+    }
+    function viewMaybeEmit(s) {
+      // Paused (idle-frozen) sessions emit nothing: no view_tick,
+      // threshold_met, or error_impression while the ad merely persists.
+      if (s.paused) return;
+      var elapsed = Math.max(0, Date.now() - s.sessionStartedAt);
+      // Tick at every TICK_MS boundary of elapsed time.
+      var tickFired = false;
+      while (elapsed - s.lastTickMs >= TICK_MS) {
+        s.lastTickMs += TICK_MS;
+        tickFired = true;
+        var tickEventUuid = newEventUuid();
+        var q = "?surface=" + encodeURIComponent(s.surface)
+          + "&ad=" + encodeURIComponent(s.adId)
+          + "&visible_ms=" + s.lastTickMs
+          + "&session=" + encodeURIComponent(s.sessionNonce)
+          + "&event_uuid=" + encodeURIComponent(tickEventUuid);
+        ping("view_tick" + q);
+      }
+      // When view_tick fires, advance errorImpressionCount past the same
+      // boundary so error_impression can't re-fire it on the next poll
+      // (tickFired resets to false each call).
+      if (tickFired && MAX_SESSION_MS > 0) {
+        var syncCount = Math.floor(s.lastTickMs / MAX_SESSION_MS);
+        if (syncCount > s.errorImpressionCount) {
+          s.errorImpressionCount = syncCount;
+        }
+      }
+      // Mutual exclusion: threshold_met fires once per session AND only
+      // when no error_impression has fired yet this session. With the
+      // default cap=5s / threshold=15s, error_impression always fires
+      // first → threshold_met effectively never fires. Kept for the
+      // disabled-cap config (MAX_SESSION_MS=0).
+      if (!s.thresholdMet && s.errorImpressionCount === 0
+          && elapsed >= THRESHOLD_MS) {
+        s.thresholdMet = true;
+        var thresholdEventUuid = newEventUuid();
+        var q2 = "?surface=" + encodeURIComponent(s.surface)
+          + "&ad=" + encodeURIComponent(s.adId)
+          + "&visible_ms=" + elapsed
+          + "&threshold_ms=" + THRESHOLD_MS
+          + "&session=" + encodeURIComponent(s.sessionNonce)
+          + "&event_uuid=" + encodeURIComponent(thresholdEventUuid);
+        dlog("view.threshold_met", { adId: s.adId, surface: s.surface,
+          visibleMs: elapsed, eventUuid: thresholdEventUuid });
+        ping("view_threshold_met" + q2);
+      }
+      // error_impression is the fallback for view_tick — only fire when
+      // view_tick did NOT fire this poll. When both hit the same 5s
+      // boundary they race the backend cooldown gate and double-bill.
+      var nextFireAt = (s.errorImpressionCount + 1) * MAX_SESSION_MS;
+      if (!tickFired && !s.thresholdMet && elapsed >= nextFireAt) {
+        s.errorImpressionCount += 1;
+        var errorEventUuid = newEventUuid();
+        var q3 = "?surface=" + encodeURIComponent(s.surface)
+          + "&ad=" + encodeURIComponent(s.adId)
+          + "&visible_ms=" + elapsed
+          + "&max_session_ms=" + MAX_SESSION_MS
+          + "&fire=" + s.errorImpressionCount
+          + "&session=" + encodeURIComponent(s.sessionNonce)
+          + "&event_uuid=" + encodeURIComponent(errorEventUuid);
+        dlog("view.error_impression", { adId: s.adId, surface: s.surface,
+          visibleMs: elapsed, fire: s.errorImpressionCount,
+          eventUuid: errorEventUuid });
+        ping("error_impression" + q3);
+      }
+    }
+    // Snapshot of the elapsed session time for an ad+surface RIGHT NOW.
+    // Read at click time so the loopback can apply the click-threshold
+    // floor: clicks before the ad has been on-screen long enough are
+    // logged but not billed (anti-misclick / anti-bot). Returns 0 when
+    // the surface was never shown.
+    function viewVisibleMsNow(adId, surface) {
+      try {
+        var s = _vt[vtKey(adId, surface)];
+        if (!s) return 0;
+        // While paused, elapsed is frozen at pausedAt (idle time excluded).
+        var end = (s.paused && s.pausedAt) ? s.pausedAt : Date.now();
+        return Math.max(0, end - s.sessionStartedAt);
+      } catch (e) { return 0; }
+    }
+
+    // Suspend/wake clamp: elapsed is absolute wall-clock, so a laptop
+    // suspend (or a long-frozen webview) would otherwise be billed as
+    // visible time AND replayed as a synchronous view_tick burst on wake
+    // (one tick per 5s of sleep — ~5760 POSTs after an 8h sleep). viewTick
+    // tracks when it last polled; a gap of more than two tick intervals
+    // means nothing was actually on screen, so every live (unpaused)
+    // session's baseline is shifted forward past the gap, keeping at most
+    // one TICK_MS of it billable — the catch-up loop then fires at most
+    // ONE tick per wake. Paused sessions are skipped: their resume path
+    // already excludes the whole pause window via pausedAt.
+    var SUSPEND_GAP_MS = TICK_MS * 2;
+    var _vtLastPollMs = 0;
+    function viewTick() {
+      try {
+        var now = Date.now();
+        var gap = _vtLastPollMs > 0 ? (now - _vtLastPollMs) : 0;
+        _vtLastPollMs = now;
+        if (gap > SUSPEND_GAP_MS) {
+          var excess = gap - TICK_MS;
+          for (var pk in _vt) {
+            var ps = _vt[pk];
+            if (!ps.paused) {
+              // Math.min: never push a baseline into the future (a session
+              // created post-wake, before this poll, must start at 0).
+              ps.sessionStartedAt =
+                Math.min(now, ps.sessionStartedAt + excess);
+            }
+          }
+        }
+        for (var k in _vt) viewMaybeEmit(_vt[k]);
+      } catch (e) { /* prime directive */ }
+    }
+    setInterval(viewTick, 250);
+    // visibilitychange flush removed in the absolute-epoch baseline: a
+    // hidden webview keeps counting. Worst case bound = one bill per ad
+    // per cooldown window (mutex + server-side gate). See top-of-section
+    // comment for the full rationale.
+    dlog("block.start", { base: BASE, tier: TIER, href: location.href });
+    var _actLogged = false;
+    function pollActivity() {
+      try {
+        fetch(BASE + "/activity").then(function (r) { return r.json(); })
+          .then(function (j) {
+            realAct = j;
+            if (!_actLogged) { _actLogged = true;
+              dlog("activity.first", { tool: j && j.tool, done: j && j.done }); }
+          }).catch(function () {
+            if (!_actLogged) { _actLogged = true; dlog("activity.fail"); }
+          });
+      } catch (e) { /* ignore */ }
+    }
+    // Telemetry-CONFIRMED: CC's "thinking" verb is a container whose class is
+    // `spinnerRow_<hash>` (inside `messagesContainer_<hash> stickyMode_…`),
+    // and CC animates its glyph in a CHILD of that row. We target THAT exact
+    // element by its stable `spinnerRow_` class prefix — NOT a glyph/markdown
+    // heuristic (which clobbered the Monaco editor: prime-directive
+    // violation) and NOT by destroying its innerHTML (which killed CC's
+    // animated child, froze our liveness signal, and made the ad vanish
+    // after GRACE and never return). The hash suffix can change across CC
+    // builds; matching the prefix substring is resilient, and if CC renames
+    // it entirely the ad simply doesn't show (safe — prime directive over
+    // visibility; the verb.dom telemetry will reveal the new class).
+    // Prefer a spinnerRow with NON-EMPTY content (the live one). At turn end
+    // CC empties the current spinnerRow to `<div></div>` (textContent "") but
+    // keeps the `.spinnerRow_` node — returning that emptied node made the
+    // idle test rely on rowActive() alone. An emptied/whitespace-only row is
+    // treated as "no spinner" (=> idle path); only a node with real content
+    // is a candidate. Still strictly class-scoped (cannot match editor /
+    // markdown — prime directive preserved).
+    // Audit #28: among those already-matched candidates prefer the LAST
+    // non-empty row in document order. The transcript APPENDS, so the live
+    // animating row is always the latest; CC can leave a STALE prior row
+    // frozen mid-glyph (see GRACE_MS note above) while keeping it mounted —
+    // returning the FIRST non-empty row let that dead row shadow the live
+    // one mounted below it, so the freshness signature never changed and
+    // the ad was suppressed for the whole turn. The selector and the
+    // observation scope are UNCHANGED (prime directive: never widen
+    // detection); only the choice AMONG matched rows differs. Known corner:
+    // when the live row is emptied at turn end while a stale row remains,
+    // the stale row briefly becomes "the" row again — its frozen signature
+    // fails the freshness gate within GRACE_MS, so the normal idle dock
+    // follows, same as before.
+    function findSpinner() {
+      var els = document.querySelectorAll('[class*="spinnerRow_"]');
+      var last = null;
+      for (var i = 0; i < els.length; i++) {
+        if (els[i].nodeType !== 1) continue;
+        if ((els[i].textContent || "").trim() !== "") last = els[i];
+      }
+      return last;
+    }
+    // Liveness gate: CC keeps spinnerRow MOUNTED after the turn (it only
+    // vanished before because our mutation made React tear it out — we no
+    // longer mutate). So "row present" is not idle-vs-active. CC animates a
+    // sparkle glyph (✢✶✻✽) while thinking and stops it at turn end. Reading
+    // the row's textContent is read-only / React-safe; this only ever
+    // inspects the element already pinned by the spinnerRow_ class, so it
+    // can never match editor/markdown text (no prime-directive risk).
+    function rowActive(row) {
+      if (!row) return false;
+      var t = (row.textContent || "").replace(/^[\s ]+/, "");
+      var c = t.charCodeAt(0);
+      // ✢ U+2722, ✶ U+2736, ✻ U+273B, ✽ U+273D
+      return c === 0x2722 || c === 0x2736 || c === 0x273b || c === 0x273d;
+    }
+    // The overlay MUST be opaque to cover CC's real verb glyph behind it
+    // (transparent => the verb shows through and overlaps the ad into
+    // garble). Sample CC's actual surface colour read-only (getComputedStyle
+    // is reconciliation-safe): walk ancestors for the first non-transparent
+    // background so the box blends into the chat panel (theme-matched) and
+    // is never a hardcoded mismatched colour.
+    function surfaceBg(el) {
+      try {
+        var n = el, hops = 0;
+        while (n && n.nodeType === 1 && hops++ < 10) {
+          var bg = (window.getComputedStyle(n) || {}).backgroundColor;
+          if (bg && bg !== "transparent" &&
+              bg !== "rgba(0, 0, 0, 0)") return bg;
+          n = n.parentElement;
+        }
+      } catch (e) { /* no layout / jsdom — fall through */ }
+      return "var(--vscode-editor-background,#1e1e1e)";
+    }
+
+    // Clicking the ad: the anchor's real http(s) href is what actually opens
+    // the advertiser page — the VS Code webview host navigates it externally,
+    // which is the ONLY click-out that survives CC's `default-src 'none'` CSP.
+    // We therefore do NOT preventDefault (that would suppress the host's open).
+    // The loopback /click ping is now purely the fire-and-forget billing
+    // metric (CSP-revived by the companion extension.js connect-src patch);
+    // losing it must never cost the click-through. Capture phase, fully guarded.
+    document.addEventListener("click", function (ev) {
+      var el = ev.target;
+      while (el && el !== document) {
+        if (el.getAttribute && el.getAttribute("data-vibe-ads-ad")) {
+          // Walk up to find which surface contains this anchor so the click
+          // ping carries surface= (mirrors impression events).
+          var surface = "overlay";
+          var p = el;
+          while (p && p !== document) {
+            try {
+              if (p.getAttribute && p.getAttribute("data-vibe-ads-banner") === "1") {
+                surface = "banner"; break;
+              }
+              if (p.getAttribute && p.getAttribute("data-vibe-ads-overlay") === "1") {
+                surface = "overlay"; break;
+              }
+            } catch (e) { /* prime directive */ }
+            p = p.parentNode;
+          }
+          // Include the current cumulative visible_ms so the extension-
+          // side loopback can apply the click-threshold floor (don't bill
+          // clicks before the ad has been visible long enough).
+          var vms = viewVisibleMsNow(AD, surface);
+          var clickEventUuid = newEventUuid();
+          dlog("click.ad", { ct: CLICKTOKEN, surface: surface,
+            visibleMs: vms, eventUuid: clickEventUuid });
+          // `ad=` is the attribution CLAIM — the same param the view-event
+          // pings carry (the pollAd-adopted identifier the block keys its
+          // _vt sessions on, i.e. the AD text). Without it the host's
+          // recent-ads registry (audit #17) could not resolve a click that
+          // lands during the ≤10s /ad poll lag after a rotation and billed
+          // it to the freshly-rotated campaign instead.
+          ping("click?ct=" + encodeURIComponent(CLICKTOKEN)
+            + "&corr=" + encodeURIComponent(CORR)
+            + "&surface=" + encodeURIComponent(surface)
+            + "&visible_ms=" + vms
+            + "&ad=" + encodeURIComponent(AD)
+            + "&event_uuid=" + encodeURIComponent(clickEventUuid));
+          return;
+        }
+        el = el.parentNode;
+      }
+    }, true);
+
+    // Ad-icon load failure → inline 'K' badge. The icon normally arrives as a
+    // CSP-safe data: URI; this catches the residual cases (a stored https GCS
+    // URL the backend couldn't inline, or a genuine 404) so the slot never
+    // renders empty. `error` events don't bubble but DO fire in the capture
+    // phase on ancestors, so one document-level capture listener covers the
+    // overlay AND the banner. Programmatic by necessity — an inline onerror=""
+    // is blocked by CC's webview script-src CSP. Idempotent: swapping outerHTML
+    // for the SVG removes the data-va-icon node, so it can't re-fire.
+    document.addEventListener("error", function (ev) {
+      try {
+        var t = ev && ev.target;
+        if (t && t.tagName === "IMG" &&
+            t.getAttribute && t.getAttribute("data-va-icon") === "1") {
+          t.outerHTML = FAVICON_FALLBACK;
+        }
+      } catch (e) { /* prime directive: never let this disturb the page */ }
+    }, true);
+
+    // --- Usage-banner rewrite loop -------------------------------------
+    // Runs every 1 s independent of the spinner's `active` gate (the banner
+    // is visible at idle). findUsageBanner picks the tightest matching element
+    // via looksLikeUsageBanner; when found, replaces its innerHTML with a
+    // single clickable ad anchor (buildBannerHtml) mirroring the spinner
+    // creative. Guarded against rewrite thrash via _bannerLast equality check;
+    // found/miss each logged once.
+    function findUsageBanner() {
+      var els = document.querySelectorAll("span, div, p");
+      var best = null, bestLen = Infinity;
+      for (var i = 0; i < els.length; i++) {
+        var el = els[i];
+        if (el.children.length > 8) continue;          // not a leaf-ish node
+        var t = (el.textContent || "").trim();
+        if (t.length > 240) continue;                   // banner is short
+        if (!looksLikeUsageBanner(t)) continue;
+        if (t.length < bestLen) { best = el; bestLen = t.length; }
+      }
+      return best;
+    }
+    var _bannerLogged = false, _bannerLast = "", _bannerMissLogged = false;
+    var _bannerEl = null;
+    // Fires impression_rendered/viewable exactly once per session for the
+    // banner surface. Spinner has its own (st.sentRender / st.wasVisible).
+    var _bannerSentRender = false, _bannerSentViewable = false;
+    // No-serve latch (wave-4 carry-over of the wave-2 /ad gate): the host
+    // answers /ad with an EMPTY payload on a confirmed kill, a deliberate
+    // disable, or any serving-gate refusal. A fetch ERROR is transient
+    // network and keeps today's keep-last-ad behavior; a SUCCESSFUL empty
+    // response is the no-serve signal. After TWO empty reads with no served
+    // payload between them (debounce against a single racing read; fetch
+    // errors neither count nor reset — only a real payload proves serving
+    // resumed) the block stops SHOWING anything — overlay dropped, banner
+    // hidden, every view session ended — instead of leaving the last
+    // creative painted (unbillable but visible) until idle/reload. Any
+    // served payload re-arms. The signed-out demo flow serves real
+    // payloads, so this only engages on a genuine stop.
+    var _adEmptyPolls = 0, _noServe = false;
+    if (BANNER_ON) setInterval(function () {
+      try {
+        // No-serve: keep the banner hidden and its session ended until the
+        // host serves again (see pollAd / enterNoServe).
+        if (_noServe) {
+          if (_bannerEl) {
+            try { _bannerEl.style.setProperty("display", "none", "important"); }
+            catch (e) { /* prime directive */ }
+          }
+          try { viewHide(AD, "banner"); } catch (e) { /* prime directive */ }
+          return;
+        }
+        // IDLE-ONLY (user request): while CC is actively thinking the
+        // spinner ad covers that turn — hide the banner ad. We keep a ref
+        // to the element we clobbered (its text no longer matches
+        // looksLikeUsageBanner, so findUsageBanner can't re-find it) and
+        // just toggle display.
+        if (_spinnerActive) {
+          if (_bannerEl) {
+            try { _bannerEl.style.setProperty("display", "none", "important"); }
+            catch (e) { /* prime directive */ }
+          }
+          // W3: banner is hidden during spinner; END its visibility session
+          // (it must not bill while display:none — a fresh session starts
+          // when the banner un-hides at idle).
+          try { viewHide(AD, "banner"); } catch (e) { /* prime directive */ }
+          return;
+        }
+        var el = (_bannerEl && _bannerEl.isConnected)
+          ? _bannerEl : findUsageBanner();
+        if (!el) {
+          if (!_bannerMissLogged) { _bannerMissLogged = true;
+            dlog("banner.miss", {}); }
+          // No banner element to render into → make sure the accumulator
+          // isn't counting.
+          try { viewHide(AD, "banner"); } catch (e) { /* prime directive */ }
+          return;
+        }
+        try { el.style.removeProperty("display"); } catch (e) {}  // un-hide
+        if (!_bannerLogged) { _bannerLogged = true;
+          dlog("banner.found", { len: (el.textContent || "").length }); }
+        var html = buildBannerHtml(AD, CLICKURL);
+        if (el.getAttribute("data-vibe-ads-banner") === "1" &&
+            _bannerLast === html) {
+          // Still mounted + unchanged: ensure accumulator is running.
+          try { viewShow(AD, "banner"); } catch (e) { /* prime directive */ }
+          return;
+        }
+        el.innerHTML = html;
+        el.setAttribute("data-vibe-ads-banner", "1");
+        _bannerEl = el; _bannerLast = html;
+        // W3: banner is now visible — start (or resume) accumulation.
+        try { viewShow(AD, "banner"); } catch (e) { /* prime directive */ }
+        // Initial impressions for the banner surface. Guarded so we only
+        // fire once per block session (true first commit of the banner ad).
+        if (!_bannerSentRender) {
+          ping("impression_rendered?surface=banner&ad=" + encodeURIComponent(AD)
+            + "&event_uuid=" + encodeURIComponent(newEventUuid()));
+          _bannerSentRender = true;
+        }
+        if (!_bannerSentViewable) {
+          var bVis = (typeof document.hidden === "undefined")
+            ? true : !document.hidden;
+          if (bVis) {
+            ping("impression_viewable?surface=banner&ad="
+              + encodeURIComponent(AD)
+              + "&event_uuid=" + encodeURIComponent(newEventUuid()));
+            _bannerSentViewable = true;
+          }
+        }
+      } catch (e) {
+        dlog("banner.error", { msg: String(e && e.message || e).slice(0, 160) });
+      }
+    }, 1000);
+
+    // BODY-LEVEL OVERLAY — the ONLY React-safe architecture (proven 3×: any
+    // mutation of CC's spinner subtree — innerHTML, child append, child
+    // restyle — makes CC's next React reconciliation tear spinnerRow out and
+    // never re-render it that turn). So we NEVER touch CC's tree: read its
+    // rect READ-ONLY (getBoundingClientRect is reconciliation-safe) and paint
+    // the ad in OUR OWN element appended to <body>, OUTSIDE React's roots,
+    // absolutely positioned over the spinner. CC's spinner then behaves
+    // natively (stays the whole turn, removed at turn end) so detection is
+    // reliable every tick — the recurring "vanish / never return" is
+    // structurally impossible. Liveness = spinnerRow PRESENT (not its rect:
+    // jsdom has no layout, and presence is the true signal).
+    var overlay = null;
+    // True while CC is actively thinking (spinner ad shown). The usage-banner
+    // ad is gated on the INVERSE of this: shown only at idle, hidden during a
+    // turn (user request — it was visible all the time). It STAYS true while
+    // the overlay is frozen at idle (below), so the persisted spinner ad
+    // remains the sole ad surface and the banner stays suppressed.
+    var _spinnerActive = false;
+    // PERSIST-AT-IDLE → DOCK-TO-COMPOSER (user request): when CC goes idle we
+    // don't drop the overlay AND we don't strand it at its last viewport pixel
+    // (that floated over transcript content on scroll). Instead `_frozen`
+    // marks idle (billing paused, "thinking" animation stopped) and we DOCK the
+    // overlay as a compact line just above CC's input/composer box — a stable,
+    // always-present bottom anchor — so it stays parked and out of the scrolled
+    // transcript. The next active turn thaws (paint re-glues to the verb +
+    // viewShow resumes the same session). If the composer can't be located on a
+    // given CC build, we DROP the idle ad (prime-directive fallback) rather
+    // than strand it. While idle the ad accrues NO view-time.
+    var _frozen = false;       // idle: billing paused, animation stopped
+    var _docked = false;       // idle overlay re-anchored above the composer
+    var _dockNode = null;      // cached composer element (read-only rect target)
+    function ensureOverlay(row) {
+      if (overlay && overlay.parentNode) return overlay;
+      overlay = document.createElement("div");
+      overlay.setAttribute("data-vibe-ads", String(TIER));
+      overlay.setAttribute("data-vibe-ads-overlay", "1");
+      // OPAQUE, theme-matched bg: it must cover CC's verb glyph behind it
+      // (transparent => verb shows through and overlaps the ad). The
+      // earlier "box flashes over the input" concern is solved separately
+      // by visibility:hidden until the FIRST placeOverlay sets real coords
+      // — so opaque is safe AND the flash is gone.
+      overlay.style.cssText =
+        "position:fixed;z-index:2147483646;pointer-events:auto;" +
+        "display:flex;align-items:center;box-sizing:border-box;" +
+        "overflow:hidden;white-space:nowrap;visibility:hidden;background:" +
+        surfaceBg(row);
+      try { (document.body || document.documentElement).appendChild(overlay); }
+      catch (e) { /* body not ready yet — retried next tick */ }
+      return overlay;
+    }
+    var _rect = "";
+    function placeOverlay(row) {
+      try {
+        var r = row.getBoundingClientRect();
+        if (r && (r.width || r.height || r.top || r.left)) {
+          // Only WRITE styles when the rect actually moved — a redundant
+          // style write every 140ms forces needless recalc/compositing and
+          // looked glitchy. (read is unavoidable; the write is what we gate.)
+          var key = r.left + "," + r.top + "," + r.width + "," + r.height;
+          if (key !== _rect) {
+            _rect = key;
+            overlay.style.left = r.left + "px";
+            overlay.style.top = r.top + "px";
+            overlay.style.minWidth = r.width + "px";
+            overlay.style.height = r.height + "px";
+            overlay.style.visibility = "visible";   // reveal once positioned
+          }
+        }
+      } catch (e) { /* no layout (jsdom) — overlay still renders the ad */ }
+    }
+    // Locate CC's input/composer box — the stable, always-present element at
+    // the panel bottom that the idle ad docks above. READ-ONLY (querySelector +
+    // getBoundingClientRect only; the composer is NEVER mutated — prime
+    // directive). This runs in the CC WEBVIEW document, which is isolated from
+    // the VS Code workbench, so a generic editable-textbox selector cannot
+    // match the Monaco editor. CC's composer is a contenteditable textbox
+    // (newer builds use contenteditable="plaintext-only", not "true"); we try
+    // the most specific selector first, then a plain textarea, and among
+    // matches pick the LOWEST visible one (the composer sits at the bottom).
+    // Returns null if nothing matches (CC restructured) — the caller then DROPS
+    // the idle ad rather than stranding it. The matched selector/class is
+    // logged once (composer.found) so the locator can be hardened to a stable
+    // hashed class later, the way the spinnerRow_ locator was.
+    var _composerLogged = false;
+    function findComposer() {
+      try {
+        var sels = ['[contenteditable][role="textbox"]',
+                    'div[contenteditable]', 'textarea'];
+        for (var s = 0; s < sels.length; s++) {
+          var els = document.querySelectorAll(sels[s]);
+          var best = null, bestTop = -1;
+          for (var i = 0; i < els.length; i++) {
+            var el = els[i];
+            if (el.nodeType !== 1) continue;
+            var r = el.getBoundingClientRect();
+            if (!(r.width > 0 && r.height > 0)) continue;   // visible only
+            if (r.top > bestTop) { bestTop = r.top; best = el; }
+          }
+          if (best) {
+            if (!_composerLogged) { _composerLogged = true;
+              dlog("composer.found", { sel: sels[s], tag: best.tagName,
+                cls: String(best.className || "").slice(0, 80) }); }
+            return best;
+          }
+        }
+      } catch (e) { /* no layout / prime directive */ }
+      return null;
+    }
+    // Park the idle overlay as a compact line in the gap just above the
+    // composer's top edge. Same read-only-rect technique as placeOverlay; keeps
+    // the overlay's existing height (the last verb line-height) so it stays a
+    // compact line and spans the composer width (theme-bg blends it in). The
+    // "dock," key prefix keeps it distinct from placeOverlay's key so the first
+    // dock write always lands.
+    function placeDocked(composer) {
+      try {
+        var r = composer.getBoundingClientRect();
+        if (r && (r.width || r.height || r.top || r.left)) {
+          var h = overlay.offsetHeight || 20;
+          var GAP = 4;
+          var top = r.top - h - GAP;
+          if (top < 0) top = 0;                  // clamp if composer near top
+          var key = "dock," + r.left + "," + top + "," + r.width;
+          if (key !== _rect) {
+            _rect = key;
+            overlay.style.left = r.left + "px";
+            overlay.style.top = top + "px";
+            overlay.style.minWidth = r.width + "px";
+            overlay.style.visibility = "visible";
+          }
+        }
+      } catch (e) { /* no layout */ }
+    }
+    function dropOverlay() {
+      // W3: stop the overlay-surface visibility accumulator for the
+      // current ad. Banner accumulator (if running) is independent.
+      try { viewHide(AD, "overlay"); } catch (e) { /* prime directive */ }
+      try { if (overlay && overlay.parentNode)
+        overlay.parentNode.removeChild(overlay); } catch (e) {}
+      overlay = null; _rect = ""; _shown = false;
+      st.wasVisible = false; _spinnerActive = false; _frozen = false;
+      _docked = false; _dockNode = null;
+      st.simStart = 0;
+      _chromeSig = ""; _dotsEl = null; _elapsedEl = null;
+    }
+    // Idle transition (replaces the old freeze-at-pixel). DOCK the overlay as a
+    // compact line above CC's composer and pause billing. We deliberately keep
+    // `overlay`, `lastNode`, `_spinnerActive`, `st.sentRender`, and
+    // `st.wasVisible` set: the next active turn thaws (paint re-glues to the
+    // verb + viewShow resumes the same impression session — no duplicate
+    // impression_rendered/viewable). st.simStart is cleared so the per-turn
+    // elapsed timer restarts at 0 next turn. The "thinking" dots are stopped
+    // (cleared via the cached child's textContent — NEVER innerHTML, which would
+    // detach the click anchor; see the clickable-overlay rule). PRIME-DIRECTIVE
+    // FALLBACK: if the composer can't be located, DROP the ad rather than
+    // strand it over content.
+    function dockOverlay() {
+      if (_frozen) return;
+      var composer = findComposer();
+      if (!composer) {
+        dlog("loop.idle.dock_miss_drop", {});
+        dropOverlay();
+        lastNode = null;
+        return;
+      }
+      _frozen = true;
+      _docked = true;
+      _dockNode = composer;
+      try { viewPause(AD, "overlay"); } catch (e) { /* prime directive */ }
+      st.simStart = 0;
+      try { if (_dotsEl) _dotsEl.textContent = ""; } catch (e) { /* safe */ }
+      placeDocked(composer);   // reposition immediately so it never strands
+      dlog("loop.idle.dock", {});
+    }
+    // Host stopped serving (two consecutive empty /ad payloads — see the
+    // _noServe declaration). Tear down every ad surface via the EXISTING
+    // drop paths and end every view session: the wave-2 host gate already
+    // refuses to BILL a stale overlay, this removes the PIXELS too.
+    function enterNoServe() {
+      if (_noServe) return;                 // idempotent
+      _noServe = true;
+      dlog("ad.no_serve", { emptyPolls: _adEmptyPolls });
+      try { dropOverlay(); } catch (e) { /* best-effort */ }
+      lastNode = null;
+      try { if (_bannerEl) _bannerEl.style.setProperty(
+        "display", "none", "important"); } catch (e) { /* prime directive */ }
+      try { viewHide(AD, "banner"); } catch (e) { /* best-effort */ }
+      // Belt & suspenders: END every remaining session. dropOverlay ended
+      // the overlay's and viewHide the banner's; the wipe covers stragglers
+      // so nothing can emit another view event while no-serve holds.
+      try { _vt = Object.create(null); } catch (e) { /* best-effort */ }
+    }
+    // Audit #27: pollAd adopted a DIFFERENT ad while the overlay is parked
+    // at idle (docked + frozen). paint() only runs in the active branch, so
+    // without this the docked line kept showing the OLD creative — its
+    // clicks opened the old URL but could not attribute (the old session is
+    // gone → visible_ms 0 → host 15s click floor) or misattributed to the
+    // new adId. Retarget the EXISTING stable child nodes ONLY: the ad-text
+    // TEXT NODE inside the anchor (nodeValue write) and the anchor's href
+    // attribute. NEVER innerHTML and never re-create the anchor — rewriting
+    // a live clickable element detaches it mid-click (a shipped bug, fixed
+    // once). The icon is retargeted in place too (a src swap on the EXISTING
+    // img — the img is a SIBLING of the anchor, so the anchor is untouched):
+    // the swap used to be deferred to the next thaw's rebuild, which left the
+    // NEW ad's text docked beside the OLD ad's logo for the whole idle
+    // period. When the icon node shape doesn't match the new creative (img
+    // vs inline 'K' SVG) there is no anchor-safe in-place swap — fall through
+    // to the drop path; a missing overlay beats a crossed icon/ad pair.
+    // Billing: pollAd already wiped
+    // _vt (the old ad's session is ENDED); the docked state stays
+    // NON-billing per persist-at-idle, so NO live session is started here —
+    // the next thaw's viewShow(AD) opens the NEW ad's session, and the
+    // click ping reads the swapped AD module var, so both attribute to the
+    // new ad. If the docked structure can't be retargeted safely
+    // (unexpected children), fall back to the existing drop path so a stale
+    // creative is never shown.
+    function retargetDockedOverlay() {
+      try {
+        var a = overlay && overlay.querySelector('a[data-vibe-ads-ad]');
+        var tn = a && a.firstChild;
+        if (!a || !tn || tn.nodeType !== 3) {
+          dlog("ad.dock_retarget_drop", {});
+          dropOverlay();
+          lastNode = null;
+          return;
+        }
+        // Icon shape check BEFORE any mutation so a drop never leaves a
+        // half-retargeted overlay. Tier <3 renders no icon at all — skip.
+        var icon = (TIER >= 3)
+          ? overlay.querySelector('img[data-va-icon="1"]') : null;
+        if (TIER >= 3 && (ICON_URL ? !icon : !!icon)) {
+          // New creative needs an img but the docked one has the 'K' SVG
+          // (or vice versa): no anchor-safe in-place swap exists.
+          dlog("ad.dock_retarget_drop", { why: "icon_shape" });
+          dropOverlay();
+          lastNode = null;
+          return;
+        }
+        tn.nodeValue = AD;          // text node only — anchor never detached
+        a.setAttribute("href", CLICKURL ? CLICKURL : "#");
+        if (icon && ICON_URL) icon.setAttribute("src", ICON_URL);
+        dlog("ad.dock_retarget", { toId: AD_ID });
+      } catch (e) {
+        // Never show a stale creative: drop on any error (prime directive —
+        // dropOverlay only touches OUR element).
+        try { dropOverlay(); lastNode = null; } catch (e2) { /* no-op */ }
+      }
+    }
+    function paint(row, anim) {
+      var now = Date.now();
+      _verbSeq++;
+      var gap = _lastVerbMs ? (now - _lastVerbMs) : 0;
+      var reused = (row === _lastVerbEl);
+      if (_verbSeq <= 12 || !reused || gap > 1200) {
+        dlog("verb.dom", { seq: _verbSeq, reused: reused, gapMs: gap,
+          tag: row.tagName, cls: String(row.className || "").slice(0, 80),
+          kids: row.children.length,
+          pCls: row.parentElement
+            ? String(row.parentElement.className || "").slice(0, 80) : null,
+          txt: (row.textContent || "").trim().slice(0, 40) });
+      }
+      _lastVerbEl = row; _lastVerbMs = now;
+      lastNode = row; lastSeenMs = now;
+      if (!_foundLogged) { _foundLogged = true;
+        dlog("spinner.found", { cls: String(row.className || "").slice(0, 80) }); }
+      if (anim) st.frame++;
+      if (!st.simStart) st.simStart = now;        // elapsed = since turn start
+      if (!st.sentRender) {
+        ping("impression_rendered?surface=overlay&ad=" + encodeURIComponent(AD)
+          + "&event_uuid=" + encodeURIComponent(newEventUuid()));
+        st.sentRender = true;
+      }
+      var vis = (typeof document.hidden === "undefined")
+        ? true : !document.hidden;
+      if (vis && !st.wasVisible) {
+        ping("impression_viewable?surface=overlay&ad=" + encodeURIComponent(AD)
+          + "&event_uuid=" + encodeURIComponent(newEventUuid()));
+      }
+      st.wasVisible = vis;
+      if (!_shown) { _shown = true; dlog("loop.adopt", { active: true }); }
+      var o = ensureOverlay(row);
+      _spinnerActive = true;
+      // W3: this overlay-ad-session is now visible. The accumulator will
+      // start counting visible time toward the threshold. AD is the ad
+      // text rather than an ad_id — the block doesn't receive an id (the
+      // backend correlates by client_id + ts + corr) so we pass the AD
+      // string as the session key; same ad text in the same session
+      // continues accumulating. Safe no-op when AD is empty.
+      try { viewShow(AD, "overlay"); } catch (e) { /* prime directive */ }
+      placeOverlay(row);
+      var dots = ellipsis(st.frame);
+      var elapsed = fmtElapsed(now - st.simStart);
+      // AD_ID and ICON_URL are in the signature so a rotation between two
+      // creatives with identical text+clickUrl, or a same-ad icon update,
+      // still triggers the structural rebuild (the icon img is only written
+      // on this path).
+      var sig = TIER + "|" + AD_ID + "|" + AD + "|" + CLICKURL + "|" + ICON_URL;
+      if (sig !== _chromeSig) {
+        o.innerHTML = buildAdHtml(TIER, { ad: AD,
+          href: CLICKURL, elapsed: elapsed, dots: dots });
+        _chromeSig = sig;
+        _dotsEl = o.querySelector('[data-va-dots]');
+        _elapsedEl = o.querySelector('[data-va-elapsed]');
+      } else {
+        if (_dotsEl) _dotsEl.textContent = dots;
+        if (_elapsedEl) {
+          _elapsedEl.textContent = elapsed;
+        }
+      }
+    }
+
+    // POSITION on every animation frame so the overlay stays glued to the
+    // spinner with zero perceptible lag — including while the user scrolls
+    // or CC streams text and the row moves (the 140ms-only cadence left the
+    // ad behind during scroll). This is ONE getBoundingClientRect on ONE
+    // cached node per frame with the style WRITE gated to only-on-move — no
+    // layout thrash (unlike the removed unthrottled capture scroll handler,
+    // which fired many times per frame). rAF self-pauses when the tab is
+    // hidden. Content/animation + detection/idle stay on the slower loop.
+    function frame() {
+      try {
+        // Active: glue to the spinner verb. Idle+docked: keep parked above the
+        // composer (cheap getBoundingClientRect on the cached _dockNode — never
+        // a full-document query here; re-acquisition lives in evaluate()). If
+        // _dockNode disconnected, hold position this frame; evaluate() re-finds.
+        if (overlay && !_frozen && lastNode && lastNode.isConnected) {
+          placeOverlay(lastNode);
+        } else if (overlay && _frozen && _docked
+                   && _dockNode && _dockNode.isConnected) {
+          placeDocked(_dockNode);
+        }
+      } catch (e) { /* prime directive */ }
+      try { window.requestAnimationFrame(frame); }
+      catch (e) { setTimeout(frame, 16); }
+    }
+    try { window.requestAnimationFrame(frame); }
+    catch (e) { setTimeout(frame, 16); }
+
+    setInterval(pollActivity, 1000);
+
+    // Live ad refresh: poll the loopback every 60s for the current ad from
+    // the extension (which polls /v1/portfolio). When the ad changes, swap
+    // the module-scope AD/CLICKURL/ICON_URL so the next paint() and banner
+    // tick render the fresh creative without a VS Code reload.
+    function pollAd() {
+      try {
+        fetch(BASE + "/ad").then(function (r) { return r.json(); })
+          .then(function (j) {
+            if (!j || !j.adText) {
+              // SUCCESSFUL response, no ad = the host's no-serve signal
+              // (kill / disable / serving-gate refusal). Debounced to two
+              // consecutive reads — see the _noServe declaration. A fetch
+              // ERROR never reaches here (catch below keeps the last ad).
+              _adEmptyPolls++;
+              if (_adEmptyPolls >= 2) enterNoServe();
+              return;
+            }
+            _adEmptyPolls = 0;
+            if (_noServe) {
+              // Host resumed serving (possibly the SAME creative). Re-arm:
+              // the dropped overlay re-mounts on the next active paint()
+              // and the banner repaints on its next 1s tick.
+              _noServe = false;
+              _bannerLast = "";
+              dlog("ad.serve_resume", { adId: j.adId });
+            }
+            // Icon is part of the change check: a same-ad icon update (an
+            // advertiser swaps their creative icon, or the backend's first
+            // serve carried the raw https URL and a later one the inlined
+            // data: URI) must be adopted too, or the stale icon sits next to
+            // this ad for the life of the webview.
+            var changed = (j.adId && j.adId !== AD_ID) || (j.adText !== AD)
+              || (j.clickUrl !== CLICKURL) || ((j.iconUrl || "") !== ICON_URL);
+            if (!changed) return;
+            dlog("ad.rotated", { fromId: AD_ID, toId: j.adId, from: AD, to: j.adText });
+            AD_ID = j.adId || AD_ID;
+            AD = j.adText;
+            CLICKURL = j.clickUrl || "";
+            ICON_URL = j.iconUrl || "";
+            // Rebuild favicon for new icon
+            FAVICON = ICON_URL ? faviconImg(ICON_URL) : FAVICON_FALLBACK;
+            // Reset view-time sessions: old ad's accumulated time must not
+            // carry over to the new ad's billing.
+            _vt = Object.create(null);
+            st.sentRender = false;
+            st.wasVisible = false;
+            _bannerSentRender = false;
+            _bannerSentViewable = false;
+            // Force overlay re-render by clearing the chrome signature
+            _chromeSig = "";
+            _dotsEl = null;
+            _elapsedEl = null;
+            // Force banner re-render
+            _bannerLast = "";
+            // Audit #27: the docked idle overlay is not repainted by paint()
+            // (it only runs in the active branch) — retarget its stable
+            // child nodes in place so a stale creative never sits on screen.
+            if (overlay && _frozen) retargetDockedOverlay();
+          }).catch(function () {
+            // Fetch ERROR (loopback unreachable / transient network): keep
+            // the last ad — deliberately NOT the no-serve signal.
+          });
+      } catch (e) { /* ignore */ }
+    }
+    setInterval(pollAd, 10000);
+    // Also poll once shortly after start to catch any rotation that
+    // happened between patch-time and now.
+    setTimeout(pollAd, 5000);
+
+    // --- Defense-in-depth state evaluation -----------------------------
+    // ONE shared evaluator, driven by FOUR independent signals so a miss in
+    // any single one can't strand the overlay. Combine is biased toward
+    // HIDING (a missed show is mild; a stuck overlay over CC's UI is the
+    // failure that's burned us repeatedly).
+    //   • DOM glyph  (findSpinner + rowActive)  — primary "show"
+    //   • Transcript (realAct.done / staleness)  — DOM-independent force-HIDE
+    //   • MutationObserver                       — instant edge, poll backup
+    //   • visibilitychange + watchdog            — recover stale/wedged state
+    var TXN_STALE_MS = 12000;     // no transcript activity this long => idle
+    var _evaluating = false;
+    function evaluate() {
+      if (_evaluating) return;            // re-entrancy guard (observer+timer)
+      _evaluating = true;
+      try {
+        // No-serve (host /ad gate — see pollAd/enterNoServe): never paint
+        // or keep an overlay while the host has stopped serving, even with
+        // a live spinner. Biased toward HIDE, matching this evaluator's
+        // design. pollAd re-arms on the next served payload.
+        if (_noServe) {
+          if (overlay) { dropOverlay(); lastNode = null; }
+          return;
+        }
+        var now = Date.now();
+        var row = findSpinner();          // READ-ONLY, class-scoped
+        // Content-freshness check (the missing piece that previously made
+        // row 03 'after-prompt-idle' fail intermittently). CC leaves the
+        // spinnerRow mounted at turn end with the LAST glyph still present
+        // (e.g. ✻ frozen). rowActive() alone would keep reporting "active"
+        // forever and paint() would keep animating the ad's dots. CC's
+        // textContent may STILL be changing at idle (an elapsed-time/
+        // timestamp child re-renders) so a whole-string signature is not
+        // reliable. The truthful signal is the ANIMATED GLYPH: while
+        // thinking CC cycles through {✢ U+2722, ✶ U+2736, ✻ U+273B,
+        // ✽ U+273D}; at idle it freezes on one. We track the first non-
+        // whitespace code point and only count the row as ACTIVE if that
+        // code point CHANGED within GRACE_MS.
+        if (row) {
+          var t = (row.textContent || "").replace(/^[\s ]+/, "");
+          var cc = t.charCodeAt(0) | 0;
+          if (cc !== lastSig) { lastSig = cc; lastSigMs = now; }
+        } else {
+          lastSig = null;
+        }
+        var glyphLed = !!(row && rowActive(row));
+        var fresh = glyphLed && lastSigMs > 0
+          && (now - lastSigMs) <= GRACE_MS;
+        var domActive = glyphLed && fresh;
+        // Transcript second opinion (only when we actually have data — never
+        // force-hide on missing/empty activity; DOM-only in that case).
+        var txnIdle = !!(realAct && (realAct.done === true ||
+          (typeof realAct.ts === "number" && realAct.ts > 0 &&
+           (now - realAct.ts) > TXN_STALE_MS)));
+        if (domActive && !txnIdle) {
+          if (_frozen) {
+            _frozen = false; _docked = false; _dockNode = null;
+            dlog("loop.thaw", {});
+          }
+          paint(row, true);
+        } else if (overlay && !_frozen && ((now - lastSeenMs) > GRACE_MS
+            || txnIdle || (glyphLed && !fresh))) {
+          // Turn ended (DOM idle past GRACE, OR the transcript says done, OR a
+          // glyph-led row froze stale > GRACE_MS). DOCK the overlay above the
+          // composer and pause billing — no longer freeze-at-pixel (which
+          // floated over scrolled transcript content). The next active turn
+          // thaws it. If the composer can't be found, dockOverlay() DROPS the
+          // ad (prime-directive fallback).
+          dlog("loop.idle.dock_enter",
+            { sinceSeenMs: now - lastSeenMs, txnIdle: txnIdle,
+              staleFrozen: glyphLed && !fresh });
+          dockOverlay();
+        } else if (overlay && _frozen && _docked
+            && (!_dockNode || !_dockNode.isConnected)) {
+          // Still idle but CC re-rendered/removed the composer element:
+          // re-acquire it (the 80ms cadence absorbs the one querySelectorAll),
+          // or DROP if it's truly gone — never strand the ad over content.
+          var c2 = findComposer();
+          if (c2) { _dockNode = c2; placeDocked(c2); }
+          else {
+            dlog("loop.idle.dock_lost_drop", {});
+            dropOverlay(); lastNode = null;
+          }
+        }
+      } catch (e) {
+        dlog("loop.error", { msg: String(e && e.message || e).slice(0, 200) });
+        /* prime directive: never disturb Claude Code */
+      } finally { _evaluating = false; }
+    }
+
+    setInterval(evaluate, 80);  // primary cadence; rAF handles positioning
+
+    // Signal 3 REMOVED — root cause of the CC crash. A whole-document
+    // { childList:true, subtree:true } observer firing evaluate() (=
+    // full-document querySelectorAll + getBoundingClientRect) on EVERY DOM
+    // mutation meant that while CC streams a response (transcript mutates
+    // per token) it ran hundreds of times/sec → main-thread saturation →
+    // the webview goes unresponsive and VS Code terminates it. Intermittent
+    // & load-dependent, so a quick test of 0.3.25 looked fine. The 80ms
+    // interval detects within 80ms; the remaining bounded signals
+    // (interval + rAF + transcript-idle + watchdog + visibilitychange)
+    // preserve reliability with NO unbounded cost. Do not reintroduce a
+    // document-wide subtree observer.
+
+    // Signal 4a: rAF pauses while the tab is hidden, so a turn that ended in
+    // the background could leave a stale overlay. Re-evaluate on re-show.
+    try {
+      document.addEventListener("visibilitychange", function () {
+        if (!document.hidden) evaluate();
+      }, false);
+      window.addEventListener("focus", function () { evaluate(); }, false);
+    } catch (e) { /* no document/window — fine */ }
+
+    // Signal 4b: independent watchdog. lastSeenMs refreshes every evaluate()
+    // while genuinely active (even across multi-minute turns), so this only
+    // fires if the main loop itself wedged (exception/throttle) yet the
+    // overlay is still up — a true backstop against "stuck forever". Its own
+    // timer so a broken evaluate() can't disable it.
+    var WATCHDOG_MS = 15000;
+    setInterval(function () {
+      try {
+        // `!_frozen`: a FROZEN overlay is stale-by-design (it persists at
+        // idle), so the watchdog must ignore it — otherwise it would tear
+        // down the persisted ad after 15s, defeating PERSIST-AT-IDLE. This
+        // still catches a genuinely wedged ACTIVE overlay (loop threw/
+        // throttled while a turn was live), which is the backstop's purpose.
+        if (overlay && !_frozen && (Date.now() - lastSeenMs) > WATCHDOG_MS) {
+          dlog("loop.watchdog", { sinceSeenMs: Date.now() - lastSeenMs });
+          dropOverlay();
+          lastNode = null;
+        }
+      } catch (e) { /* prime directive */ }
+    }, 5000);
+  } catch (e) { /* no-op */ }
+})();
+/* VIBE-ADS-END */
diff --git a/src/adapters/codex-cli/adapter.ts b/src/adapters/codex-cli/adapter.ts
new file mode 100644
index 0000000..678e0ac
--- /dev/null
+++ b/src/adapters/codex-cli/adapter.ts
@@ -0,0 +1,160 @@
+import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync,
+         copyFileSync, chmodSync } from "node:fs";
+import { resolve, dirname, join, basename } from "node:path";
+import type { TargetAdapter, PreflightResult, OpResult, RestoreResult,
+              PatchParams } from "../types";
+import { sha256 } from "../../util/crypto";
+import { resolveAsset } from "../../util/asset";
+
+const MARKER = "VIBE-ADS-CODEX-CLI";
+const AD_FILE_NAME = "codex-cli-ad.txt";
+
+/** Terminal esc()-analog: strip control chars (C0 + DEL + C1) — and ONLY
+ *  those — before the wrappers printf/echo the ad text raw to a terminal.
+ *  Emoji / pipes / unicode / URLs pass through untouched (permissive by
+ *  design — see backend validate_creative_fields, same char class). */
+function stripControlChars(s: string): string {
+  return s.replace(/[\x00-\x1f\x7f-\x9f]/g, "");
+}
+
+/** Resolve the wrapper asset in BOTH unbundled (co-located src) and
+ *  esbuild-bundled (dist/adapters/codex-cli/) layouts — mirrors the
+ *  CLI status-line adapter's resolveStatuslineAsset contract. */
+export function resolveWrapperAsset(baseDir: string, isWin: boolean): string {
+  const name = isWin ? "wrapper.cmd.asset" : "wrapper.sh.asset";
+  return resolveAsset(baseDir, "adapters/codex-cli", name);
+}
+
+/** Wraps the npm-installed `codex` CLI shim with a small script that prints
+ *  a one-line ad banner above the real codex invocation. Reversible: the
+ *  pristine shim is copied to `<stem>.vibe-ads-orig<.cmd>` alongside it
+ *  (kept next to the shim so the wrapper's `call` can target it on Windows,
+ *  which requires a .cmd extension).
+ *
+ *  Codex CLI has no documented hook surface (per row 05 of
+ *  test-stack/codexcli-test-e2e/), so a PATH wrapper is the only injection
+ *  path that doesn't require an open-source binary rewrite. The banner is
+ *  a startup print, NOT a live spinner verb — replacing the TUI's "Working"
+ *  status row would require a context-anchored binary patch which is
+ *  intentionally out of scope here. */
+export class CodexCliWrapperAdapter implements TargetAdapter {
+  readonly name = "codex-cli-wrapper";
+  private readonly shim: string;
+  private readonly home: string;
+  private readonly isWin: boolean;
+
+  /** @param shimPath absolute path to the npm-generated codex shim
+   *      (codex.cmd on Windows; the extensionless `codex` JS shebang on POSIX).
+   *  @param home directory hosting `~/.vibe-ads/` (typically os.homedir()). */
+  constructor(shimPath: string, home: string) {
+    this.shim = resolve(shimPath);
+    this.home = resolve(home);
+    this.isWin = this.shim.toLowerCase().endsWith(".cmd");
+  }
+
+  private vibeDir(): string { return join(this.home, ".vibe-ads"); }
+  private adFilePath(): string { return join(this.vibeDir(), AD_FILE_NAME); }
+  /** Backup lives ALONGSIDE the shim, not under ~/.vibe-ads, because cmd.exe's
+   *  `call` resolves relative to the wrapper and only honours .cmd/.bat
+   *  extensions on Windows. Keep round-trip simple by mirroring the shim's
+   *  basename + extension. */
+  private backupPath(): string {
+    const dir = dirname(this.shim);
+    if (this.isWin) {
+      const stem = basename(this.shim, ".cmd");
+      return join(dir, stem + ".vibe-ads-orig.cmd");
+    }
+    return join(dir, basename(this.shim) + ".vibe-ads-orig");
+  }
+
+  version(): string | null { return "cli"; }
+
+  isPatched(): boolean {
+    try {
+      return existsSync(this.shim)
+        && readFileSync(this.shim, "utf8").includes(MARKER);
+    } catch { return false; }
+  }
+
+  preflight(): PreflightResult {
+    try {
+      if (!existsSync(this.shim))
+        return { ok: true, compatible: false, version: "cli",
+                 reason: "shim not found" };
+      const raw = readFileSync(this.shim, "utf8");
+      if (raw.includes(MARKER))
+        return { ok: true, compatible: true, version: "cli" };
+      // Sanity-check: only wrap things that LOOK like an npm-generated
+      // codex shim — both the Windows .cmd and the POSIX JS-shebang version
+      // reference `@openai/codex` (or its Windows-path form). A substring
+      // match on bare "codex" would false-positive on any unrelated
+      // codex.cmd containing the word ("not-codex", "claude-codex", etc.).
+      if (!/@openai[\/\\]codex|codex\.js/.test(raw))
+        return { ok: true, compatible: false, version: "cli",
+                 reason: "shim doesn't look like @openai/codex" };
+      return { ok: true, compatible: true, version: "cli" };
+    } catch (e) {
+      return { ok: false, compatible: false, version: null, reason: String(e) };
+    }
+  }
+
+  private renderWrapper(): string {
+    const asset = resolveWrapperAsset(dirname(__filename), this.isWin);
+    return readFileSync(asset, "utf8")
+      .split("__VIBE_ADS_AD_PATH__").join(this.adFilePath())
+      .split("__VIBE_ADS_BACKUP__").join(this.backupPath());
+  }
+
+  applyPatch(p: PatchParams): OpResult {
+    try {
+      if (!existsSync(this.shim))
+        return { ok: false, reason: "shim not found" };
+      // Always (re)write the ad-text file. Cheap, decoupled from the wrapper
+      // so an ad change does NOT require a wrapper rewrite — the wrapper
+      // re-reads this file on every codex invocation.
+      mkdirSync(this.vibeDir(), { recursive: true });
+      // Control chars are stripped here (the shell/batch wrappers print the
+      // file raw and cannot sanitize); an adText that strips to empty falls
+      // back to the default line rather than printing a blank banner.
+      writeFileSync(this.adFilePath(),
+        (stripControlChars(p.adText || "") || "Earning Kickback") + "\n",
+        "utf8");
+      // Idempotent: if the shim already carries our marker, no wrapper rewrite.
+      const current = readFileSync(this.shim, "utf8");
+      if (current.includes(MARKER)) return { ok: true };
+      // First-time install: snapshot the pristine npm shim. The check guards
+      // against ever overwriting an existing backup with our wrapper, which
+      // would happen if a stale wrapper file somehow lost its MARKER.
+      if (!existsSync(this.backupPath()))
+        copyFileSync(this.shim, this.backupPath());
+      const wrapper = this.renderWrapper();
+      writeFileSync(this.shim, wrapper, "utf8");
+      if (!this.isWin) {
+        try { chmodSync(this.shim, 0o755); } catch { /* best-effort */ }
+      }
+      return { ok: true };
+    } catch (e) {
+      return { ok: false, reason: String(e) };
+    }
+  }
+
+  restore(): RestoreResult {
+    try {
+      const bak = this.backupPath();
+      if (!existsSync(bak))
+        return { ok: true, restored: false, reason: "no backup present" };
+      const pristine = readFileSync(bak);
+      writeFileSync(this.shim, pristine);
+      if (sha256(readFileSync(this.shim)) !== sha256(pristine))
+        return { ok: false, restored: false,
+                 reason: "sha256 mismatch after restore" };
+      rmSync(bak);
+      if (existsSync(this.adFilePath())) {
+        try { rmSync(this.adFilePath()); } catch { /* best-effort */ }
+      }
+      return { ok: true, restored: true };
+    } catch (e) {
+      return { ok: false, restored: false, reason: String(e) };
+    }
+  }
+}
diff --git a/src/adapters/codex-cli/wrapper.cmd.asset b/src/adapters/codex-cli/wrapper.cmd.asset
new file mode 100644
index 0000000..00e8ecf
--- /dev/null
+++ b/src/adapters/codex-cli/wrapper.cmd.asset
@@ -0,0 +1,20 @@
+@ECHO off
+REM ===== VIBE-ADS-CODEX-CLI =====
+REM Reversible Kickbacks wrapper around the npm-installed codex shim.
+REM Prints a one-line ad banner read from "__VIBE_ADS_AD_PATH__" then
+REM delegates to the pristine shim at "__VIBE_ADS_BACKUP__".
+REM Restore: copy that backup back over this file (or run kickbacks restore).
+setlocal enabledelayedexpansion
+set "__KB_AD_FILE=__VIBE_ADS_AD_PATH__"
+set "__KB_AD=Earning Kickback"
+if exist "%__KB_AD_FILE%" (
+  for /f "usebackq delims=" %%A in ("%__KB_AD_FILE%") do (
+    set "__KB_AD=%%A"
+    goto :__kb_after
+  )
+)
+:__kb_after
+echo.
+echo   [ad]  !__KB_AD!
+echo.
+endlocal & call "__VIBE_ADS_BACKUP__" %*
diff --git a/src/adapters/codex-cli/wrapper.sh.asset b/src/adapters/codex-cli/wrapper.sh.asset
new file mode 100644
index 0000000..a813f61
--- /dev/null
+++ b/src/adapters/codex-cli/wrapper.sh.asset
@@ -0,0 +1,13 @@
+#!/bin/sh
+# ===== VIBE-ADS-CODEX-CLI =====
+# Reversible Kickbacks wrapper around the npm-installed codex shim.
+# Prints a one-line ad banner read from "__VIBE_ADS_AD_PATH__" then
+# exec's the pristine shim at "__VIBE_ADS_BACKUP__".
+# Restore: copy that backup back over this file (or run kickbacks restore).
+AD_FILE="__VIBE_ADS_AD_PATH__"
+AD_TEXT="Earning Kickback"
+if [ -r "$AD_FILE" ]; then
+  AD_TEXT=$(head -n 1 "$AD_FILE" 2>/dev/null || echo "Earning Kickback")
+fi
+printf '\n  [ad]  %s\n\n' "$AD_TEXT"
+exec "__VIBE_ADS_BACKUP__" "$@"
diff --git a/src/adapters/codex/adapter.ts b/src/adapters/codex/adapter.ts
new file mode 100644
index 0000000..c1048cd
--- /dev/null
+++ b/src/adapters/codex/adapter.ts
@@ -0,0 +1,297 @@
+import { readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
+import { resolve, dirname, join } from "node:path";
+import type { TargetAdapter, PreflightResult, OpResult, RestoreResult, PatchParams }
+  from "../types";
+import { sha256 } from "../../util/crypto";
+import { resolveAsset } from "../../util/asset";
+
+// Strip a previously-injected block (current OR legacy markers) so re-apply
+// never stacks and preflight evaluates the pristine shape — same reversibility
+// contract as the Claude Code adapter.
+const BLOCK_START = "/* VIBE-ADS-START */";
+const BLOCK_END = "/* VIBE-ADS-END */";
+// Codex injection is an INLINE wrapper `arg=(<block>)||arg;`, unlike the
+// Claude Code adapter's appended standalone block. Stripping must remove the
+// WHOLE wrapper, not just the comment-delimited body — else a re-derive from
+// an already-patched file (no backup) leaves an empty `arg=()||arg;`, a hard
+// syntax error that ACCUMULATES on every reassert (the "finnicky / stopped
+// working" root cause). Three forms are stripped, in order:
+//   1. current:  /* START */arg=(...)||arg;/* END */   (markers OUTSIDE)
+//   2. legacy:   arg=(/* START */...../* END */)||arg;  (markers inside)
+//   3. residue:  arg=()||arg;  (legacy form after a markers-only strip)
+const ID = "[A-Za-z_$][\\w$]*";
+const STRIP_RES: RegExp[] = [
+  // 1 + 2: optional `ident=(` prefix and `)||ident;` suffix around the markers
+  new RegExp(
+    "(?:" + ID + "=\\()?" +
+    "\\/\\* VIB(?:E-)?ADS-START \\*\\/[\\s\\S]*?\\/\\* VIB(?:E-)?ADS-END \\*\\/" +
+    "(?:\\)\\|\\|" + ID + ";)?", "g"),
+  // 3: empty-wrapper residue. `=()||ident;` cannot occur in valid minified JS
+  // (`()` is not a valid expression), so matching it is safe and targeted.
+  new RegExp(ID + "=\\(\\)\\|\\|" + ID + ";", "g"),
+];
+/** Remove every historical Vibe-Ads injection form so a re-derived pristine is
+ *  byte-true to the original chunk (self-heals accumulated corruption). */
+function stripInjection(s: string): string {
+  for (const re of STRIP_RES) s = s.replace(re, "");
+  return s;
+}
+// Whitespace-tolerant: the real chunk is minified (`export{v as n,...}`) but
+// tolerating optional spaces is strictly safer against minifier variation and
+// still matches the minified form.
+const EXPORT_RE = /export\s*\{([^}]*)\}/;              // the chunk's export map
+const JSX_RE = /\(0,\s*([A-Za-z0-9_$]+)\.jsxs?\)/;     // (0,d.jsx) | (0,d.jsxs)
+const CSP_CONNECT_RE = /`connect-src\s+([^`]*)`/g;
+const CSP_MARK = "connect-src http://127.0.0.1:*";
+const CSP_INSERT = "http://127.0.0.1:* http://localhost:*";
+
+/** Resolve the shipped Codex block asset relative to `baseDir` (= dirname of
+ *  the running adapter file). Unbundled vitest: co-located. esbuild-bundled
+ *  VSIX: dist/adapters/codex/. Try both; first existing wins — mirrors the
+ *  Claude Code adapter's resolveBlockAsset contract (the S3 Wave-1 CRIT). */
+export function resolveCodexBlockAsset(baseDir: string): string {
+  return resolveAsset(baseDir, "adapters/codex", "block.asset.js");
+}
+
+export class CodexAdapter implements TargetAdapter {
+  readonly name = "codex";
+  private readonly target: string;
+  constructor(target: string) { this.target = resolve(target); }
+
+  private backupPath(): string { return this.target + ".vibe-ads-backup"; }
+  // Pre-rename installs wrote ".vibads-backup"; prefer any existing backup so
+  // we never overwrite the real pristine nor falsely report "no backup".
+  private legacyBackupPath(): string { return this.target + ".vibads-backup"; }
+  private existingBackupPath(): string | null {
+    if (existsSync(this.backupPath())) return this.backupPath();
+    if (existsSync(this.legacyBackupPath())) return this.legacyBackupPath();
+    return null;
+  }
+
+  private extensionRoot(): string {
+    // target = <ext>/webview/assets/thinking-shimmer-<hash>.js
+    return dirname(dirname(dirname(this.target)));
+  }
+  private extTarget(): string | null {
+    const root = this.extensionRoot();
+    const candidates = [
+      join(root, "out", "extension.js"),
+      join(root, "extension.js"),
+    ];
+    for (const p of candidates) {
+      if (existsSync(p)) return p;
+    }
+    return null;
+  }
+  private extBackupPath(ext: string): string {
+    return ext + ".vibe-ads-backup";
+  }
+
+  /** Codex's overlay can render while CSP blocks every loopback fetch. Add
+   *  loopback hosts to Codex's existing connect-src template so impression,
+   *  view_tick, click, and debug-log pings can leave the webview. */
+  private patchCspWithReason():
+    { ok: boolean; reason?: "no-sibling" | "already" | "anchor-missing" | "io-err" } {
+    try {
+      const ext = this.extTarget();
+      if (!ext) return { ok: false, reason: "no-sibling" };
+      const src = readFileSync(ext, "utf8");
+      if (src.includes(CSP_MARK)) return { ok: true, reason: "already" };
+      let changed = false;
+      const patched = src.replace(CSP_CONNECT_RE, (_m, rest: string) => {
+        changed = true;
+        return "`connect-src " + CSP_INSERT + " " + rest.trim() + "`";
+      });
+      if (!changed) return { ok: false, reason: "anchor-missing" };
+      const bak = this.extBackupPath(ext);
+      if (!existsSync(bak))
+        writeFileSync(bak, Buffer.from(src, "utf8"));
+      writeFileSync(ext, Buffer.from(patched, "utf8"));
+      return { ok: true };
+    } catch {
+      return { ok: false, reason: "io-err" };
+    }
+  }
+
+  /** Prime the loopback connect-src CSP relaxation WITHOUT injecting a block.
+   *  See TargetAdapter.prime. Idempotent + reversible (same keepCsp contract
+   *  as applyPatch's CSP layer); a later applyPatch is a no-op on the CSP. */
+  prime(): OpResult {
+    const r = this.patchCspWithReason();
+    return { ok: r.ok, reason: r.reason };
+  }
+
+  private restoreCsp(): void {
+    try {
+      const ext = this.extTarget();
+      if (!ext) return;
+      const bak = this.extBackupPath(ext);
+      if (!existsSync(bak)) return;
+      const pristine = readFileSync(bak);
+      writeFileSync(ext, pristine);
+      if (sha256(readFileSync(ext)) === sha256(pristine))
+        rmSync(bak);
+    } catch { /* best-effort */ }
+  }
+
+  /** The ThinkingShimmer entry: the identifier re-exported `as n`, plus its
+   *  `function NAME(ARG){` site (insertion point is just past the `{`). */
+  private locateEntry(src: string):
+    { name: string; arg: string; at: number } | null {
+    const ex = EXPORT_RE.exec(src);
+    if (!ex) return null;
+    const m = /([A-Za-z0-9_$]+)\s+as\s+n\b/.exec(ex[1]);
+    if (!m) return null;
+    const sig = new RegExp(
+      "function\\s+" + m[1] + "\\s*\\(\\s*([A-Za-z0-9_$]+)\\s*\\)\\s*\\{"
+    ).exec(src);
+    if (!sig) return null;
+    return { name: m[1], arg: sig[1], at: sig.index + sig[0].length };
+  }
+  private jsxName(src: string): string | null {
+    const m = JSX_RE.exec(src);
+    return m ? m[1] : null;
+  }
+
+  version(): string | null {
+    // .../openai.chatgpt-<ver>/webview/assets/thinking-shimmer-<hash>.js
+    const m = /openai\.chatgpt-([0-9][^/\\]*)/.exec(this.target);
+    return m ? m[1] : "unknown";
+  }
+
+  /** True iff the live target currently carries our injected block. */
+  isPatched(): boolean {
+    try {
+      return existsSync(this.target) &&
+        readFileSync(this.target, "utf8").includes(BLOCK_START);
+    } catch {
+      return false;
+    }
+  }
+
+  preflight(): PreflightResult {
+    try {
+      if (!existsSync(this.target))
+        return { ok: true, compatible: false, version: null,
+                 reason: "target not found" };
+      // Evaluate against the pristine shape: a co-located backup means WE
+      // patched this exact file, and our block strip-by-marker keeps the
+      // anchor scan honest even on an already-patched live file.
+      const bak = this.existingBackupPath();
+      const raw = readFileSync(bak ?? this.target, "utf8");
+      const src = stripInjection(raw);
+      const ok = this.locateEntry(src) !== null
+        && /defaultMessage:`Thinking`/.test(src)
+        && this.jsxName(src) !== null;
+      return ok
+        ? { ok: true, compatible: true, version: this.version() }
+        : { ok: true, compatible: false, version: this.version(),
+            reason: "thinking-shimmer anchors not found (incompatible build)" };
+    } catch (e) {
+      return { ok: false, compatible: false, version: null, reason: String(e) };
+    }
+  }
+
+  private renderBlock(p: PatchParams, arg: string, jsx: string): string {
+    const assetPath = resolveCodexBlockAsset(dirname(__filename));
+    let src = readFileSync(assetPath, "utf8").trim();
+    const subs: Record<string, string> = {
+      __VIBE_ADS_ARG__: arg,                       // bare identifier (e)
+      __VIBE_ADS_JSX__: jsx,                       // bare identifier (d)
+      __VIBE_ADS_AD__: JSON.stringify(p.adText),
+      __VIBE_ADS_PORT__: String(p.loopbackPort),
+      __VIBE_ADS_LBTOKEN__: JSON.stringify(p.loopbackToken),
+      __VIBE_ADS_BASE__: JSON.stringify(p.loopbackBase ?? ""),
+      __VIBE_ADS_CLICKTOKEN__: JSON.stringify(p.clickToken),
+      __VIBE_ADS_CLICKURL__: JSON.stringify(p.clickUrl),
+      __VIBE_ADS_CORR__: JSON.stringify(p.corr),
+      __VIBE_ADS_DEBUG__: p.debug ? "true" : "false",
+      __VIBE_ADS_VIEW_THRESHOLD_MS__: String(
+        typeof p.viewThresholdMs === "number" && p.viewThresholdMs > 0
+          ? p.viewThresholdMs : 15000),
+    };
+    for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+    return src;
+  }
+
+  applyPatch(p: PatchParams): OpResult {
+    try {
+      if (!existsSync(this.target))
+        return { ok: false, reason: "target not found" };
+      // Validate anchors on the pristine shape BEFORE taking a backup, so an
+      // incompatible build never leaves a stray .vibe-ads-backup behind.
+      const live = readFileSync(this.target, "utf8");
+      const bak = this.existingBackupPath();
+      // Re-derive the TRUE pristine: strip every historical injection form
+      // (current/legacy/residue) from the backup if present, else the live
+      // file. stripInjection is byte-true to the original chunk, so this
+      // self-heals a file corrupted by the old marker-only strip.
+      const pristine = stripInjection(
+        bak ? readFileSync(bak, "utf8") : live);
+      const loc = this.locateEntry(pristine);
+      const jsx = this.jsxName(pristine);
+      if (!loc || !jsx) return { ok: false, reason: "anchors not found" };
+      // One-time backup of the CLEAN pristine — never a patched/corrupted
+      // snapshot (the old ensureBackup stored raw live bytes, which is how a
+      // backup itself could become poisoned).
+      if (!bak)
+        writeFileSync(this.backupPath(), Buffer.from(pristine, "utf8"));
+      const block = this.renderBlock(p, loc.arg, jsx);
+      // Inject as the entry's first statement, BEFORE the props destructure +
+      // React-Compiler memo cache. Markers wrap the WHOLE `arg=(IIFE)||arg;`
+      // statement (OUTSIDE the wrapper) so a future strip removes it entirely
+      // — no empty-wrapper residue can ever be left behind. The IIFE always
+      // returns undefined (`arg = undefined || arg`) so Codex's component is
+      // never altered; the ad renders via a body-level overlay.
+      const out = pristine.slice(0, loc.at) +
+        BLOCK_START + loc.arg + "=(" + block + ")||" + loc.arg + ";" +
+        BLOCK_END + pristine.slice(loc.at);
+      const buf = Buffer.from(out, "utf8");
+      if (sha256(buf) !== sha256(readFileSync(this.target)))
+        writeFileSync(this.target, buf);
+      this.patchCspWithReason();
+      return { ok: true };
+    } catch (e) {
+      return { ok: false, reason: String(e) };
+    }
+  }
+
+  // `keepCsp` is part of the shared TargetAdapter contract. Routine extension
+  // deactivate keeps the invisible loopback-only CSP relaxation; explicit
+  // restore / kill / sign-out fully reverts it.
+  restore(opts?: { keepCsp?: boolean }): RestoreResult {
+    try {
+      const bak = this.existingBackupPath();
+      if (bak === null) {
+        if (!opts?.keepCsp) this.restoreCsp();
+        return { ok: true, restored: false, reason: "no backup present" };
+      }
+      const pristine = readFileSync(bak);
+      writeFileSync(this.target, pristine);
+      if (sha256(readFileSync(this.target)) !== sha256(pristine))
+        return { ok: false, restored: false,
+                 reason: "sha256 mismatch after restore" };
+      rmSync(bak);
+      if (!opts?.keepCsp) this.restoreCsp();
+      return { ok: true, restored: true };
+    } catch (e) {
+      return { ok: false, restored: false, reason: String(e) };
+    }
+  }
+}
+
+/** Spec §4.4 display gate (the tested mirror of the block-asset inline check).
+ *  Codex's ThinkingShimmer entry receives `e.message`: null/undefined when it
+ *  is about to render its own i18n "Thinking" placeholder, or a status element
+ *  (`{props}`) the host wants shown instead. We override ONLY the generic
+ *  thinking surface — every real tool / approval / reviewer / arbitrary status
+ *  passes through untouched (prime directive: never mask real UI). */
+export function isThinkingMessage(m: unknown): boolean {
+  if (m == null) return true;
+  if (typeof m !== "object") return false;
+  const p = (m as { props?: Record<string, unknown> }).props;
+  if (!p) return false;
+  return p.id === "reasoningItem.thinking"
+      || p.id === "thinkingShimmer.default"
+      || p.defaultMessage === "Thinking";
+}
diff --git a/src/adapters/codex/block.asset.js b/src/adapters/codex/block.asset.js
new file mode 100644
index 0000000..8b01a18
--- /dev/null
+++ b/src/adapters/codex/block.asset.js
@@ -0,0 +1,558 @@
+(function () {
+  "use strict";
+  // Injected as the first statement of Codex's ThinkingShimmer entry:
+  //   function v(e){ e=(<THIS IIFE>)||e; ... }
+  // It ALWAYS returns undefined → `e = undefined || e` → Codex's component
+  // runs completely untouched. We do NOT swap e.message / return a JSX
+  // element: any mutation of (or substitution into) Codex's React-Compiler
+  // tree is torn out on the next reconcile (the finnicky no-render). Instead,
+  // exactly like the proven Claude Code block, we read Codex's thinking row
+  // READ-ONLY and paint the ad in OUR OWN element appended to <body>, OUTSIDE
+  // React's roots. Rendering is pure DOM (no network) so it works even though
+  // Codex's webview CSP blocks the loopback fetch (telemetry/billing only).
+  // Any throw is swallowed and undefined returned — Codex must never break.
+  try {
+    if (window.__vibeAdsCodexBoot) return undefined;   // one bootstrap / webview
+    window.__vibeAdsCodexBoot = 1;
+  } catch (e) { return undefined; }
+  try {
+    var AD = __VIBE_ADS_AD__;
+    // Ad identity for the live /ad poll's change detection (see pollAd).
+    // Patch-time bake carries no adId; the first poll response fills it in.
+    var AD_ID = "";
+    var CLICKURL = __VIBE_ADS_CLICKURL__;
+    var CLICKTOKEN = __VIBE_ADS_CLICKTOKEN__;
+    var CORR = __VIBE_ADS_CORR__, DEBUG = __VIBE_ADS_DEBUG__;
+    var PORT = __VIBE_ADS_PORT__, LBTOKEN = __VIBE_ADS_LBTOKEN__;
+    var BASE = __VIBE_ADS_BASE__ ||
+      ("http://127.0.0.1:" + PORT + "/vibe-ads/" + LBTOKEN);
+    var GRACE_MS = 1500;
+
+    function esc(s) {
+      return String(s).replace(/[&<>"]/g, function (c) {
+        return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
+      });
+    }
+    function ell(f) { return ["", " .", " ..", " ..."][f % 4]; }
+    function elp(ms) { return (ms / 1000).toFixed(1) + "s"; }
+    function dlog(evt, data) {
+      if (!DEBUG) return;
+      try {
+        var o = { evt: evt, corr: CORR, t: "codex" };
+        if (data) for (var k in data) o[k] = data[k];
+        fetch(BASE + "/log", { method: "POST", keepalive: true,
+          headers: { "content-type": "application/json" },
+          body: JSON.stringify(o) }).catch(function () {});
+      } catch (e) {}
+    }
+    function ping(kind) {
+      try {
+        fetch(BASE + "/" + kind, { method: "POST", keepalive: true })
+          .catch(function () {});
+      } catch (e) {}
+    }
+    function newEventUuid() {
+      try {
+        if (typeof crypto !== "undefined"
+            && typeof crypto.randomUUID === "function") {
+          return crypto.randomUUID();
+        }
+      } catch (e) { /* fall through */ }
+      try {
+        return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,
+          function (c) {
+            var r = Math.random() * 16 | 0;
+            var v = c === "x" ? r : (r & 0x3 | 0x8);
+            return v.toString(16);
+          });
+      } catch (e) { return "evt-" + Date.now() + "-" + Math.random(); }
+    }
+    dlog("codex.boot", { href: String(location.href).slice(0, 80) });
+
+    // ---- W3 view-time accumulator (mirrors claude-code/block.asset.js) -----
+    // An ad must accumulate THRESHOLD_MS of cumulative visible time on the
+    // codex_overlay surface before it counts as a billable view. Threshold
+    // is server-overridable via /v1/portfolio.view_threshold_seconds, baked
+    // into the block as __VIBE_ADS_VIEW_THRESHOLD_MS__ (fallback 15s). Pure
+    // best-effort: any throw is swallowed.
+    var THRESHOLD_MS = (typeof __VIBE_ADS_VIEW_THRESHOLD_MS__ === "number"
+      && __VIBE_ADS_VIEW_THRESHOLD_MS__ > 0)
+      ? __VIBE_ADS_VIEW_THRESHOLD_MS__ : 15000;
+    var TICK_MS = 5000;
+    // MAX_SESSION_MS billing cap. Pinned to THRESHOLD_MS (was a hard 5000)
+    // so the 15s `view_threshold_met` fires FIRST and becomes the billing
+    // path; the moment it fires the mutex (errorImpressionCount===0 check)
+    // suppresses error_impression for the rest of the session. Previously
+    // cap(5s) < threshold(15s) forced EVERY codex view down the
+    // error_impression path — and because cooldown_view_seconds is also 5s
+    // (and the codex block never got the c22c9aa client-side
+    // view_tick/error_impression sync), a long-lived or backgrounded codex
+    // panel billed a phantom error_impression (tier_error_impression_micros,
+    // ~$0.01) roughly every 5s indefinitely, up to the per-user daily cap.
+    // With cap===threshold, error_impression is now only a genuine fallback
+    // (fires solely if threshold_met somehow didn't). See fixes #1/#3 below.
+    var MAX_SESSION_MS = THRESHOLD_MS;
+    var SESSION_NONCE = (function () {
+      try {
+        return (Math.random().toString(36).slice(2)
+          + Math.random().toString(36).slice(2)).slice(0, 16);
+      } catch (e) { return "s" + Date.now(); }
+    })();
+    var _vt = Object.create(null);
+    function vtKey(adId, surface) { return surface + ":" + adId; }
+    function viewShow(adId, surface) {
+      if (!adId) return;
+      var k = vtKey(adId, surface);
+      var s = _vt[k];
+      if (!s) {
+        // Sticky sessionStartedAt — repeat viewShow() with same nonce does
+        // NOT restart the baseline. `paused`/`pausedAt` carry fix #3 (freeze
+        // while the webview is hidden). The session is ENDED (not merely
+        // hidden) when the overlay drops at idle — see viewEnd / dropOverlay.
+        _vt[k] = { adId: adId, surface: surface,
+          sessionNonce: SESSION_NONCE,
+          sessionStartedAt: Date.now(),
+          lastTickMs: 0, thresholdMet: false,
+          errorImpressionCount: 0,
+          paused: false, pausedAt: 0 };
+      }
+    }
+    // Fix #1: END a view session outright (used when the overlay drops at
+    // idle). The old viewHide was a no-op, so once viewShow created a session
+    // the 250ms viewTick kept accumulating elapsed time FOREVER — even after
+    // the ad left the screen — which is exactly the 31-minute "stuck" codex
+    // session that spammed error_impression. Removing the record stops the
+    // accumulator; a later paint() calls viewShow() again for a FRESH session.
+    function viewEnd(adId, surface) {
+      try { delete _vt[vtKey(adId, surface)]; } catch (e) {}
+    }
+    function viewHide(_adId, _surface) {
+      // No-op kept for call-site compatibility; real teardown is viewEnd().
+    }
+    function viewMaybeEmit(s) {
+      // Fix #3: a hidden Codex webview must NOT accrue view-time. (The Claude
+      // adapter deliberately keeps counting when hidden under its absolute-
+      // epoch model; Codex does the opposite — a backgrounded panel was the
+      // other half of the phantom-billing loop.) While document.hidden we
+      // freeze: mark paused (stamping pausedAt once) and emit nothing. On the
+      // next visible poll we shift sessionStartedAt forward by the hidden gap
+      // so off-screen time is excluded from elapsed, then resume mid-session
+      // (lastTickMs / errorImpressionCount untouched, so cadence continues).
+      var hidden = false;
+      try { hidden = (typeof document.hidden === "boolean") && document.hidden; }
+      catch (e) { hidden = false; }
+      if (hidden) {
+        if (!s.paused) { s.paused = true; s.pausedAt = Date.now(); }
+        return;
+      }
+      if (s.paused) {
+        s.sessionStartedAt += Math.max(0, Date.now() - (s.pausedAt || Date.now()));
+        s.paused = false; s.pausedAt = 0;
+      }
+      var elapsed = Math.max(0, Date.now() - s.sessionStartedAt);
+      var tickFired = false;
+      while (elapsed - s.lastTickMs >= TICK_MS) {
+        s.lastTickMs += TICK_MS;
+        tickFired = true;
+        var tickEventUuid = newEventUuid();
+        var q = "?surface=" + encodeURIComponent(s.surface)
+          + "&ad=" + encodeURIComponent(s.adId)
+          + "&visible_ms=" + s.lastTickMs
+          + "&session=" + encodeURIComponent(s.sessionNonce)
+          + "&event_uuid=" + encodeURIComponent(tickEventUuid);
+        ping("view_tick" + q);
+      }
+      // Mutex: threshold_met fires once per session, and only when no
+      // error_impression has fired yet. With cap===threshold (fix #2) the
+      // threshold check runs first at elapsed>=THRESHOLD_MS and wins, so
+      // threshold_met is now the PRIMARY billing path for codex (it used to
+      // never fire because the 5s cap tripped error_impression first).
+      if (!s.thresholdMet && s.errorImpressionCount === 0
+          && elapsed >= THRESHOLD_MS) {
+        s.thresholdMet = true;
+        var thresholdEventUuid = newEventUuid();
+        var q2 = "?surface=" + encodeURIComponent(s.surface)
+          + "&ad=" + encodeURIComponent(s.adId)
+          + "&visible_ms=" + elapsed
+          + "&threshold_ms=" + THRESHOLD_MS
+          + "&session=" + encodeURIComponent(s.sessionNonce)
+          + "&event_uuid=" + encodeURIComponent(thresholdEventUuid);
+        dlog("codex.view.threshold_met", { adId: s.adId, surface: s.surface,
+          visibleMs: elapsed, eventUuid: thresholdEventUuid });
+        ping("view_threshold_met" + q2);
+      }
+      var nextFireAt = (s.errorImpressionCount + 1) * MAX_SESSION_MS;
+      if (!tickFired && !s.thresholdMet && elapsed >= nextFireAt) {
+        s.errorImpressionCount += 1;
+        var errorEventUuid = newEventUuid();
+        var q3 = "?surface=" + encodeURIComponent(s.surface)
+          + "&ad=" + encodeURIComponent(s.adId)
+          + "&visible_ms=" + elapsed
+          + "&max_session_ms=" + MAX_SESSION_MS
+          + "&fire=" + s.errorImpressionCount
+          + "&session=" + encodeURIComponent(s.sessionNonce)
+          + "&event_uuid=" + encodeURIComponent(errorEventUuid);
+        dlog("codex.view.error_impression", { adId: s.adId,
+          surface: s.surface, visibleMs: elapsed,
+          fire: s.errorImpressionCount, eventUuid: errorEventUuid });
+        ping("error_impression" + q3);
+      }
+    }
+    function viewTick() {
+      try {
+        for (var k in _vt) viewMaybeEmit(_vt[k]);
+      } catch (e) {}
+    }
+    setInterval(viewTick, 250);
+    // Click-threshold companion: snapshot the running visible_ms for an
+    // ad+surface so the extension loopback can apply a floor (clicks
+    // before X ms of cumulative visible time get logged but not billed).
+    function viewVisibleMsNow(adId, surface) {
+      try {
+        var s = _vt[vtKey(adId, surface)];
+        if (!s) return 0;
+        return Math.max(0, Date.now() - s.sessionStartedAt);
+      } catch (e) { return 0; }
+    }
+    // Hidden-webview accounting (fix #3): unlike the Claude adapter, a hidden
+    // Codex panel does NOT keep counting — viewMaybeEmit pauses the
+    // accumulator while document.hidden is true and resumes (shifting the
+    // baseline past the hidden gap) when it's shown again. The 250ms poll
+    // observes the visibility flip within a tick, so no separate
+    // visibilitychange listener is needed.
+
+    var FG = "var(--vscode-foreground,currentColor)";
+    var DIM = "var(--vscode-descriptionForeground,currentColor)";
+    var FAV = '<svg width="13" height="13" viewBox="0 0 13 13" ' +
+      'aria-hidden="true" style="vertical-align:middle;border-radius:3px;' +
+      'flex:0 0 auto"><rect width="13" height="13" rx="3" fill="#188a45"/>' +
+      '<text x="6.5" y="9.6" font-size="9" font-family="monospace" ' +
+      'font-weight="700" text-anchor="middle" fill="#fff">K</text></svg>';
+    function buildAd(dots, elapsed) {
+      var href = /^https?:\/\//i.test(CLICKURL || "") ? esc(CLICKURL) : "#";
+      // Favicon lives INSIDE the anchor (matches CC's adapter shape) so a
+      // probe like `[data-vibe-ads-ad] svg` finds it — the row 08 / 09
+      // adHasFavicon check used to fail because the SVG was a sibling of
+      // the anchor, not a descendant. Visual is unchanged: favicon stays
+      // immediately left of the ad text, just now part of the link
+      // (clicking the favicon now navigates, which is the desired UX).
+      var A = '<a href="' + href + '" target="_blank" rel="noopener noreferrer" ' +
+        'data-vibe-ads-ad="1" style="display:inline-flex;align-items:center;' +
+        'gap:7px;color:' + FG +
+        ';text-decoration:underline;overflow:hidden;text-overflow:ellipsis">' +
+        FAV + esc(AD) + '<span style="display:inline-block;width:3ch;' +
+        'text-align:left;white-space:pre">' + esc(dots) + "</span></a>";
+      var left = '<span style="display:flex;align-items:center;gap:7px;' +
+        'color:' + FG + ';min-width:0">' + A + "</span>";
+      var right = '<span style="font-size:11px;color:' + DIM +
+        ';flex:0 0 auto;margin-left:auto;padding-left:24px;' +
+        'font-variant-numeric:tabular-nums">' + esc(elapsed) + "</span>";
+      return '<span style="display:flex;align-items:center;width:100%;' +
+        'box-sizing:border-box;padding:0 4px;justify-content:flex-start;' +
+        'white-space:nowrap">' + left + right + "</span>";
+    }
+
+    // Locate Codex's thinking-shimmer line READ-ONLY. The entry component `v`
+    // renders its text span with the stable class combo
+    // `text-size-chat … select-none truncate` (from the chunk:
+    // o=s("text-size-chat leading-[1.5] select-none truncate", className)).
+    // Require ALL THREE tokens so nothing else in the chat ever matches —
+    // and since we only READ its rect (never mutate it) a mis-match is at
+    // worst a cosmetic mispaint, never a prime-directive violation.
+    //
+    // ALSO require `loading-shimmer` (the live sweep marker) and a non-zero
+    // bounding rect. Codex 26.x keeps the post-turn "Thinking 1.2s" summary
+    // chip in the chat history with the SAME `text-size-chat truncate
+    // select-none` class combo and textContent "Thinking 1.2s", but as a
+    // display:none / hidden element — it does NOT carry `loading-shimmer-*`.
+    // Without the extra anchors the old predicate matched the static history
+    // chip and `isThinkingRow()` returned true forever — overlay never
+    // released at idle (row 03 regression) and stuck to turn 1 on multi-
+    // turn prompts (row 08, "glued to turn 1").
+    function findRow() {
+      var els = document.querySelectorAll(
+        '[class*="text-size-chat"][class*="truncate"]');
+      for (var i = 0; i < els.length; i++) {
+        var el = els[i];
+        if (el.nodeType !== 1) continue;
+        var c = " " + (el.className || "") + " ";
+        if (c.indexOf("select-none") === -1) continue;
+        if (c.indexOf("loading-shimmer") === -1) continue;
+        var r = el.getBoundingClientRect && el.getBoundingClientRect();
+        if (!r || (!r.width && !r.height)) continue;
+        // Honour visibility / opacity. Codex 26.x keeps the live shimmer
+        // element mounted briefly with `visibility:hidden` between the
+        // text-stream-end and the React unmount, so the row is technically
+        // present (rect non-zero, class trio still on) but invisible —
+        // exactly the post-stream state row 03 / codexBusy treat as
+        // "idle." Without this guard the overlay keeps painting on a
+        // hidden shimmer and isAnimating() catches our dot-cycle frames.
+        try {
+          var cs = window.getComputedStyle && window.getComputedStyle(el);
+          if (cs) {
+            if (cs.visibility === "hidden" || cs.display === "none") continue;
+            if (parseFloat(cs.opacity || "1") < 0.05) continue;
+          }
+        } catch (e) { /* jsdom / detached frame: fall through, pass */ }
+        return el;
+      }
+      return null;
+    }
+    // §4.4 gate, DOM-driven: show ONLY when the row is Codex's GENERIC
+    // "Thinking" placeholder. A real tool/approval/reasoning status renders
+    // different text (e.g. "Reading …", "Running …") → we yield (no overlay).
+    //
+    // Codex 26.x renders the shimmer as TWO stacked spans (base + sweep
+    // highlight overlay) so textContent concatenates to "ThinkingThinking".
+    // The previous /^thinking\b/i predicate failed against that string
+    // because `\b` requires a non-word char after "thinking" and the next
+    // char (`T`) is a word char — that was the regression causing rows
+    // 02 / 08 / 09 sawAd:false. Drop the word-boundary and rely on the
+    // length cap + the class-trio anchor in findRow().
+    function isThinkingRow(el) {
+      if (!el) return false;
+      var t = (el.textContent || "").trim();
+      // Empty was previously treated as "shimmer mounted, text incoming"
+      // but it's ALSO the post-completion state where Codex unmounts
+      // the text but leaves the wrapper span. That ambiguity made the
+      // ad linger past streaming (row 03 idle-release regression).
+      // Require an explicit "thinking" prefix; the transient empty
+      // window is brief enough that the GRACE_MS=1500 idle timer
+      // handles re-paint on subsequent turns without leaking idle paint.
+      if (!t) return false;
+      // accept "Thinking", "ThinkingThinking" (duplicated-span case),
+      // "Thinking…", "Thinking 1.2s" — all start with "thinking".
+      var lc = t.toLowerCase();
+      return lc.length <= 32 && lc.indexOf("thinking") === 0;
+    }
+    function surfaceBg(el) {
+      // The overlay sits on top of the "Thinking" row, so a transparent
+      // bg would let the underlying verb text bleed through. We need a
+      // colour, and it must match the surrounding chat-panel surface.
+      // The old strategy (first non-transparent ancestor) grabbed
+      // intermediate wrappers — chat bubble surfaces, hover tints —
+      // which differ in shade from the visible panel and made the ad
+      // look like a patch. Preferred chain:
+      //   1. nearest SCROLLABLE ancestor's bg (== the visible panel)
+      //   2. document.body's computed bg (== VS Code's webview theme)
+      //   3. CSS var fallback (theme-aware even when computed values
+      //      aren't resolvable, e.g. detached frames)
+      try {
+        var n = el, hops = 0;
+        while (n && n.nodeType === 1 && hops++ < 20) {
+          var cs = window.getComputedStyle(n) || {};
+          var ov = cs.overflowY || cs.overflow;
+          if (ov === "auto" || ov === "scroll") {
+            var bg = cs.backgroundColor;
+            if (bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)")
+              return bg;
+            break;          // found the panel; its bg is transparent
+          }
+          n = n.parentElement;
+        }
+        var bodyBg = (window.getComputedStyle(document.body) || {})
+          .backgroundColor;
+        if (bodyBg && bodyBg !== "transparent"
+          && bodyBg !== "rgba(0, 0, 0, 0)") return bodyBg;
+      } catch (e) {}
+      return "var(--vscode-sideBar-background,"
+        + "var(--vscode-editor-background,#1e1e1e))";
+    }
+
+    // Click-out: the anchor's real http(s) href is what the VS Code webview
+    // host opens externally (CSP-exempt) — the only click that survives
+    // Codex's `default-src 'none'`. Do NOT preventDefault. The ping is
+    // best-effort billing (revived by the Codex connect-src patch).
+    document.addEventListener("click", function (ev) {
+      var el = ev.target;
+      while (el && el !== document) {
+        if (el.getAttribute && el.getAttribute("data-vibe-ads-ad")) {
+          var vms = viewVisibleMsNow(AD, "codex_overlay");
+          var clickEventUuid = newEventUuid();
+          dlog("codex.click", { ct: CLICKTOKEN, visibleMs: vms,
+            eventUuid: clickEventUuid });
+          // `ad=` is the attribution CLAIM (parity with the CC block): the
+          // host's recent-ads registry resolves a click that lands during
+          // the ≤10s /ad poll lag after a rotation to the creative actually
+          // on screen instead of the freshly-rotated one.
+          ping("click?ct=" + encodeURIComponent(CLICKTOKEN) +
+            "&corr=" + encodeURIComponent(CORR) +
+            "&surface=codex_overlay" +
+            "&visible_ms=" + vms +
+            "&ad=" + encodeURIComponent(AD) +
+            "&event_uuid=" + encodeURIComponent(clickEventUuid));
+          return;
+        }
+        el = el.parentNode;
+      }
+    }, true);
+
+    var overlay = null, lastRow = null, lastSeenMs = 0, _rect = "";
+    var t0 = 0, frameN = 0, _shown = false, _sent = false;
+    function ensureOverlay(row) {
+      if (overlay && overlay.parentNode) return overlay;
+      overlay = document.createElement("div");
+      overlay.setAttribute("data-vibe-ads", "codex");
+      overlay.style.cssText =
+        "position:fixed;z-index:2147483646;pointer-events:auto;" +
+        "display:flex;align-items:center;box-sizing:border-box;" +
+        "overflow:hidden;white-space:nowrap;visibility:hidden;background:" +
+        surfaceBg(row);
+      try { (document.body || document.documentElement).appendChild(overlay); }
+      catch (e) {}
+      return overlay;
+    }
+    function placeOverlay(row) {
+      try {
+        var r = row.getBoundingClientRect();
+        if (r && (r.width || r.height || r.top || r.left)) {
+          var key = r.left + "," + r.top + "," + r.width + "," + r.height;
+          if (key !== _rect) {
+            _rect = key;
+            overlay.style.left = r.left + "px";
+            overlay.style.top = r.top + "px";
+            overlay.style.minWidth = r.width + "px";
+            overlay.style.height = r.height + "px";
+            overlay.style.visibility = "visible";
+          }
+        }
+      } catch (e) {}
+    }
+    function dropOverlay() {
+      // Fix #1: END the view session (was a no-op viewHide) so the
+      // accumulator stops the instant the ad leaves the screen at idle,
+      // instead of ticking off-screen forever. _sent reset below means the
+      // next turn opens a fresh impression session.
+      try { viewEnd(AD, "codex_overlay"); } catch (e) {}
+      try { if (overlay && overlay.parentNode)
+        overlay.parentNode.removeChild(overlay); } catch (e) {}
+      overlay = null; lastRow = null; _rect = ""; _shown = false; _sent = false;
+    }
+    function paint(row) {
+      var now = Date.now();
+      if (!t0) t0 = now;
+      lastRow = row; lastSeenMs = now;
+      frameN++;
+      if (!_shown) { _shown = true;
+        dlog("codex.show", { cls: String(row.className || "").slice(0, 60) }); }
+      if (!_sent) { _sent = true;
+        ping("impression_rendered?surface=codex_overlay&ad="
+          + encodeURIComponent(AD)
+          + "&event_uuid=" + encodeURIComponent(newEventUuid()));
+        var vis = (typeof document.hidden === "undefined")
+          ? true : !document.hidden;
+        if (vis) {
+          ping("impression_viewable?surface=codex_overlay&ad="
+            + encodeURIComponent(AD)
+            + "&event_uuid=" + encodeURIComponent(newEventUuid()));
+        }
+      }
+      try { viewShow(AD, "codex_overlay"); } catch (e) {}
+      var o = ensureOverlay(row);
+      placeOverlay(row);
+      var html = buildAd(ell(Math.floor(frameN / 3)), elp(now - t0));
+      if (o.innerHTML !== html) o.innerHTML = html;     // OUR node only
+    }
+    function frame() {
+      try {
+        if (overlay && lastRow && lastRow.isConnected) placeOverlay(lastRow);
+      } catch (e) {}
+      try { window.requestAnimationFrame(frame); }
+      catch (e) { setTimeout(frame, 16); }
+    }
+    try { window.requestAnimationFrame(frame); }
+    catch (e) { setTimeout(frame, 16); }
+
+    // ---- Live ad refresh (parity with claude-code/block.asset.js pollAd) --
+    // The baked __VIBE_ADS_AD__ used to be FROZEN for the life of the webview:
+    // rotation re-patched the bundle on disk but a running Codex panel never
+    // re-read it, so users sat on one creative — or the pre-inventory
+    // "Your ad here" placeholder — until a full reload (the "frozen ads on
+    // codex" complaint, 2026-06-11). Poll the loopback /ad every 10s:
+    //   • changed payload  → adopt AD/AD_ID/CLICKURL and RESET all view-time
+    //     sessions + impression flags, so the old creative's accumulated time
+    //     never bills against the new one (fresh session, fresh threshold).
+    //   • successful-but-EMPTY payload ×2 (debounced, same as CC) → the
+    //     host's no-serve signal (kill / disable / sign-out): drop the
+    //     overlay, end every session, and suppress repaint until served.
+    //   • fetch ERROR → keep the last ad (transient network / CSP without the
+    //     connect-src patch — identical to today's behavior, no regression).
+    var _adEmptyPolls = 0, _noServe = false;
+    function pollAd() {
+      try {
+        fetch(BASE + "/ad").then(function (r) { return r.json(); })
+          .then(function (j) {
+            if (!j || !j.adText) {
+              _adEmptyPolls++;
+              if (_adEmptyPolls >= 2 && !_noServe) {
+                _noServe = true;
+                dlog("codex.no_serve", { polls: _adEmptyPolls });
+                // End EVERY session (not just the current key) before the
+                // drop: billing must stop with the serve, unconditionally.
+                _vt = Object.create(null);
+                dropOverlay(); t0 = 0; frameN = 0;
+              }
+              return;
+            }
+            _adEmptyPolls = 0;
+            if (_noServe) {
+              // Host resumed serving (possibly the same creative): re-arm.
+              // The overlay re-mounts on the next active 80ms tick.
+              _noServe = false;
+              dlog("codex.serve_resume", { adId: j.adId });
+            }
+            var changed = (j.adId && j.adId !== AD_ID) || (j.adText !== AD)
+              || ((j.clickUrl || "") !== CLICKURL);
+            if (!changed) return;
+            dlog("codex.ad_rotated",
+              { fromId: AD_ID, toId: j.adId, from: AD, to: j.adText });
+            AD_ID = j.adId || AD_ID;
+            AD = j.adText;
+            CLICKURL = j.clickUrl || "";
+            // Billing fairness on swap: end the old creative's sessions and
+            // re-arm the impression events — the next paint() opens a FRESH
+            // session for the new ad (viewShow keys on the ad text) and
+            // fires impression_rendered/viewable for it. paint()'s
+            // innerHTML-diff repaints the overlay with the new creative on
+            // the next 80ms tick (codex rewrites it every dot-frame anyway).
+            _vt = Object.create(null);
+            _sent = false; _shown = false;
+          }).catch(function () {
+            // Fetch ERROR: keep the last ad — deliberately NOT no-serve.
+          });
+      } catch (e) { /* prime directive */ }
+    }
+    setInterval(pollAd, 10000);
+    // One early poll so a patch-time placeholder ("Your ad here") or a
+    // rotation that landed between patch and boot is replaced within ~5s.
+    setTimeout(pollAd, 5000);
+
+    setInterval(function () {
+      try {
+        var now = Date.now();
+        var row = findRow();
+        if (!_noServe && row && isThinkingRow(row)) {
+          paint(row);
+        } else if (overlay && (now - lastSeenMs) > GRACE_MS) {
+          dlog("codex.idle", { sinceMs: now - lastSeenMs });
+          dropOverlay(); t0 = 0; frameN = 0;
+        }
+      } catch (e) {
+        dlog("codex.looperr",
+          { msg: String(e && e.message || e).slice(0, 160) });
+      }
+    }, 80);
+  } catch (__vibeads) {
+    try {
+      var D = __VIBE_ADS_DEBUG__, C = __VIBE_ADS_CORR__,
+        B = __VIBE_ADS_BASE__ || ("http://127.0.0.1:" + __VIBE_ADS_PORT__ +
+          "/vibe-ads/" + __VIBE_ADS_LBTOKEN__);
+      if (D) fetch(B + "/log", { method: "POST", keepalive: true,
+        headers: { "content-type": "application/json" },
+        body: JSON.stringify({ evt: "codex.throw", corr: C, t: "codex",
+          msg: String(__vibeads && __vibeads.message || __vibeads)
+            .slice(0, 140) }) }).catch(function () {});
+    } catch (e) {}
+  }
+  return undefined;                 // ALWAYS — Codex's component untouched
+})()
diff --git a/src/adapters/registry.ts b/src/adapters/registry.ts
new file mode 100644
index 0000000..1108385
--- /dev/null
+++ b/src/adapters/registry.ts
@@ -0,0 +1,119 @@
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { readdirSync, existsSync } from "node:fs";
+import type { TargetAdapter } from "./types";
+import { ClaudeCodeAdapter } from "./claude-code/adapter";
+import { CodexAdapter } from "./codex/adapter";
+import { compareClaudeCodeInstall } from "../util/claudeCodeVersion";
+
+// Same host roots locate.ts scans for Claude Code (keep the lists in sync):
+// local (.vscode/.vscode-insiders/.cursor) AND remote/server hosts
+// (Remote-SSH, dev containers, vscode.dev) where extensions live under
+// *-server/.
+const ROOTS = [".vscode", ".vscode-insiders", ".vscode-server",
+  ".vscode-server-insiders", ".cursor", ".cursor-server"]
+  .map((d) => join(homedir(), d, "extensions"));
+
+// An env override is AUTHORITATIVE when set (non-empty): return it iff it
+// exists, else treat the target as absent — never silently fall back to a
+// machine scan. Keeps the discovery matrix hermetic and makes an explicit
+// S5/manual-smoke override predictable. `undefined` => env unset => scan.
+function envTarget(name: string): string | null | undefined {
+  const v = process.env[name];
+  if (!v) return undefined;                 // unset/empty => fall through to scan
+  return existsSync(v) ? v : null;          // set => authoritative (exists or absent)
+}
+
+// Newest `<root>/<prefix>*/<...sub>` across all host roots. Highest semantic
+// Claude Code version wins; lexical ordering is only a fallback for unexpected
+// names. Never throws.
+function newestUnder(prefix: string, sub: string[]): string | null {
+  for (const root of ROOTS) {
+    try {
+      if (!existsSync(root)) continue;
+      const hits: string[] = [];
+      for (const name of readdirSync(root)) {
+        if (!name.startsWith(prefix)) continue;
+        const p = join(root, name, ...sub);
+        if (existsSync(p)) hits.push(p);
+      }
+      hits.sort(compareClaudeCodeInstall);
+      if (hits.length) return hits[hits.length - 1];
+    } catch { /* ignore this root */ }
+  }
+  return null;
+}
+
+// Codex ships the entry component in a content-hashed chunk:
+// openai.chatgpt-<ver>/webview/assets/thinking-shimmer-<hash>.js — glob the
+// assets dir for the prefix (newest extension dir, first matching chunk).
+function newestCodexChunk(): string | null {
+  for (const root of ROOTS) {
+    try {
+      if (!existsSync(root)) continue;
+      const ext = readdirSync(root)
+        .filter((n) => n.startsWith("openai.chatgpt-")).sort();
+      for (let i = ext.length - 1; i >= 0; i--) {
+        const assets = join(root, ext[i], "webview", "assets");
+        if (!existsSync(assets)) continue;
+        const cf = readdirSync(assets)
+          .filter((n) => /^thinking-shimmer-.*\.js$/.test(n)).sort();
+        if (cf.length) return join(assets, cf[0]);
+      }
+    } catch { /* ignore this root */ }
+  }
+  return null;
+}
+
+export interface TargetEntry {
+  id: string;
+  locate(): string | null;
+  make(target: string): TargetAdapter;
+}
+
+// Order = precedence: claude-code is the "primary" (spec S9-5) — its version
+// is the one reported to the backend / status bar.
+export const REGISTRY: TargetEntry[] = [
+  {
+    id: "claude-code",
+    locate: () => {
+      const ev = envTarget("KICKBACKS_CC_TARGET")
+        ?? envTarget("VIBE_ADS_CC_TARGET");
+      if (ev !== undefined) return ev;        // authoritative when set
+      return newestUnder("anthropic.claude-code-", ["webview", "index.js"]);
+    },
+    make: (t) => new ClaudeCodeAdapter(t),
+  },
+  {
+    id: "codex",
+    locate: () => {
+      const ev = envTarget("KICKBACKS_CODEX_TARGET")
+        ?? envTarget("VIBE_ADS_CODEX_TARGET");
+      if (ev !== undefined) return ev;        // authoritative when set
+      return newestCodexChunk();
+    },
+    make: (t) => new CodexAdapter(t),
+  },
+];
+
+/** The Codex chunk target on this host (env override authoritative when set,
+ *  else newest install), or null. Never throws — lets extension.ts wire Codex
+ *  as a guarded, additive second target without duplicating the scan. */
+export function locateCodexTarget(): string | null {
+  try {
+    return REGISTRY.find((e) => e.id === "codex")!.locate();
+  } catch { return null; }
+}
+
+/** Every target present on this host, in registry (precedence) order. One bad
+ *  locator never blocks the others (each guarded). */
+export function discover(): { id: string; adapter: TargetAdapter }[] {
+  const out: { id: string; adapter: TargetAdapter }[] = [];
+  for (const e of REGISTRY) {
+    try {
+      const t = e.locate();
+      if (t) out.push({ id: e.id, adapter: e.make(t) });
+    } catch { /* one bad locator never blocks the other */ }
+  }
+  return out;
+}
diff --git a/src/adapters/types.ts b/src/adapters/types.ts
new file mode 100644
index 0000000..1ced698
--- /dev/null
+++ b/src/adapters/types.ts
@@ -0,0 +1,106 @@
+/** Result of a never-throwing real-install operation. */
+export interface OpResult {
+  ok: boolean;
+  /** Human-readable reason on failure / status note. */
+  reason?: string;
+}
+
+export interface PreflightResult extends OpResult {
+  compatible: boolean;
+  /** Target version string if locatable, else null. */
+  version: string | null;
+}
+
+export interface RestoreResult extends OpResult {
+  restored: boolean;
+}
+
+export interface PatchParams {
+  tier: 0 | 1 | 2 | 3;
+  adText: string;
+  iconRef: string;
+  iconUrl: string;
+  clickToken: string;
+  /** Advertiser landing URL. Rendered as the ad anchor's real `href` so the
+   *  VS Code webview host opens it externally on click — the only click-out
+   *  that survives Claude Code's `default-src 'none'` webview CSP (an in-page
+   *  fetch to the loopback is CSP-blocked; postMessage reaches CC's extension,
+   *  not ours). The loopback is used only for the fire-and-forget click metric
+   *  (revived by the companion extension.js connect-src patch). */
+  clickUrl: string;
+  /** Correlation id minted at patch time (`<adId>.<rand>` / `debug.<rand>`);
+   *  threaded into the block's loopback /click ping + relayed dlog lines and
+   *  the X-Vibe-Corr metrics header so one grep reconstructs the event chain
+   *  (webview → loopback → ext metric → backend ingest). */
+  corr: string;
+  loopbackPort: number;
+  loopbackToken: string;
+  /** Webview-reachable loopback base (scheme/host/port + /vibe-ads/<token>).
+   *  On VS Code Remote/Server the webview runs on the client, so raw
+   *  127.0.0.1 is unreachable — callers resolve this via
+   *  vscode.env.asExternalUri before applyPatch. */
+  loopbackBase: string;
+  /** When true the injected block relays timestamped lifecycle events to the
+   *  loopback /log route (→ ~/.vibe-ads/debug.log) for headless diagnosis.
+   *  Resolved from VIBE_ADS_DEBUG / the debug.enabled sentinel at patch time. */
+  debug?: boolean;
+  /** When true the injected block also renders the auction ad in Claude
+   *  Code's usage-limit banner (mirror of the spinner ad; spec §3). Resolved
+   *  from the server bannerEnabled flag ⊕ the local debug override at patch
+   *  time. Empty/false ⇒ the block never touches the banner. */
+  bannerOn?: boolean;
+  /** W3: cumulative-visible-time threshold (in ms) an ad must accumulate
+   *  before it counts as "shown". Server-authoritative via
+   *  /v1/portfolio.view_threshold_seconds; falls back to 15_000 ms when the
+   *  server did not specify. Baked into the block as
+   *  `__VIBE_ADS_VIEW_THRESHOLD_MS__`. */
+  viewThresholdMs?: number;
+}
+
+/** Ground-truth snapshot for the `Kickbacks: Diagnose` command — everything
+ *  needed to tell a cosmetic flash from a real miss without reading the bundle
+ *  by hand. `bareVerbPresent` is the key tell: a verb word exists in the live
+ *  file but `hasArray` is false ⇒ the bundle format changed (fix the regex);
+ *  neither present ⇒ the file was stripped/corrupted (reinstall Claude Code). */
+export interface AdapterDiagnostics {
+  name: string;
+  target: string;
+  targetExists: boolean;
+  version: string | null;
+  compatible: boolean;
+  reason?: string;
+  isPatched: boolean;
+  backup: { exists: boolean; path: string | null; hasArray: boolean; hasBlock: boolean };
+  live: { hasArray: boolean; bareVerbPresent: boolean };
+}
+
+/** A patch target (Claude Code now; Codex later). All methods are
+ *  absolute-path, never-throw, and return typed results. */
+export interface TargetAdapter {
+  readonly name: string;
+  preflight(): PreflightResult;
+  version(): string | null;
+  applyPatch(p: PatchParams): OpResult;
+  /** `keepCsp` (claude-code only) reverts the visible patch but keeps the
+   *  loopback connect-src CSP relaxation — see ClaudeCodeAdapter.restore.
+   *  Routine deactivate passes it; explicit teardown does not. */
+  restore(opts?: { keepCsp?: boolean }): RestoreResult;
+  /** True iff the target currently carries our injected block. Cheap
+   *  (one file read); lets a reassert tick no-op when already applied so it
+   *  only does work when the patch actually drifted (CC overwrite / fresh
+   *  load). Optional: adapters without a meaningful notion may omit it. */
+  isPatched?(): boolean;
+  /** Ground-truth diagnostics for the user-facing diagnose command. Optional:
+   *  adapters that don't implement it are simply summarised by preflight(). */
+  diagnose?(): AdapterDiagnostics;
+  /** Apply ONLY the invisible structural relaxation the loopback needs (the
+   *  connect-src CSP insertion on the sibling extension.js) WITHOUT injecting
+   *  any ad block. Lets activation prime the surface on every boot — even when
+   *  no ad is in hand yet, even signed out — so loopback telemetry works the
+   *  moment an ad arrives instead of waiting for a reassert tick or a manual
+   *  reload. Idempotent, never throws, reversible via restore() (same keepCsp
+   *  contract as applyPatch's CSP layer). A later applyPatch is a no-op on the
+   *  CSP it already inserted. Optional: adapters with no structural layer may
+   *  omit it. */
+  prime?(): OpResult;
+}
diff --git a/src/auth/client.ts b/src/auth/client.ts
new file mode 100644
index 0000000..b7ffa0d
--- /dev/null
+++ b/src/auth/client.ts
@@ -0,0 +1,419 @@
+import * as vscode from "vscode";
+import { randomBytes } from "node:crypto";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
+import { createVault, type SecretVault } from "./vault";
+import { dlog } from "../log";
+import { isLoopbackBase } from "../util/loopback";
+import { timeoutFetch } from "../util/http";
+
+type Fetch = typeof fetch;
+// kickbacks.* are the current keys; vibe-ads.* are legacy (pre-W1-rename)
+// and read-through-only — copied forward on first access so the user's stable
+// device id, refresh token, and access token survive the rename. We never
+// DELETE the legacy entries; that way a downgrade still finds a session.
+const A = "kickbacks.access", R = "kickbacks.refresh", CID = "kickbacks.clientId";
+const A_LEGACY = "vibe-ads.access", R_LEGACY = "vibe-ads.refresh";
+const CID_LEGACY = "vibe-ads.clientId";
+// A vault-sealed refresh value; anything NOT matching is a pre-vault legacy
+// plaintext token written by an older build (upgraded in place on first read).
+const ENVELOPE = /^(plain|keychain|dpapi|libsecret):1:/;
+
+interface Fallback { refresh?: string; clientId?: string }
+
+/** S1 backend-brokered sign-in. Cross-platform persistence is LAYERED:
+ *
+ *   1. VS Code SecretStorage (ctx.secrets) — fast cache. Durable on macOS/
+ *      Windows; a coin-flip on Linux (no Secret Service on headless/WSL/
+ *      devcontainer/Remote-SSH). NEVER the source of truth.
+ *   2. id-independent home file (~/.vibe-ads/auth.json, 0600) — the universal
+ *      floor; behaves identically on all 3 OSes and survives reinstall/rename.
+ *      The refresh token in it is sealed by `SecretVault` (Keychain on macOS,
+ *      DPAPI on Windows, libsecret on Linux, plaintext floor otherwise).
+ *
+ *  The file is ALWAYS written when we hold a refresh token (the old `if
+ *  (refresh)`-only bug created the "signed in last session but shows signed
+ *  out" gap on keyring-less Linux). S1's refresh role/email loss is ACCEPTED. */
+export class AuthClient {
+  private at: string | null = null;
+  private keyringOk: boolean | undefined; // set by the post-store read-back
+  // Single-flight guard: coalesce concurrent refresh() callers (status-bar
+  // earnings 401 + portfolio 401 + the load-time re-mint) onto ONE in-flight
+  // request. S1 ROTATES (consumes) the refresh token, so two parallel
+  // refreshes race — the first rotates the token, the second sends the
+  // now-consumed token, 401s, and nulls `at`, clobbering the first call's
+  // success. That race is the most likely cause of "signed out after a
+  // self-update restart".
+  private refreshInFlight: Promise<boolean> | null = null;
+  // Single-flight guard for the INTERACTIVE flow (mirrors refreshInFlight):
+  // a second "Sign in" click while the browser tab is still loading must
+  // join the in-flight flow, not mint a second `state` + parallel 3-minute
+  // poll loop that ends in a false "timed out" toast after the user already
+  // signed in through the first one.
+  private signInInFlight: Promise<boolean> | null = null;
+  private secretsStoreWarned = false; // log keyring store failures once
+  // Login trigger: fired once on a successful interactive sign-in so the
+  // injection patch is reasserted immediately rather than waiting up to 60s
+  // for the next reassert tick. Wired by extension.ts; no-op until then.
+  private onSignedIn: (() => void) | null = null;
+  setOnSignedIn(fn: () => void): void { this.onSignedIn = fn; }
+  constructor(private base: string,
+              private ctx: vscode.ExtensionContext,
+              private f: Fetch = timeoutFetch(15000),
+              private pollMs = 1500,
+              // ~/.kickbacks/auth.json is the new universal floor. If only the
+              // legacy ~/.vibe-ads/auth.json exists we migrate-on-read inside
+              // readFallback(); the legacy file is left in place for downgrade.
+              private authFile = join(homedir(), ".kickbacks", "auth.json"),
+              private vault: SecretVault = createVault(process.platform),
+              // Defined AFTER `vault` to preserve the constructor's positional
+              // parameter order — existing call sites that pass `vault` as the
+              // 6th arg keep working without test churn.
+              private legacyAuthFile = join(homedir(), ".vibe-ads", "auth.json")) {}
+
+  accessToken(): string | null { return this.at; }
+  signedIn(): boolean { return this.at != null; }
+
+  /** For `vibe-ads.status` / debug menu: which at-rest scheme is in use and
+   *  whether the OS keyring actually round-tripped (the original "didn't catch
+   *  that I was signed in" was an undetected keyring-less environment). */
+  storageInfo(): { scheme: string; keyringDurable?: boolean } {
+    return { scheme: this.vault.scheme(), keyringDurable: this.keyringOk };
+  }
+
+  /** Full sign-out. Must clear EVERY place a session can be recovered from or
+   *  loadCached() silently re-mints and the user can never log out: in-memory
+   *  token, both SecretStorage keys, the OS-store entry behind the envelope
+   *  (Keychain/libsecret), AND the refresh field in the file. The stable anon
+   *  clientId is intentionally KEPT (device id, not auth). Never throws. */
+  async signOut(): Promise<void> {
+    this.at = null;
+    // Revoke server-side FIRST (BL-188): resolve the raw refresh token before
+    // the clears below destroy every copy, then fire the revocation without
+    // awaiting it — sign-out must stay instant and must succeed offline. The
+    // backend deletes the token record, so a copy of the rotating token
+    // (stolen file, forgotten machine) can no longer mint sessions for the
+    // rest of its TTL after the user signed out.
+    try {
+      let rt = (await this.ctx.secrets.get(R)) || undefined;
+      if (!rt) {
+        const stored = this.readFallback().refresh;
+        if (stored && ENVELOPE.test(stored)) {
+          rt = (await this.vault.open(stored)) || undefined;
+        } else if (stored) {
+          rt = stored;
+        }
+      }
+      if (rt) {
+        this.f(`${this.base}/v1/auth/signout`, {
+          method: "POST", headers: { "content-type": "application/json" },
+          body: JSON.stringify({ refresh_token: rt }),
+        }).then((r) => dlog("ext", "auth.revoke", { ok: r.ok, status: r.status }))
+          .catch((e) => dlog("ext", "auth.revoke",
+            { ok: false, msg: e instanceof Error ? e.message : String(e) }));
+      }
+    } catch { /* revocation is defense-in-depth; never block sign-out */ }
+    const env = this.readFallback().refresh;
+    try { await this.ctx.secrets.delete(A); } catch { /* best-effort */ }
+    try { await this.ctx.secrets.delete(R); } catch { /* best-effort */ }
+    // Also clear legacy vibe-ads.* keys so a stale token can't re-sign-in.
+    try { await this.ctx.secrets.delete(A_LEGACY); } catch { /* best-effort */ }
+    try { await this.ctx.secrets.delete(R_LEGACY); } catch { /* best-effort */ }
+    if (env && ENVELOPE.test(env)) {
+      try { await this.vault.clear(env); } catch { /* best-effort */ }
+    }
+    try {
+      const fb = this.readFallback();
+      delete fb.refresh;                       // keep clientId
+      mkdirSync(join(this.authFile, ".."), { recursive: true, mode: 0o700 });
+      writeFileSync(this.authFile, JSON.stringify(fb), { mode: 0o600 });
+      try { chmodSync(this.authFile, 0o600); } catch { /* best-effort */ }
+    } catch { /* mirror clear is best-effort; never break the extension */ }
+    dlog("ext", "auth.signout", {});
+  }
+
+  /** SecretStorage writes are BEST-EFFORT: a locked/absent Secret Service
+   *  (the exact keyring-less Linux env the file fallback exists for) makes
+   *  ctx.secrets.store() THROW. An unwrapped throw aborted activation from
+   *  loadCached and turned a server-side-successful refresh into a sign-out
+   *  after the rotating token was already consumed. The sealed file
+   *  (sealToFile) is the durable layer; this cache write never throws. */
+  private async storeSecret(key: string, value: string): Promise<boolean> {
+    try { await this.ctx.secrets.store(key, value); return true; }
+    catch (e) {
+      if (!this.secretsStoreWarned) {
+        this.secretsStoreWarned = true;
+        dlog("ext", "auth.secrets.store-failed",
+          { error: e instanceof Error ? e.message : String(e) });
+      }
+      return false;
+    }
+  }
+
+  // --- id-independent fallback (best-effort; never throws) -----------------
+  private readFallback(): Fallback {
+    try { return JSON.parse(readFileSync(this.authFile, "utf8")) as Fallback; }
+    catch { /* fall through to legacy */ }
+    try { return JSON.parse(readFileSync(this.legacyAuthFile, "utf8")) as Fallback; }
+    catch { return {}; }
+  }
+  private writeFallback(patch: Fallback): void {
+    try {
+      const merged = { ...this.readFallback(), ...patch };
+      mkdirSync(join(this.authFile, ".."), { recursive: true, mode: 0o700 });
+      writeFileSync(this.authFile, JSON.stringify(merged), { mode: 0o600 });
+      try { chmodSync(this.authFile, 0o600); } catch { /* best-effort */ }
+    } catch { /* persistence is best-effort; never break the extension */ }
+  }
+
+  clientId(): string {
+    const fileId = this.readFallback().clientId;
+    let id = this.ctx.globalState.get<string>(CID)
+      || this.ctx.globalState.get<string>(CID_LEGACY)  // W1: pre-rename users
+      || fileId;
+    if (!id) id = randomBytes(12).toString("hex");
+    this.ctx.globalState.update(CID, id);
+    // Write the file ONLY when its clientId is missing/stale. This runs on
+    // EVERY metrics send; an unconditional read-merge-write of auth.json can
+    // resurrect a just-rotated refresh envelope another window sealed between
+    // our read and our write (the rotated token is single-use — clobbering it
+    // signs the user out). writeFallback re-reads at write time and merges,
+    // so the token envelope on disk is preserved, never rewritten from here.
+    if (fileId !== id) this.writeFallback({ clientId: id }); // stable across reinstall/rename
+    return id;
+  }
+
+  async loadCached(): Promise<void> {
+    if (this.devBypassEnabled()) {
+      this.at = "dev-bypass";
+      dlog("ext", "auth.loadCached",
+        { hadAccess: true, refreshSource: "dev-bypass", signedIn: true });
+      return;
+    }
+    // W1 rename: try kickbacks.* first, fall back to legacy vibe-ads.* keys.
+    this.at = (await this.ctx.secrets.get(A))
+      || (await this.ctx.secrets.get(A_LEGACY))
+      || null;
+    // Recover the refresh token from the id-independent file if the
+    // SecretStorage namespace was lost (reinstall / rename / keyring-less).
+    let rt = (await this.ctx.secrets.get(R))
+      || (await this.ctx.secrets.get(R_LEGACY)) || undefined;
+    let rtSource = rt ? "secrets" : "none";
+    if (!rt) {
+      const stored = this.readFallback().refresh;
+      if (stored && ENVELOPE.test(stored)) {
+        rt = (await this.vault.open(stored)) || undefined; // sealed (current)
+      } else if (stored) {
+        // Pre-vault plaintext token from an older build: use it, then
+        // upgrade-in-place by re-sealing through the vault.
+        rt = stored;
+        await this.sealToFile(rt);
+      }
+      if (rt) { rtSource = "file"; await this.storeSecret(R, rt); } // re-warm
+    }
+    // No access token but we have a refresh token => re-mint silently
+    // (this is what makes a reinstall NOT require a new Google sign-in).
+    // Hand the recovered token to refresh() EXPLICITLY: relying on it to
+    // re-read ctx.secrets is the gap that breaks recovery when the store()
+    // above doesn't durably round-trip in the same activation (keyring-less
+    // box / fresh post-reinstall namespace).
+    if (!this.at && rt) await this.refresh(rt);
+    dlog("ext", "auth.loadCached",
+      { hadAccess: this.at != null, refreshSource: rtSource,
+        signedIn: this.signedIn() });
+  }
+
+  /** Seal a refresh token via the OS-native vault and ALWAYS persist the
+   *  resulting envelope to the universal file. Best-effort; never throws. */
+  private async sealToFile(refresh: string): Promise<void> {
+    try {
+      const env = await this.vault.seal(this.clientId(), refresh);
+      this.writeFallback({ refresh: env });
+    } catch { /* persistence is best-effort; never break the extension */ }
+  }
+
+  private async persistTokens(access: string, refresh?: string): Promise<void> {
+    this.at = access;
+    const stored = await this.storeSecret(A, access);
+    // Keyring health probe: if the store doesn't round-trip we're in a
+    // keyring-less env and the file (above-vault) is doing the real work.
+    if (!stored) this.keyringOk = false;
+    else {
+      try { this.keyringOk = (await this.ctx.secrets.get(A)) === access; }
+      catch { this.keyringOk = false; }
+    }
+    if (refresh) {
+      await this.storeSecret(R, refresh); // cache only
+      await this.sealToFile(refresh); // ALWAYS — the durable source of truth
+    }
+  }
+
+  /** Interactive sign-in. Concurrent calls (double-click, palette + nudge)
+   *  are coalesced onto ONE browser tab + poll loop — a parallel loop polls
+   *  a state the user never completes and ends in a false "timed out" toast
+   *  minutes after the real flow succeeded. */
+  async signIn(): Promise<boolean> {
+    if (this.signInInFlight) return this.signInInFlight;
+    const p = this._signIn();
+    this.signInInFlight = p;
+    try { return await p; }
+    finally { if (this.signInInFlight === p) this.signInInFlight = null; }
+  }
+
+  private async _signIn(): Promise<boolean> {
+    try {
+      // S1 contract (see tools/ext_auth_harness.py): /extension/start 307-
+      // redirects to Google with `state` embedded in the Location URL. Do NOT
+      // follow the redirect (must not fetch Google's HTML) — read Location,
+      // open it in the system browser, poll with the parsed state.
+      const start = await this.f(`${this.base}/v1/auth/extension/start`,
+        { redirect: "manual" } as RequestInit);
+      const loc = start.headers.get("location");
+      if (!loc) { dlog("ext", "auth.signin", { ok: false, reason: "no-location" }); return false; }
+      const state = new URL(loc).searchParams.get("state");
+      if (!state) { dlog("ext", "auth.signin", { ok: false, reason: "no-state" }); return false; }
+      await vscode.env.openExternal(vscode.Uri.parse(loc));
+      const wasSignedIn = this.signedIn();
+      for (let i = 0; i < 120; i++) {
+        // Signed in through another path mid-poll (background refresh,
+        // loadCached re-mint)? Exit silently — the session is live, and an
+        // error toast after a successful sign-in reads as a broken product.
+        if (!wasSignedIn && this.signedIn()) {
+          dlog("ext", "auth.signin", { ok: true, reason: "signed-in-elsewhere" });
+          return true;
+        }
+        const r = await this.f(
+          `${this.base}/v1/auth/extension/poll?state=${encodeURIComponent(state)}`);
+        const j = await r.json() as { status?: string; access_token?: string;
+          refresh_token?: string };
+        if (j.access_token) {
+          await this.persistTokens(j.access_token, j.refresh_token);
+          dlog("ext", "auth.signin", { ok: true });
+          try { this.onSignedIn?.(); } catch { /* trigger is best-effort */ }
+          return true;
+        }
+        await new Promise((res) => setTimeout(res, this.pollMs));
+      }
+      if (!wasSignedIn && this.signedIn()) {
+        dlog("ext", "auth.signin", { ok: true, reason: "signed-in-elsewhere" });
+        return true;
+      }
+      dlog("ext", "auth.signin", { ok: false, reason: "timeout" });
+      vscode.window.showErrorMessage(
+        "Vibe-Ads sign-in timed out: no token after polling. " +
+        "Did you complete the Google consent in the browser?");
+      return false;
+    } catch (e) {
+      dlog("ext", "auth.signin", { ok: false, reason: "error" });
+      vscode.window.showErrorMessage(
+        `Vibe-Ads sign-in failed: ${e instanceof Error ? e.message : String(e)}`);
+      return false;
+    }
+  }
+
+  /** Re-mint the access token from the refresh token. Concurrent callers are
+   *  coalesced (single-flight) so the rotating refresh token is consumed at
+   *  most once per cycle. `explicitRt` lets loadCached() hand the just-
+   *  recovered token straight in, bypassing a SecretStorage round-trip that
+   *  may not have landed yet. Never throws. */
+  async refresh(explicitRt?: string): Promise<boolean> {
+    if (this.devBypassEnabled()) {
+      this.at = "dev-bypass";
+      dlog("ext", "auth.refresh", { ok: true, reason: "dev-bypass" });
+      return true;
+    }
+    if (this.refreshInFlight) return this.refreshInFlight;
+    const p = this._refresh(explicitRt);
+    this.refreshInFlight = p;
+    try { return await p; }
+    finally { if (this.refreshInFlight === p) this.refreshInFlight = null; }
+  }
+
+  private async _refresh(explicitRt?: string): Promise<boolean> {
+    // wave-2A-H1 + audit #10: only an EXPLICIT server rejection (401/403 or
+    // an invalid_grant-style body — the session is dead) clears `this.at`,
+    // flipping signedIn() to false at the right moment. A TRANSIENT failure
+    // (network throw, timeout, 5xx) says nothing about token validity:
+    // pre-fix it nulled a possibly-valid access token and — because every
+    // refresh() caller is gated on accessToken() — one offline blip during
+    // the activation-time forced refresh demoted the whole session to demo
+    // (user-credit loss) with zero retry paths. On transient failure we keep
+    // the current tokens and return false; the next caller (60s rotation,
+    // earnings 401 retry) retries naturally. The lost-response rotation race
+    // (server rotated, reply dropped) is covered server-side by the
+    // idempotent reuse-grace window.
+    try {
+      // Token source order: an explicitly-handed token (load-time recovery),
+      // then the fast SecretStorage cache, then the durable sealed file. The
+      // file fallback closes the gap where secrets is empty/stale (reinstall,
+      // keyring-less) but the file still holds a usable — possibly newer —
+      // token (e.g. a prior store() silently failed but sealToFile() landed).
+      let rt = explicitRt || (await this.ctx.secrets.get(R)) || undefined;
+      if (!rt) {
+        const stored = this.readFallback().refresh;
+        if (stored && ENVELOPE.test(stored)) rt = (await this.vault.open(stored)) || undefined;
+        else if (stored) rt = stored;
+      }
+      if (!rt) {
+        // No refresh token anywhere: nothing can ever re-mint this session.
+        this.at = null;
+        dlog("ext", "auth.refresh", { ok: false, reason: "no-token" });
+        return false;
+      }
+      const r = await this.f(`${this.base}/v1/auth/refresh`, {
+        method: "POST", headers: { "content-type": "application/json" },
+        body: JSON.stringify({ refresh_token: rt }),
+      });
+      if (!r.ok) {
+        if (await this.isExplicitRejection(r)) {
+          this.at = null;
+          dlog("ext", "auth.refresh", { ok: false, reason: `http-${r.status}` });
+        } else {
+          // 5xx / 429 / gateway noise: the token may be fine — keep it.
+          dlog("ext", "auth.refresh",
+            { ok: false, reason: `http-${r.status}`, transient: true });
+        }
+        return false;
+      }
+      const j = await r.json() as { access_token?: string; refresh_token?: string };
+      if (!j.access_token) {
+        // A 2xx without a token is a server/proxy anomaly, not a rejection.
+        dlog("ext", "auth.refresh",
+          { ok: false, reason: "no-access-token", transient: true });
+        return false;
+      }
+      // S1 /refresh ROTATES the refresh token (consume + reissue); persist the
+      // new one or the next refresh fails with a consumed token.
+      await this.persistTokens(j.access_token, j.refresh_token);
+      dlog("ext", "auth.refresh", { ok: true, rotated: !!j.refresh_token });
+      return true;
+    } catch {
+      // Network / DNS / timeout: transient — keep the current tokens so a
+      // later refresh can succeed (audit #10).
+      dlog("ext", "auth.refresh",
+        { ok: false, reason: "throw", transient: true });
+      return false;
+    }
+  }
+
+  /** An EXPLICIT auth rejection: the server saw the refresh token and refused
+   *  it. Our backend 401s a consumed/unknown token; 403 and an OAuth-style
+   *  `invalid_grant` 4xx body (defensive — proxies / future backends) also
+   *  count. Anything else non-ok (5xx, 429, gateway noise) is transient and
+   *  must NOT discard tokens. Never throws. */
+  private async isExplicitRejection(r: Response): Promise<boolean> {
+    if (r.status === 401 || r.status === 403) return true;
+    if (r.status >= 500) return false;
+    try {
+      const body = (await r.clone?.().text?.()) ?? "";
+      return /invalid_grant/i.test(body);
+    } catch { return false; }
+  }
+
+  private devBypassEnabled(): boolean {
+    const on = process.env.KICKBACKS_DEV_BYPASS === "1"
+      || process.env.VIBE_ADS_DEV_BYPASS === "1";
+    return on && isLoopbackBase(this.base);
+  }
+}
diff --git a/src/auth/vault.ts b/src/auth/vault.ts
new file mode 100644
index 0000000..6ec6a42
--- /dev/null
+++ b/src/auth/vault.ts
@@ -0,0 +1,174 @@
+import { execFile } from "node:child_process";
+
+/** Cross-platform credential at-rest protection.
+ *
+ *  WHY THIS EXISTS: VS Code `ctx.secrets` is durable on macOS (Keychain) and
+ *  Windows (DPAPI) but a coin-flip on Linux (no Secret Service on headless /
+ *  WSL / devcontainer / code-server / Remote-SSH). Any design that *depends*
+ *  on it is broken on Linux. So the universal floor is a file on disk; this
+ *  module upgrades the at-rest protection of that file's payload using an
+ *  OS-native primitive when one is reachable, with ZERO native npm deps:
+ *
+ *    macOS   → `security` Keychain CLI            (store model)
+ *    Windows → DPAPI via PowerShell ProtectedData (encrypt-blob model)
+ *    Linux   → `secret-tool` (libsecret) if present
+ *    any     → plaintext (the always-available floor)
+ *
+ *  Every operation is best-effort and NEVER throws or blocks (prime
+ *  directive). On ANY failure `seal()` returns a `plain:` envelope so the
+ *  caller still has a durable credential — degraded, never broken. */
+
+export type RunResult = { code: number; stdout: string; stderr: string };
+export type Run = (
+  cmd: string,
+  args: string[],
+  opts?: { input?: string; env?: Record<string, string> },
+) => Promise<RunResult>;
+
+export type VaultScheme = "keychain" | "dpapi" | "libsecret" | "plain";
+
+export interface SecretVault {
+  /** Best guess of the durable scheme for this platform (actual durability is
+   *  re-validated at seal() time — this is for status/diagnostics only). */
+  scheme(): VaultScheme;
+  /** Persist `secret` for `account`; returns an opaque envelope to store in
+   *  the universal file. Never throws. */
+  seal(account: string, secret: string): Promise<string>;
+  /** Recover the secret from an envelope, or null if unrecoverable (entry
+   *  wiped externally, tool missing, garbage). Never throws. */
+  open(envelope: string): Promise<string | null>;
+  /** Best-effort removal of any OS-store-held secret. Never throws. */
+  clear(envelope: string): Promise<void>;
+}
+
+const SERVICE = "vibe-ads";
+const V = "1"; // envelope version
+
+// --- default production runner (never throws; ENOENT => code 127) ----------
+export const defaultRun: Run = (cmd, args, opts) =>
+  new Promise<RunResult>((resolve) => {
+    try {
+      const child = execFile(cmd, args, {
+        timeout: 4000, windowsHide: true, maxBuffer: 1 << 20,
+        env: { ...process.env, ...(opts?.env || {}) },
+      }, (err, stdout, stderr) => {
+        const code = err && typeof (err as { code?: unknown }).code === "number"
+          ? (err as { code: number }).code : err ? 1 : 0;
+        resolve({ code, stdout: String(stdout || ""), stderr: String(stderr || "") });
+      });
+      child.on("error", () => resolve({ code: 127, stdout: "", stderr: "spawn" }));
+      if (opts?.input != null) { try { child.stdin?.end(opts.input); } catch { /* */ } }
+    } catch { resolve({ code: 127, stdout: "", stderr: "throw" }); }
+  });
+
+const stripNL = (s: string): string => s.replace(/\r?\n$/, "");
+const env = (e: string): { s: VaultScheme | string; p: string } | null => {
+  const i1 = e.indexOf(":"); if (i1 < 0) return null;
+  const i2 = e.indexOf(":", i1 + 1); if (i2 < 0) return null;
+  if (e.slice(i1 + 1, i2) !== V) return null; // unknown version => unrecoverable
+  return { s: e.slice(0, i1), p: e.slice(i2 + 1) };
+};
+const plainEnv = (secret: string) => `plain:${V}:${secret}`;
+
+// PowerShell DPAPI (CurrentUser). Secret is passed via env (NOT argv — argv is
+// world-readable in the process list); `Add-Type` covers PS Core where the
+// System.Security assembly isn't auto-loaded.
+const PS_PROTECT =
+  "Add-Type -AssemblyName System.Security;" +
+  "$b=[Text.Encoding]::UTF8.GetBytes($env:VIBE_ADS_SECRET);" +
+  "[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($b,$null,'CurrentUser'))";
+const PS_UNPROTECT =
+  "Add-Type -AssemblyName System.Security;" +
+  "$e=[Convert]::FromBase64String($env:VIBE_ADS_SECRET);" +
+  "[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect($e,$null,'CurrentUser'))";
+
+export function createVault(
+  platform: NodeJS.Platform | string,
+  run: Run = defaultRun,
+): SecretVault {
+  const scheme: VaultScheme =
+    platform === "darwin" ? "keychain" :
+    platform === "win32" ? "dpapi" :
+    platform === "linux" ? "libsecret" : "plain";
+
+  const safe = async <T>(fn: () => Promise<T>, fb: T): Promise<T> => {
+    try { return await fn(); } catch { return fb; }
+  };
+
+  async function sealKeychain(acct: string, secret: string): Promise<string | null> {
+    // NOTE: `security` has no stdin password input; `-w <secret>` is briefly
+    // visible in argv to the SAME user only (sub-second). Accepted vs. the
+    // alternative of plaintext-at-rest. `-U` upserts.
+    const r = await run("security", ["add-generic-password", "-U",
+      "-s", SERVICE, "-a", acct, "-w", secret]);
+    return r.code === 0 ? `keychain:${V}:${acct}` : null;
+  }
+  async function sealDpapi(secret: string): Promise<string | null> {
+    const r = await run("powershell",
+      ["-NoProfile", "-NonInteractive", "-Command", PS_PROTECT],
+      { env: { VIBE_ADS_SECRET: secret } });
+    const ct = stripNL(r.stdout).trim();
+    return r.code === 0 && ct ? `dpapi:${V}:${ct}` : null;
+  }
+  async function sealLibsecret(acct: string, secret: string): Promise<string | null> {
+    const r = await run("secret-tool",
+      ["store", "--label", SERVICE, "service", SERVICE, "account", acct],
+      { input: secret }); // secret via stdin — no argv leak
+    return r.code === 0 ? `libsecret:${V}:${acct}` : null;
+  }
+
+  return {
+    scheme: () => scheme,
+
+    async seal(account, secret) {
+      const got = await safe<string | null>(async () => {
+        if (scheme === "keychain") return sealKeychain(account, secret);
+        if (scheme === "dpapi") return sealDpapi(secret);
+        if (scheme === "libsecret") return sealLibsecret(account, secret);
+        return null;
+      }, null);
+      return got ?? plainEnv(secret); // any failure => universal plaintext floor
+    },
+
+    async open(envelope) {
+      const e = env(envelope);
+      if (!e) return null;
+      if (e.s === "plain") return e.p; // no exec — works even with no tools
+      return safe<string | null>(async () => {
+        if (e.s === "keychain") {
+          const r = await run("security", ["find-generic-password",
+            "-s", SERVICE, "-a", e.p, "-w"]);
+          const v = stripNL(r.stdout);
+          return r.code === 0 && v ? v : null;
+        }
+        if (e.s === "dpapi") {
+          const r = await run("powershell",
+            ["-NoProfile", "-NonInteractive", "-Command", PS_UNPROTECT],
+            { env: { VIBE_ADS_SECRET: e.p } });
+          const v = stripNL(r.stdout);
+          return r.code === 0 && v ? v : null;
+        }
+        if (e.s === "libsecret") {
+          const r = await run("secret-tool",
+            ["lookup", "service", SERVICE, "account", e.p]);
+          const v = stripNL(r.stdout);
+          return r.code === 0 && v ? v : null;
+        }
+        return null; // unknown scheme
+      }, null);
+    },
+
+    async clear(envelope) {
+      const e = env(envelope);
+      if (!e || e.s === "plain") return; // plain: nothing OS-side to remove
+      await safe(async () => {
+        if (e.s === "keychain")
+          await run("security", ["delete-generic-password", "-s", SERVICE, "-a", e.p]);
+        else if (e.s === "libsecret")
+          await run("secret-tool", ["clear", "service", SERVICE, "account", e.p]);
+        // dpapi ciphertext lives only in the file — caller drops the file.
+        return null;
+      }, null);
+    },
+  };
+}
diff --git a/src/banner.ts b/src/banner.ts
new file mode 100644
index 0000000..dd25cbb
--- /dev/null
+++ b/src/banner.ts
@@ -0,0 +1,12 @@
+/** Local debug override for the usage-banner ad. "server" (default) follows
+ *  the backend bannerEnabled flag; "on"/"off" force either way (dev). */
+export type BannerOverride = "server" | "on" | "off";
+
+/** Resolve whether the banner ad renders. Pure; the single source of truth
+ *  for the server-flag ⊕ local-override decision (spec §4.1). */
+export function resolveBannerOn(
+  serverFlag: boolean, override: BannerOverride | undefined): boolean {
+  if (override === "on") return true;
+  if (override === "off") return false;
+  return serverFlag; // "server" or undefined ⇒ follow backend
+}
diff --git a/src/buildflags.ts b/src/buildflags.ts
new file mode 100644
index 0000000..9a52fd2
--- /dev/null
+++ b/src/buildflags.ts
@@ -0,0 +1,46 @@
+// Typed accessors for the build-time constants baked in by esbuild.mjs from
+// the repo-root .env. Every constant is `typeof`-guarded so unbundled
+// callers (vitest, ts-node) never ReferenceError — the same pattern
+// buildinfo.ts uses for __BUILD_TS__ / __BUILD_VERSION__.
+
+declare const __DEVELOPER_MODE__: boolean | undefined;
+declare const __ADMIN_URL__: string | undefined;
+declare const __SITE_URL__: string | undefined;
+declare const __BUILD_VERBOSE__: boolean | undefined;
+declare const __BUILD_CODEX_OPTIN__: boolean | undefined;
+declare const __BUILD_TEST_HOOKS_OPTIN__: boolean | undefined;
+
+/** When true, the status-bar dropdown surfaces dev quick-links (admin portal,
+ *  front-end website). OFF on shipped builds (the repo's .env.example default). */
+export function developerMode(): boolean {
+  return typeof __DEVELOPER_MODE__ === "boolean" ? __DEVELOPER_MODE__ : false;
+}
+
+/** URL opened by the dev-only "Open Admin Portal" item. Empty when unset. */
+export function adminUrl(): string {
+  return typeof __ADMIN_URL__ === "string" ? __ADMIN_URL__ : "";
+}
+
+/** URL opened by the dev-only "Open Front-end Website" item. Empty when unset. */
+export function siteUrl(): string {
+  return typeof __SITE_URL__ === "string" ? __SITE_URL__ : "";
+}
+
+/** Build-time equivalent of `~/.vibe-ads/debug.enabled` / `VIBE_ADS_DEBUG=1`.
+ *  Consumed by log.ts::debugEnabled() so a build can ship pre-enabled without
+ *  touching ~/.vibe-ads/ on the install host. */
+export function verboseBuild(): boolean {
+  return typeof __BUILD_VERBOSE__ === "boolean" ? __BUILD_VERBOSE__ : false;
+}
+
+/** Build-time equivalent of `VIBE_ADS_CODEX=1` / `~/.vibe-ads/codex.enabled`. */
+export function codexBuildOptIn(): boolean {
+  return typeof __BUILD_CODEX_OPTIN__ === "boolean" ? __BUILD_CODEX_OPTIN__ : false;
+}
+
+/** Build-time equivalent of `VIBE_ADS_TEST_HOOKS=1` /
+ *  `~/.vibe-ads/test-hooks.enabled`. NEVER true on shipped builds. */
+export function testHooksBuildOptIn(): boolean {
+  return typeof __BUILD_TEST_HOOKS_OPTIN__ === "boolean"
+    ? __BUILD_TEST_HOOKS_OPTIN__ : false;
+}
diff --git a/src/buildinfo.ts b/src/buildinfo.ts
new file mode 100644
index 0000000..53d8bb0
--- /dev/null
+++ b/src/buildinfo.ts
@@ -0,0 +1,41 @@
+// Build identity. `__BUILD_TS__` is text-substituted by esbuild's `define` at
+// bundle time (see esbuild.mjs). Outside the bundle (vitest, ts-node) it is
+// undefined — guarded with `typeof` so it never ReferenceErrors. This is the
+// ONLY truthful place a *live* relative build age can come from: the VS Code
+// "Installation" panel (Identifier/Version/Source) is rendered by VS Code from
+// the manifest and is NOT author-extensible.
+declare const __BUILD_TS__: string | undefined;
+// Installed extension semver, text-substituted by esbuild's `define` from
+// package.json `version` (see esbuild.mjs). Undefined outside the bundle
+// (vitest/ts-node) — `typeof`-guarded so it never ReferenceErrors.
+declare const __BUILD_VERSION__: string | undefined;
+
+export const BUILD_TS: string =
+  (typeof __BUILD_TS__ === "string" && __BUILD_TS__) || "";
+
+/** Installed extension semver, or a safe "0.0.0" when unbundled so the reload
+ *  decision (sentinel-version vs running-version) degrades to "always differs"
+ *  rather than throwing on an undefined global. */
+export function buildVersion(): string {
+  return (typeof __BUILD_VERSION__ === "string" && __BUILD_VERSION__) || "0.0.0";
+}
+
+/** Compact, non-misleading relative age ("47s ago" / "3h ago" / "2d ago").
+ *  Returns "unknown" for an unparseable/empty stamp rather than a wrong number. */
+export function humanAge(fromIso: string, now: number = Date.now()): string {
+  const t = Date.parse(fromIso);
+  if (!fromIso || Number.isNaN(t)) return "unknown";
+  const s = Math.max(0, Math.floor((now - t) / 1000));
+  if (s < 90) return `${s}s ago`;
+  const m = Math.floor(s / 60);
+  if (m < 90) return `${m}m ago`;
+  const h = Math.floor(m / 60);
+  if (h < 48) return `${h}h ago`;
+  return `${Math.floor(h / 24)}d ago`;
+}
+
+/** "build 2026-05-17T01:11Z (3h ago)" for status surfaces; "build dev" when
+ *  unbundled so a dev run is never misreported as a stale published build. */
+export function buildLabel(now: number = Date.now()): string {
+  return BUILD_TS ? `build ${BUILD_TS} (${humanAge(BUILD_TS, now)})` : "build dev";
+}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..59a6a28
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,117 @@
+// User-editable extension config at ~/.vibe-ads/config.json.
+//
+// Shape (all fields optional):
+//   {
+//     "backendBaseUrl":      "https://kickbacks-backend-gmdaqm2c7q-uw.a.run.app",
+//     "localVsixPath":       "C:/path/to/kickbacks.vsix", // mtime-watched
+//     "updatePollIntervalMs": 90000,                   // remote poll cadence
+//     "debugMode":           false                     // enable dlog writes (debug logging)
+//   }
+//
+// Reads are best-effort: a missing/malformed file resolves to the defaults so
+// activation can never be broken by config. Writes go through ensureFile()
+// which materialises the file with the current effective defaults the first
+// time the user opens the editor.
+
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+
+export interface VibeAdsConfig {
+  backendBaseUrl: string;
+  /** Base URL for the self-update manifest poll. When set, the UpdateClient
+   *  uses this instead of backendBaseUrl — so self-update can point at the
+   *  public site while auth/metrics stay on the local backend. Empty string
+   *  falls through to the env var, then to the compiled-in default. */
+  updateBaseUrl: string;
+  localVsixPath: string;
+  updatePollIntervalMs: number;
+  /** When true, the extension behaves as if `VIBE_ADS_DEBUG=1` or the
+   *  `~/.vibe-ads/debug.enabled` sentinel were present: dlog writes events.
+   *  False (or unset) = off. */
+  debugMode: boolean;
+}
+
+export const DEFAULT_POLL_MS = 90_000;
+
+export function configDir(): string {
+  return join(homedir(), ".vibe-ads");
+}
+export function configPath(): string {
+  return join(configDir(), "config.json");
+}
+
+function defaults(): VibeAdsConfig {
+  return { backendBaseUrl: "", updateBaseUrl: "", localVsixPath: "",
+           updatePollIntervalMs: DEFAULT_POLL_MS, debugMode: false };
+}
+
+export function readConfig(): VibeAdsConfig {
+  const out = defaults();
+  try {
+    const raw = readFileSync(configPath(), "utf8");
+    const j = JSON.parse(raw) as Partial<VibeAdsConfig>;
+    if (typeof j.backendBaseUrl === "string") out.backendBaseUrl = j.backendBaseUrl.trim();
+    if (typeof j.updateBaseUrl === "string") out.updateBaseUrl = j.updateBaseUrl.trim();
+    if (typeof j.localVsixPath === "string") out.localVsixPath = j.localVsixPath.trim();
+    if (typeof j.updatePollIntervalMs === "number" && j.updatePollIntervalMs >= 10_000) {
+      out.updatePollIntervalMs = j.updatePollIntervalMs;
+    }
+    if (typeof j.debugMode === "boolean") out.debugMode = j.debugMode;
+  } catch { /* absent or malformed -> defaults */ }
+  return out;
+}
+
+/** Materialise the config file with current defaults if it doesn't exist.
+ *  Used by the debug "Edit config" entry so the user always opens a real,
+ *  documented file. Returns the absolute path either way. */
+export function ensureConfigFile(): string {
+  const p = configPath();
+  if (!existsSync(p)) {
+    try { mkdirSync(configDir(), { recursive: true }); } catch { /* ok */ }
+    const tmpl = {
+      // Backend / manifest base URL. Empty string -> use VIBE_ADS_BASE env
+      // var, else fall back to production Cloud Run. Used by auth, metrics,
+      // killswitch, earnings, consent.
+      backendBaseUrl: "",
+      // Self-update manifest base URL. Empty -> KICKBACKS_UPDATE_BASE env var
+      // -> public site default. Separates the update path (can be public)
+      // from the API path (may still be localhost during migration).
+      updateBaseUrl: "",
+      // Optional local-source update: an absolute path to a .vsix file. When
+      // set, the extension watches its mtime and installs whenever it changes
+      // — useful for dev rigs without a manifest server. Empty -> disabled.
+      localVsixPath: "",
+      // Remote-manifest poll cadence in ms. Clamped to >= 10s.
+      updatePollIntervalMs: DEFAULT_POLL_MS,
+      // Debug mode. Equivalent to setting `VIBE_ADS_DEBUG=1` or touching
+      // ~/.vibe-ads/debug.enabled: enables dlog writes. Off in prod.
+      debugMode: false,
+    };
+    try { writeFileSync(p, JSON.stringify(tmpl, null, 2) + "\n", "utf8"); }
+    catch { /* best-effort */ }
+  }
+  return p;
+}
+
+const DEFAULT_BACKEND_BASE = "https://kickbacks-backend-gmdaqm2c7q-uw.a.run.app";
+
+/** Resolve the effective backend base URL: config file > env > default.
+ *  Non-loopback HTTP is refused at the call site in extension.ts (this fn
+ *  is pure and side-effect-free so it can be unit-tested without mocking). */
+export function resolveBackendBase(cfg: VibeAdsConfig, env: string | undefined): string {
+  if (cfg.backendBaseUrl) return cfg.backendBaseUrl;
+  if (env) return env;
+  return DEFAULT_BACKEND_BASE;
+}
+
+const DEFAULT_UPDATE_BASE = "https://kickbacks-public-gmdaqm2c7q-uw.a.run.app";
+
+/** Resolve the self-update manifest base URL: config > env > public site.
+ *  Separated from the API base so self-update works over the public internet
+ *  while auth/metrics can still hit the local backend during migration. */
+export function resolveUpdateBase(cfg: VibeAdsConfig, env: string | undefined): string {
+  if (cfg.updateBaseUrl) return cfg.updateBaseUrl;
+  if (env) return env;
+  return DEFAULT_UPDATE_BASE;
+}
diff --git a/src/consent/client.ts b/src/consent/client.ts
new file mode 100644
index 0000000..bb8cae8
--- /dev/null
+++ b/src/consent/client.ts
@@ -0,0 +1,62 @@
+import { timeoutFetch } from "../util/http";
+
+type Fetch = typeof fetch;
+
+export interface ConsentState {
+  telemetryOptIn: boolean;
+  tosAcceptedVersion: string | null;
+  currentTosVersion: string;
+}
+
+/** Backend-facing consent shim (wave-2I-F03). Mirrors the EarningsClient
+ *  fail-safe pattern: any error => null. Never throws into activation. */
+export class ConsentClient {
+  constructor(
+    private base: string,
+    private token: () => string | null,
+    // audit-2026-06-09 #38: was bare `fetch` — the only client whose DEFAULT
+    // bypassed the 2A-01 timeout wrapper. Consent is two tiny calls; 30s is
+    // a generous budget for the user-blocking Agree POST.
+    private f: Fetch = timeoutFetch(30000),
+  ) {}
+
+  async read(): Promise<ConsentState | null> {
+    try {
+      const t = this.token();
+      if (!t) return null;
+      const r = await this.f(`${this.base}/v1/me/consent`,
+        { headers: { authorization: `Bearer ${t}` } });
+      if (!r.ok) return null;
+      const j = await r.json() as {
+        telemetry_opt_in?: boolean;
+        tos_accepted_version?: string | null;
+        current_tos_version?: string;
+      };
+      if (typeof j.current_tos_version !== "string") return null;
+      return {
+        telemetryOptIn: !!j.telemetry_opt_in,
+        tosAcceptedVersion: typeof j.tos_accepted_version === "string"
+          ? j.tos_accepted_version
+          : null,
+        currentTosVersion: j.current_tos_version,
+      };
+    } catch { return null; }
+  }
+
+  async accept(): Promise<{ tosVersion: string; acceptedAt: string } | null> {
+    try {
+      const t = this.token();
+      if (!t) return null;
+      const r = await this.f(`${this.base}/v1/me/consent`,
+        { method: "POST", headers: { authorization: `Bearer ${t}` } });
+      if (!r.ok) return null;
+      const j = await r.json() as {
+        tos_version?: string;
+        accepted_at?: string;
+      };
+      if (typeof j.tos_version !== "string"
+          || typeof j.accepted_at !== "string") return null;
+      return { tosVersion: j.tos_version, acceptedAt: j.accepted_at };
+    } catch { return null; }
+  }
+}
diff --git a/src/consent/prompt.ts b/src/consent/prompt.ts
new file mode 100644
index 0000000..ad856b8
--- /dev/null
+++ b/src/consent/prompt.ts
@@ -0,0 +1,73 @@
+/** First-run consent prompt (wave-2I-F03).
+ *
+ * Surfaces a one-time vscode.window.showInformationMessage AFTER sign-in,
+ * once per (extension instance × tos_version) tuple. Stores "shown for
+ * version X" in globalState so we don't nag the same user on every reload.
+ *
+ * If the user clicks Agree, we POST /v1/me/consent (server stamps the
+ * version). If they click Privacy, we open the privacy doc and leave the
+ * prompt unflagged so it surfaces again next session (gentle, not pushy).
+ * If they dismiss, we set "shown" so we don't nag this session.
+ */
+import type * as vscode from "vscode";
+import type { ConsentClient, ConsentState } from "./client";
+
+const SHOWN_KEY = "vibe-ads.consent.promptShownForVersion";
+const PRIVACY_URL = "https://kickbacks.ai/privacy"; // hosted PRIVACY.md target
+
+export interface ConsentPromptOptions {
+  client: ConsentClient;
+  ctx: vscode.ExtensionContext;
+  vsc: typeof vscode;
+  // dlog factory injected so unit tests can spy
+  dlog?: (msg: string) => void;
+}
+
+export async function maybePromptForConsent(opts: ConsentPromptOptions): Promise<void> {
+  const { client, ctx, vsc, dlog } = opts;
+  // The caller fires this without awaiting (`void maybePromptForConsent(...)`)
+  // — a rejection anywhere below would surface as an unhandledRejection, so
+  // the whole body is guarded. Consent prompting is best-effort by contract.
+  try {
+    const state = await client.read();
+    if (state === null) return; // signed out or backend unreachable; try later
+    // Already consented to the live version → nothing to do.
+    if (state.telemetryOptIn
+        && state.tosAcceptedVersion === state.currentTosVersion) {
+      return;
+    }
+    const shownFor = ctx.globalState.get<string>(SHOWN_KEY);
+    if (shownFor === state.currentTosVersion) {
+      // Already nagged for this version this session/process; defer to next.
+      return;
+    }
+    dlog?.(`consent.prompt show version=${state.currentTosVersion}`);
+    const pick = await vsc.window.showInformationMessage(
+      "Kickbacks shows subtle ads in the Claude Code spinner and splits "
+      + "50/50 of every settled dollar back to you. Telemetry is opt-in. "
+      + "Continue?",
+      { modal: false },
+      "Agree",
+      "Privacy Policy",
+    );
+    if (pick === "Agree") {
+      const result = await client.accept();
+      dlog?.(`consent.prompt accept result=${result ? "ok" : "fail"}`);
+      if (result) {
+        // Mark shown only after successful POST to avoid stranding the user
+        // in a partial-accept state.
+        void ctx.globalState.update(SHOWN_KEY, result.tosVersion);
+      }
+      return;
+    }
+    if (pick === "Privacy Policy") {
+      void vsc.env.openExternal(vsc.Uri.parse(PRIVACY_URL));
+      // Don't mark shown — surface again next session.
+      return;
+    }
+    // Dismissed -> set "shown" for this version so we don't pester.
+    void ctx.globalState.update(SHOWN_KEY, state.currentTosVersion);
+  } catch (e) {
+    dlog?.(`consent.prompt error: ${e instanceof Error ? e.message : String(e)}`);
+  }
+}
diff --git a/src/debug.ts b/src/debug.ts
new file mode 100644
index 0000000..24bf63b
--- /dev/null
+++ b/src/debug.ts
@@ -0,0 +1,649 @@
+import * as vscode from "vscode";
+import type { TargetAdapter, PatchParams } from "./adapters/types";
+import { Loopback,
+  type LoopbackMetricKind, type LoopbackMetricPayload } from "./loopback";
+import { bootLoopback } from "./util/loopbackBoot";
+import { buildLabel, buildVersion, humanAge, BUILD_TS } from "./buildinfo";
+import { dlog, debugEnabled, debugIconDataUri, LOG_PATH } from "./log";
+import { resolveBannerOn, type BannerOverride } from "./banner";
+import { errMsg } from "./util/errMsg";
+import type { SessionSnapshot } from "./sessionState";
+import { reloadSentinelPath, parseSentinel } from "./reloadSignal";
+import {
+  webviewMode, cliMode,
+  bannerOverride as modesBannerOverride, setBannerOverride,
+} from "./modes";
+import { readFileSync } from "node:fs";
+import { ensureConfigFile } from "./config";
+import { canPatch, canServeAds, clearServingSuspension } from "./servingGate";
+
+/** Auth surface the debug menu needs. Injected post-construction because the
+ *  DebugController is built before auth (it must work on incompatible/early-
+ *  return builds where auth never initializes). Absent => menu omits auth. */
+export interface AuthHook {
+  signedIn(): boolean;
+  storageInfo(): { scheme: string; keyringDurable?: boolean };
+  signOut(): Promise<void>;
+}
+
+const K_TEXT = "kickbacks.debug.text";
+const K_ON = "kickbacks.debug.on";
+// Remembers whether injection was ON at the moment of an explicit sign-out so
+// the next sign-in can RESTORE it. doSignOut() forces K_ON=false (a signed-out
+// session must not serve ads), but that false is byte-identical to the false a
+// user writes by clicking "Disable Kickbacks". Without this flag the sign-in
+// auto-enable gate (neverToggled) stays false forever after the first sign-out,
+// so signing back in left the user silently disabled — see
+// shouldAutoEnableOnSignIn(). Undefined ⇒ "no remembered sign-out intent".
+const K_PRESIGNOUT = "kickbacks.debug.onBeforeSignOut";
+// Legacy storage keys read once on init for the rename migration (W1). New
+// state always lands under the kickbacks.* keys; the vibe-ads.* keys are
+// kept readable (never deleted) so a downgrade keeps working.
+const K_TEXT_LEGACY = "vibe-ads.debug.text";
+const K_ON_LEGACY = "vibe-ads.debug.on";
+const DEFAULT_TEXT = "Your ad here — kickbacks.ai";
+const DEFAULT_CLICK = "https://kickbacks.ai";
+/** Click-threshold floor for the DEBUG-injection path. Mirrors
+ *  CLICK_THRESHOLD_MS in extension.ts (kept local to avoid an
+ *  extension→debug import cycle). 15s per product call. */
+const DEBUG_CLICK_THRESHOLD_MS = 15_000;
+
+/** Menu info-row label: "v0.3.79 · 1m ago · 7:42 PM".
+ *  Version comes from the bundle define; relative age + local-time clock
+ *  are computed from __BUILD_TS__. Returns "v… · dev build" when running
+ *  unbundled (vitest/ts-node) so the menu never lies. Delegates the
+ *  relative-age computation to buildinfo.ts::humanAge(). */
+function builtAgo(now: number = Date.now()): string {
+  const ver = buildVersion();
+  const ts = BUILD_TS;
+  const t = Date.parse(ts);
+  if (!ts || Number.isNaN(t)) return `v${ver} · dev build`;
+  const ago = humanAge(ts, now);
+  let clock: string;
+  try {
+    clock = new Date(t).toLocaleTimeString(undefined,
+      { hour: "numeric", minute: "2-digit", hour12: true });
+  } catch { clock = ""; }
+  return clock ? `v${ver} · ${ago} · ${clock}` : `v${ver} · ${ago}`;
+}
+
+/** Sender wired post-construction so the debug-injection overlay's
+ *  impressions / views / clicks reach the real backend ledger via the same
+ *  MetricsClient that the production ad path uses. Default is a no-op so
+ *  builds without the wiring (older tests, Codex-only paths) keep the
+ *  prior "visual-only" semantics. The DebugController only knows the
+ *  loopback shape; the actual MetricsClient + portfolio resolution lives in
+ *  extension.ts. */
+export type DebugMetricSender = (
+  kind: LoopbackMetricKind | "click",
+  payload: LoopbackMetricPayload & { surface?: LoopbackMetricPayload["surface"] },
+) => void;
+
+/** Manual admin/debug override driven from the status bar. Lets an operator
+ *  set a custom spinner message and toggle the injection on/off without the
+ *  server-driven ad path. Never throws (prime directive); patch failures are
+ *  surfaced as messages, not exceptions. */
+export class DebugController {
+  private lb: Loopback | null = null;
+  private auth: AuthHook | null = null;
+  private reassertFn: (() => void) | null = null;
+  private reassertCodexFn: (() => void) | null = null;
+  /** Real-ledger forwarder for debug-mode events. Defaults to a no-op so
+   *  the controller is fully usable on early/incompatible builds where the
+   *  metrics client + portfolio context aren't ready yet. extension.ts wires
+   *  the real sender right after constructing MetricsClient. */
+  private metricsSender: DebugMetricSender = () => {};
+  private portfolioAd: { text: string; clickUrl: string } | null = null;
+  /** S9: the Codex target, patched alongside CC on the DEBUG-injection path
+   *  (the production server-ad path wires Codex separately in extension.ts).
+   *  Null when no Codex install is present. */
+  private codexAdapter: TargetAdapter | null = null;
+  /** Remember the last params apply() built so `cyclePatch()` can re-
+   *  apply the SAME patch without going through apply() (which would
+   *  tear down + re-mint the loopback, introducing a ~250ms async
+   *  window where the file is pristine). Sync restore + applyPatch
+   *  with these params is microseconds-fast and never yields. */
+  private lastApplyParams: PatchParams | null = null;
+  /** Snapshot accessor for the cross-component session state. Wired
+   *  post-construction by extension.ts after SessionState exists. Used
+   *  by `openMenu()` to flip the auth row to "Sign in (token broken)"
+   *  when the backend has rejected our access token — pre-fix the menu
+   *  read auth.signedIn() (truthy as long as the token was in-memory)
+   *  and showed "Sign out" even when every call 401'd. */
+  private sessionSnap: (() => SessionSnapshot) | null = null;
+  /** Wired post-construction by extension.ts so menu toggles take effect
+   *  without waiting for the next activation/timer. */
+  setReassert(fn: () => void): void { this.reassertFn = fn; }
+  /** S9: the Codex-only re-apply trigger (guarded inside extension.ts). */
+  setReassertCodex(fn: () => void): void { this.reassertCodexFn = fn; }
+  /** S9: wire the Codex adapter so the debug toggle patches it too. */
+  setCodexAdapter(a: TargetAdapter | null): void { this.codexAdapter = a; }
+  /** Wire the session snapshot accessor; safe to call multiple times. */
+  setSessionSnap(fn: () => SessionSnapshot): void { this.sessionSnap = fn; }
+
+  /** S9: apply/restore Codex with the SAME params CC just used on the debug
+   *  path. Preflight-gated and fully guarded — an absent/incompatible Codex
+   *  or any throw is a silent no-op that never affects CC or the menu.
+   *  Returns `true` iff the Codex patch actually landed on disk; callers
+   *  use this so a Codex-only environment (no CC install) can still
+   *  legitimately persist K_ON=true. */
+  private applyCodexDebug(p: PatchParams): boolean {
+    if (!this.codexAdapter) return false;
+    try {
+      const pf = this.codexAdapter.preflight();
+      if (!pf.compatible) {
+        dlog("ext", "debug.codex.skip", { reason: pf.reason });
+        return false;
+      }
+      const r = this.codexAdapter.applyPatch(p);
+      dlog("ext", "debug.codex.apply", { ok: r.ok, reason: r.reason });
+      return !!r.ok;
+    } catch (e) {
+      dlog("ext", "debug.codex.error",
+        { msg: errMsg(e) });
+      return false;
+    }
+  }
+  private restoreCodexDebug(): void {
+    try { this.codexAdapter?.restore(); } catch { /* prime directive */ }
+  }
+
+  constructor(
+    private readonly adapter: TargetAdapter,
+    private readonly ctx: vscode.ExtensionContext,
+    private readonly onState: (on: boolean) => void,
+  ) {}
+
+  /** Wire auth in once it exists (post-preflight). */
+  setAuth(a: AuthHook): void { this.auth = a; }
+  /** Wire the real-ledger forwarder. Called by extension.ts after
+   *  MetricsClient + portfolio context exist. Idempotent. */
+  setMetricsSender(fn: DebugMetricSender): void { this.metricsSender = fn; }
+  setPortfolioAd(text: string, clickUrl: string): void {
+    this.portfolioAd = { text, clickUrl };
+  }
+
+  // Read kickbacks.* first, fall back to the legacy vibe-ads.* keys so an
+  // upgrade carries forward the user's custom message + ON state. We never
+  // delete the legacy key — a downgrade keeps working.
+  text(): string {
+    return this.ctx.globalState.get<string>(K_TEXT)
+      || this.ctx.globalState.get<string>(K_TEXT_LEGACY)
+      || this.portfolioAd?.text
+      || DEFAULT_TEXT;
+  }
+  on(): boolean {
+    const cur = this.ctx.globalState.get<boolean>(K_ON);
+    if (typeof cur === "boolean") return cur;
+    return this.ctx.globalState.get<boolean>(K_ON_LEGACY) ?? false;
+  }
+  neverToggled(): boolean {
+    return this.ctx.globalState.get<boolean>(K_ON) === undefined
+      && this.ctx.globalState.get<boolean>(K_ON_LEGACY) === undefined;
+  }
+
+  /** Tiered auto-enable decision for the sign-in path. True ⇒ injection should
+   *  be (re-)enabled on a successful sign-in:
+   *    Tier 1 — the user has never made an explicit on/off choice
+   *             (neverToggled): first-run default-on.
+   *    Tier 2 — injection was ON at the last explicit sign-out (K_PRESIGNOUT):
+   *             signing out is a pause, not a sticky disable, so signing back
+   *             in restores the prior state.
+   *  A deliberate "Disable Kickbacks" while signed in falls through BOTH tiers
+   *  (the user wrote K_ON=false themselves, and a subsequent sign-out captures
+   *  that OFF into K_PRESIGNOUT), so an intentional disable is preserved across
+   *  a later sign-out/in. */
+  shouldAutoEnableOnSignIn(): boolean {
+    if (this.neverToggled()) return true;
+    return this.ctx.globalState.get<boolean>(K_PRESIGNOUT) === true;
+  }
+
+  /** One-shot: consume the remembered pre-sign-out intent once the sign-in
+   *  path has acted on it, so a stale flag can't override a later choice. */
+  async clearSignOutMemory(): Promise<void> {
+    await this.ctx.globalState.update(K_PRESIGNOUT, undefined);
+  }
+  bannerOverride(): BannerOverride { return modesBannerOverride(); }
+  async cycleBannerOverride(): Promise<void> {
+    const next: Record<BannerOverride, BannerOverride> =
+      { server: "on", on: "off", off: "server" };
+    setBannerOverride(next[this.bannerOverride()]);
+    if (this.on()) await this.apply();   // live re-apply if injection is on
+  }
+
+  /** QuickPick shown when the status bar item is clicked.
+   *
+   *  Menu shape:
+   *    0. GET PAID OUT $$$ (opens the user's earnings portal — always first)
+   *    1. Sign in / Sign out (auth flip; omitted if auth never initialised)
+   *    2. Enable / Disable Kickbacks (the renamed toggle)
+   *    3. Edit Vibe-Ads config… (opens ~/.vibe-ads/config.json)
+   *    4. Re-apply patch now (CC + Codex consolidated)
+   *    5. Restart extensions now
+   *    6. Restore Claude Code
+   *    7. Open debug log
+   *    8. ─── separator ───
+   *    9. "Built Xm ago" (info row, click is a no-op)
+   *
+   *  Removed: "Set custom message" (rolled into config.json). Other power-
+   *  user commands (Copy diagnostics, Webview injection, CLI status-line,
+   *  Banner ad cycle, Show status) remain accessible via the command
+   *  palette. */
+  async openMenu(): Promise<void> {
+    try {
+      const on = this.on();
+      const snap = this.sessionSnap?.();
+      // Prefer the SessionState's view over auth.signedIn() when both
+      // exist. Distinguishes "signed in and healthy" from "signed in but
+      // backend keeps 401'ing" — the second case used to show the
+      // misleading "Sign out" label and forced users to dig through
+      // debug.log to realise the token had been revoked.
+      const si = snap
+        ? snap.signedIn && snap.authHealthy !== "401"
+        : (this.auth?.signedIn() ?? false);
+      const authBroken = !!snap && snap.signedIn && snap.authHealthy === "401";
+      // Single row, flips between Sign in (signed out) and Sign out
+      // (signed in). The "token broken" path shows "Sign in again" with
+      // a warning description so the user can recover in one click.
+      const authItem = this.auth
+        ? authBroken
+          ? { id: "signin",
+              label: "$(warning) Sign in again to Kickbacks",
+              description: "your session expired — backend rejecting calls" }
+          : si
+            ? { id: "signout", label: "$(sign-out) Sign out of Kickbacks" }
+            : { id: "signin", label: "$(sign-in) Sign in to Kickbacks" }
+        : null;
+      const items: ({ id: string; label: string; description?: string;
+                     kind?: vscode.QuickPickItemKind })[] = [
+        // Always the very top row — the payout portal is the product's whole
+        // promise, so it outranks even the auth flip.
+        { id: "getpaid", label: "$(credit-card) GET PAID OUT $$$",
+          description: "your earnings portal — kickbacks.ai/me" },
+        ...(authItem ? [authItem] : []),
+        { id: "toggle",
+          label: on ? "$(circle-slash) Disable Kickbacks"
+                    : "$(megaphone) Enable Kickbacks",
+          description: on ? "currently ON" : "currently OFF" },
+        { id: "config", label: "$(json) Edit Vibe-Ads config…",
+          description: "~/.vibe-ads/config.json" },
+        { id: "reapply", label: "$(sync) Re-apply patch now",
+          description: "Claude Code + Codex" },
+        { id: "checkupdates", label: "$(cloud-download) Check for updates",
+          description: "force a self-update poll" },
+        { id: "reload", label: "$(refresh) Restart extensions now" },
+        { id: "restore", label: "$(history) Restore Claude Code" },
+        { id: "openlog", label: "$(output) Open debug log" },
+        { id: "__sep", label: "", kind: vscode.QuickPickItemKind.Separator },
+        { id: "builtinfo", label: `$(info) ${builtAgo()}`,
+          description: buildLabel() },
+      ];
+      const pick = await vscode.window.showQuickPick(items as never, {
+        placeHolder: "Kickbacks",
+      }) as { id: string } | undefined;
+      if (!pick) return;
+      if (pick.id === "getpaid")
+        await vscode.env.openExternal(
+          vscode.Uri.parse("https://kickbacks.ai/me"));
+      else if (pick.id === "toggle") await this.setOn(!on);
+      else if (pick.id === "config") await this.editConfig();
+      else if (pick.id === "restore") await this.doRestore();
+      else if (pick.id === "signin")
+        await vscode.commands.executeCommand("kickbacks.signIn");
+      else if (pick.id === "signout")
+        await vscode.commands.executeCommand("kickbacks.signOut");
+      else if (pick.id === "reload")
+        await vscode.commands.executeCommand(
+          "workbench.action.restartExtensionHost");
+      else if (pick.id === "reapply") {
+        // Consolidated re-apply: CC first, then Codex via the production
+        // server-ad path AND the debug-injection path. Both are guarded
+        // no-ops if their adapter isn't installed.
+        this.reassertFn?.();
+        this.reassertCodexFn?.();
+        if (this.on()) await this.apply();
+        vscode.window.showInformationMessage(
+          this.on()
+            ? "Kickbacks: re-applied (Claude Code + Codex)."
+            : "Kickbacks: re-apply triggered (no-op — injection OFF).");
+      }
+      else if (pick.id === "checkupdates")
+        await vscode.commands.executeCommand("kickbacks.checkUpdates");
+      else if (pick.id === "openlog")
+        await vscode.commands.executeCommand(
+          "vscode.open", vscode.Uri.file(LOG_PATH));
+      // "builtinfo" is intentionally a no-op; it's a labelled-only info row.
+    } catch { /* prime directive */ }
+  }
+
+  private async editMessage(): Promise<void> {
+    const v = await vscode.window.showInputBox({
+      prompt: "Custom spinner ad text (≈30–40 chars renders best)",
+      value: this.text(),
+    });
+    if (v === undefined) return;
+    await this.setText(v);
+  }
+
+  /** Materialise ~/.vibe-ads/config.json with defaults (if missing) and open
+   *  it in the editor. Saving the file triggers the watcher in extension.ts
+   *  which restarts the extension host so the new values take effect.
+   *  Public so the `kickbacks.editConfig` command can call it directly. */
+  async editConfig(): Promise<void> {
+    try {
+      const p = ensureConfigFile();
+      const doc = await vscode.workspace.openTextDocument(p);
+      await vscode.window.showTextDocument(doc, { preview: false });
+    } catch (e) {
+      vscode.window.showErrorMessage(
+        `Kickbacks: could not open config — ${errMsg(e, 200)}`);
+    }
+  }
+
+  /** Persist a custom message; live re-apply if injection is currently on. */
+  async setText(t: string): Promise<void> {
+    await this.ctx.globalState.update(K_TEXT, t);
+    if (this.on()) await this.apply();
+  }
+
+  async setOn(on: boolean): Promise<void> {
+    // wave-2A-F07: if turning ON, apply BEFORE persisting K_ON. Pre-fix,
+    // K_ON was persisted first, so a failed apply() left K_ON=true on
+    // disk -> every subsequent activation re-ran apply() (which still
+    // failed) -> re-toasted the same error forever. By applying first
+    // and only persisting on success, a failed apply leaves K_ON in its
+    // prior value (typically false) and the toast happens once, not
+    // per-activation.
+    if (on) {
+      // An explicit user enable lifts the crash-canary suspension (wave 2,
+      // audit #14) — this is the "manually re-enable" the canary toast
+      // promises, and the only thing that un-suspends a session.
+      clearServingSuspension();
+      const ok = await this._applyAndReport();
+      if (!ok) return; // do NOT persist K_ON=true; user can retry manually
+      await this.ctx.globalState.update(K_ON, true);
+    } else {
+      await this.ctx.globalState.update(K_ON, false);
+      // A deliberate disable overrides any remembered sign-out intent: the
+      // serving gate (and the next sign-in's auto-enable) must read this as
+      // "the user opted out", even when a K_PRESIGNOUT=true from an earlier
+      // sign-out is still lying around (wave 2, audit #4).
+      await this.ctx.globalState.update(K_PRESIGNOUT, undefined);
+      this.adapter.restore();
+      this.restoreCodexDebug();
+    }
+    this.onState(on);
+  }
+
+  private async _applyAndReport(): Promise<boolean> {
+    try {
+      return await this.apply();
+    } catch (e) {
+      // apply() shows its own error toast; we only need the success signal
+      // to know whether to persist K_ON.
+      void e;
+      return false;
+    }
+  }
+
+  /** Re-assert the patch on a fresh activation/reload if injection was left
+   *  ON in a previous session.
+   *
+   *  ROOT CAUSE of the user's "I have to disable then re-enable to get it to
+   *  inject" bug: `setOn(true)` persists `K_ON` in globalState AND patches the
+   *  file, but `deactivate()` unconditionally calls `adapter.restore()` (best-
+   *  effort "never leave a user patched"). So on the NEXT window reload the
+   *  on-disk `index.js` is pristine again, the webview reloads clean, and
+   *  nothing re-applies it: `activate()` never read `K_ON`, and `apply()` is
+   *  private + only reachable via an explicit user toggle. The persisted ON
+   *  state was therefore inert until a manual off→on. Idempotent re-apply is
+   *  a hard project constraint, so activation must re-assert it itself.
+   *
+   *  Guarded (prime directive): never throws; reports honestly via dlog. */
+  async reapplyIfOn(): Promise<{ on: boolean; applied: boolean; reason?: string }> {
+    let on = false;
+    try {
+      on = this.on();
+      if (!on) {
+        dlog("ext", "reapply", { on: false, applied: false, reason: "off" });
+        return { on: false, applied: false, reason: "off" };
+      }
+      // Serving gate (wave 2): a persisted/confirmed kill, the offline
+      // freeze, or a crash-canary suspension blocks this AUTOMATIC re-apply
+      // (boot canary, sign-in trigger). Manual setOn(true) is not gated.
+      if (!canPatch()) {
+        dlog("ext", "reapply",
+          { on: true, applied: false, reason: "serving-gate" });
+        return { on: true, applied: false, reason: "serving-gate" };
+      }
+      await this.apply();
+      // apply() reports patch failures via showErrorMessage but does not throw;
+      // surface the same signal here for the e2e by re-checking didn't throw.
+      dlog("ext", "reapply", { on: true, applied: true });
+      this.onState(true);
+      return { on: true, applied: true };
+    } catch (e) {
+      const reason = errMsg(e, 200);
+      dlog("ext", "reapply", { on, applied: false, reason });
+      return { on, applied: false, reason };
+    }
+  }
+
+  /** Boot-time disable → reenable cycle. The webview can hold a cached
+   *  pre-patch module after a reinstall / self-update even though the
+   *  on-disk file IS patched (block.desync); changing the file's identity
+   *  via restore + applyPatch sometimes nudges VS Code to re-evaluate.
+   *  Mirrors the manual "Disable Kickbacks → Enable Kickbacks" the user
+   *  used to run by hand. Sync-fast (microseconds between the two
+   *  renames) so the pristine window can't race CC's boot imports —
+   *  unlike the earlier setTimeout(4s) forceReapplyCycle (commit 505f777)
+   *  which had a 250ms async gap. Guarded; never throws. Skipped when
+   *  K_ON is false (nothing to recycle) or when no prior apply() has
+   *  recorded its params yet (first activation hasn't patched). */
+  cyclePatch(): { ok: boolean; reason?: string } {
+    try {
+      if (!this.on()) return { ok: false, reason: "off" };
+      // Serving gate (wave 2): the boot-time cycle and the desync watchdog
+      // must never restore+re-patch a killed / frozen / suspended install.
+      if (!canPatch()) return { ok: false, reason: "serving-gate" };
+      if (!this.lastApplyParams)
+        return { ok: false, reason: "no-params" };
+      // Sync restore (rename backup → main) then sync applyPatch
+      // (rename temp → main). No await between; the pristine state
+      // exists only for the time between two filesystem renames.
+      this.adapter.restore();
+      const r = this.adapter.applyPatch(this.lastApplyParams);
+      // S9: same cycle for Codex (silent no-op if absent / incompatible).
+      try {
+        if (this.codexAdapter) {
+          const cpf = this.codexAdapter.preflight();
+          if (cpf.compatible) {
+            this.codexAdapter.restore();
+            this.codexAdapter.applyPatch(this.lastApplyParams);
+          }
+        }
+      } catch { /* prime directive */ }
+      dlog("ext", "debug.cyclePatch", { ok: r.ok, reason: r.reason });
+      return r;
+    } catch (e) {
+      const reason = errMsg(e);
+      dlog("ext", "debug.cyclePatch.error", { reason });
+      return { ok: false, reason };
+    }
+  }
+
+  /** Periodic self-heal for the debug-injection path. `reapplyIfOn()` only
+   *  runs once at activation; if that fired before Claude Code's file was
+   *  patchable, or CC later overwrote index.js, the injection silently
+   *  vanished and the user had to toggle off→on by hand. This tick re-applies
+   *  whenever injection is ON but the patch is no longer present — and is a
+   *  cheap no-op (one file read, no loopback churn) while healthy. Guarded;
+   *  never throws. */
+  async reassertTick(): Promise<void> {
+    try {
+      if (!this.on()) return;
+      // Serving gate (wave 2, audit #6): pre-fix this unconditional 60s tick
+      // re-patched a killed install right after checkKill restored it — the
+      // restore→re-patch oscillation. Killed/offline/suspended ⇒ no writes.
+      if (!canPatch()) return;
+      // Healthy only when CC AND (no Codex install | Codex also patched) —
+      // so a Codex-only drift (CC fine) still self-heals via apply().
+      const ccOk = this.adapter.isPatched?.() === true;
+      let cxOk = true;
+      try { cxOk = !this.codexAdapter || this.codexAdapter.isPatched?.() === true; }
+      catch { cxOk = true; }
+      if (ccOk && cxOk) return;                          // healthy → no churn
+      await this.apply();
+      dlog("ext", "reassert.debug", { applied: true });
+    } catch (e) {
+      dlog("ext", "reassert.debug", { applied: false,
+        reason: errMsg(e) });
+    }
+  }
+
+  private async doRestore(): Promise<void> {
+    const r = this.adapter.restore();
+    this.restoreCodexDebug();
+    await this.ctx.globalState.update(K_ON, false);
+    // A menu "Restore Claude Code" is a deliberate disable, same as
+    // setOn(false): clear the sign-out memory too, or a stale
+    // K_PRESIGNOUT=true keeps the serving gate's enabled() input true and
+    // the 60s reassert re-patches the just-restored install within a minute.
+    await this.ctx.globalState.update(K_PRESIGNOUT, undefined);
+    this.onState(false);
+    vscode.window.showInformationMessage(
+      r.restored ? "Kickbacks: Claude Code restored."
+                 : `Kickbacks: ${r.reason || "nothing to restore"}`);
+  }
+
+  /** Sign out + leave Claude Code pristine. A signed-out session must not keep
+   *  serving ads, so this also forces injection OFF and restores CC (same
+   *  end-state as the kill switch). Never throws. */
+  async doSignOut(): Promise<void> {
+    try {
+      // Capture the pre-sign-out injection intent BEFORE we force K_ON=false
+      // below, so the next sign-in can restore it (see K_PRESIGNOUT /
+      // shouldAutoEnableOnSignIn). This is what makes sign-out a pause rather
+      // than a permanent disable.
+      await this.ctx.globalState.update(K_PRESIGNOUT, this.on());
+      if (this.auth) await this.auth.signOut();
+      this.adapter.restore();
+      this.restoreCodexDebug();
+      await this.ctx.globalState.update(K_ON, false);
+      this.onState(false);
+      vscode.window.showInformationMessage(
+        "Kickbacks: signed out. Claude Code restored.");
+    } catch { /* prime directive */ }
+  }
+
+  private async apply(): Promise<boolean> {
+    // ONE Loopback per controller lifetime (audit #7). Pre-fix every apply()
+    // stopped + re-minted a fresh server; under the shared-server boot (see
+    // bootLoopback) that re-mint would tear down the single server the
+    // production webview traffic runs on. The handlers close over instance
+    // fields (metricsSender / portfolioAd), so reuse keeps every route live
+    // across applies, and the stable token+port keep baked URLs valid.
+    if (!this.lb) this.lb = new Loopback({
+      // Forward impression / view / view-threshold events to the real
+      // ledger so debug-mode traffic is indistinguishable from a served
+      // ad. The sender closure (wired by extension.ts) handles
+      // adId/campaignId resolution against the current portfolio.
+      onEvent: (k, payload) => { this.metricsSender(k, payload); },
+      // Metrics-only: the injected ad anchor's real href (DEFAULT_CLICK) is
+      // what the VS Code host opens on click. openExternal here too would
+      // double-open. Forwarding click to the ledger via the same sender
+      // means the entry shows up alongside the impression chain.
+      // Click-threshold floor mirrors the production path: a click in the
+      // first DEBUG_CLICK_THRESHOLD_MS of cumulative visible time is
+      // logged but not billed. Browser navigation still happens (the
+      // host already opened the URL by the time we get this callback).
+      onClick: (_ct, surface, visibleMs, eventUuid) => {
+        dlog("ext", "debug.click",
+          { url: DEFAULT_CLICK, surface, visibleMs, eventUuid });
+        if (typeof visibleMs === "number"
+            && visibleMs < DEBUG_CLICK_THRESHOLD_MS) {
+          dlog("ext", "debug.click.early",
+            { visibleMs, thresholdMs: DEBUG_CLICK_THRESHOLD_MS, eventUuid });
+          return;
+        }
+        this.metricsSender("click", {
+          ...(surface ? { surface } : {}),
+          ...(eventUuid ? { eventUuid } : {}),
+        });
+      },
+      getActivity: () => ({}),
+      // Gate /ad (wave 2, audit #3): a confirmed kill or a deliberate
+      // disable stops handing out ads — live webviews see the same empty
+      // payload as the no-inventory state and drop their overlays within
+      // one 10s poll. /activity and /log relay stay untouched.
+      getCurrentAd: () => canServeAds() && this.portfolioAd
+        ? { adText: this.portfolioAd.text, clickUrl: this.portfolioAd.clickUrl,
+            // Icon-less by default; the e2e icon override (data: URI only)
+            // lets the closed-loop harness screenshot-verify the custom-icon
+            // surface. Empty in production -> "K" fallback, unchanged.
+            iconUrl: debugIconDataUri(), adId: "", campaignId: "" }
+        : null,
+    });
+    // Reuse the persistent token + preferred port stored in globalState, and
+    // register SECONDARY (audit #7): when the production loopback is (or
+    // later comes) up, this returns the SAME shared server/port instead of
+    // EADDRINUSE-ing onto stablePort+1, and the debug stub's handlers never
+    // displace the production wiring. See bootLoopback() for details.
+    const { port, token, base: lbBase } =
+      await bootLoopback(this.lb, this.ctx, { secondary: true });
+    const params: PatchParams = {
+      tier: 3, adText: this.text(), iconRef: "", iconUrl: debugIconDataUri(),
+      clickToken: "debug", clickUrl: this.portfolioAd?.clickUrl || DEFAULT_CLICK, corr: "debug." + token.slice(0, 6),
+      loopbackPort: port,
+      loopbackToken: token, loopbackBase: lbBase, debug: debugEnabled(),
+      bannerOn: resolveBannerOn(false, this.bannerOverride()),
+    };
+    this.lastApplyParams = params;
+    const res = this.adapter.applyPatch(params);
+    dlog("ext", "debug.apply", { ok: res.ok, reason: res.reason, base: lbBase });
+    // S9: patch Codex with the SAME debug params/loopback (guarded; never
+    // blocks CC). This is what makes the ad show in Codex on the toggle path.
+    const codexOk = this.applyCodexDebug(params);
+    // wave-2A-F07 + Codex-only fix: succeed if EITHER target patched. The
+    // CC-only success signal previously stranded Codex-only installs
+    // (no CC extension on disk): apply() returned false, setOn refused
+    // to persist K_ON=true, the menu permanently read "Enable
+    // Kickbacks" even though the Codex shimmer file WAS patched, and
+    // every subsequent toggle no-op'd because the menu state ⇄ disk
+    // state diverged. Treat CC's "target not found" as a NON-ERROR
+    // when Codex patched (the user clearly has Codex without CC), but
+    // still surface a hard error when CC IS present and applyPatch
+    // failed for a real reason.
+    const ccPresent = res.reason !== "target not found";
+    if (!res.ok && ccPresent)
+      vscode.window.showErrorMessage(`Kickbacks: patch failed — ${res.reason}`);
+    return !!res.ok || codexOk;
+  }
+
+  private diagnostics(): string {
+    let sentinel = "n/a";
+    try {
+      const s = parseSentinel(readFileSync(reloadSentinelPath(), "utf8"));
+      if (s) sentinel = `${s.version}@${s.ts || "?"}`;
+    } catch { /* absent */ }
+    const st = this.auth?.storageInfo();
+    const authLine = this.auth
+      ? (this.auth.signedIn() ? "in" : "out")
+      : "n/a";
+    return [
+      "Kickbacks diagnostics",
+      buildLabel(),
+      `auth: ${authLine}`,
+      `injection(K_ON): ${this.on() ? "ON" : "OFF"}`,
+      `store: ${st ? st.scheme : "n/a"}`,
+      `debug: ${debugEnabled() ? "on" : "off"}`,
+      `webview: ${webviewMode()}  cli: ${cliMode()}  banner: ${this.bannerOverride()}`,
+      `reload-sentinel: ${sentinel}`,
+      `text: "${this.text()}"`,
+    ].join("\n");
+  }
+
+  async dispose(): Promise<void> {
+    if (this.lb) { await this.lb.stop(); this.lb = null; }
+  }
+}
diff --git a/src/earnings/client.ts b/src/earnings/client.ts
new file mode 100644
index 0000000..5193264
--- /dev/null
+++ b/src/earnings/client.ts
@@ -0,0 +1,113 @@
+import { timeoutFetch } from "../util/http";
+
+type Fetch = typeof fetch;
+
+/** Which earning ceiling the user has hit (server-authoritative, tiered by
+ *  account verification). Drives the red cap-warning status-bar pill. */
+export interface EarningCap {
+  scope: "hourly" | "daily";
+  capUsd: string;
+  resetSeconds: number;
+}
+
+export interface Earnings {
+  lifetimeUsd: string;
+  todayUsd: string;
+  // Present only while a cap is hit; absent/undefined means "under both
+  // ceilings" OR an older backend that doesn't send the field (back-compat).
+  cap?: EarningCap | null;
+}
+
+/** Defensive parse of the optional `cap` field from /v1/earnings. Returns a
+ *  well-typed EarningCap, or undefined for null / missing / malformed input —
+ *  a bad cap must never poison the earnings readout (the bar just won't warn).
+ *  Exported for direct unit testing. */
+export function parseCap(raw: unknown): EarningCap | undefined {
+  if (!raw || typeof raw !== "object") return undefined;
+  const c = raw as { scope?: unknown; cap_usd?: unknown; reset_seconds?: unknown };
+  if ((c.scope !== "hourly" && c.scope !== "daily")
+      || typeof c.cap_usd !== "string"
+      || typeof c.reset_seconds !== "number"
+      || !Number.isFinite(c.reset_seconds))
+    return undefined;
+  return { scope: c.scope, capUsd: c.cap_usd,
+           resetSeconds: Math.max(0, Math.floor(c.reset_seconds)) };
+}
+
+/** Optional auth-recovery callback. When the first GET /v1/earnings
+ *  returns 401, the client calls this to refresh the access token, then
+ *  retries the request exactly once with the new bearer. Without this,
+ *  a transient 401 (token rotated mid-poll) leaves the status bar's
+ *  `lastUsd`/`lastToday` stale OR blank on the very first poll. */
+export type EarningsAuthRecovery = () => Promise<boolean>;
+
+/** GET /v1/earnings — the user's display-only 50/50 credit (today + lifetime),
+ *  for the status bar. Fail-safe: any error / signed-out => null; the status
+ *  bar then renders $0.00 (it never shows a bare label and never throws). */
+export class EarningsClient {
+  private f: Fetch;
+  constructor(private base: string, private token: () => string | null,
+              f: Fetch = timeoutFetch(15000),
+              private onAuth401: EarningsAuthRecovery | null = null) {
+    // audit-2026-06-09 #38: extension.ts passes bare global `fetch`
+    // positionally (only to reach the onAuth401 arg), silently bypassing
+    // the timeout default above. Re-wrap that one case so a black-holed
+    // connection still aborts; injected test/custom fetches are untouched.
+    this.f = f === globalThis.fetch ? timeoutFetch(15000) : f;
+  }
+
+  async fetch(): Promise<Earnings | null> {
+    const r = await this.fetchDetailed();
+    return r.outcome === "ok" ? r.earnings : null;
+  }
+
+  /** audit-2026-06-09 #34: like fetch() but preserves the failure KIND so
+   *  callers can distinguish a real backend 401 (session expired) from a
+   *  transient network / 5xx / malformed-body failure. "401" is only
+   *  reported when the backend actually said 401 AND the one-shot
+   *  refresh-retry did not recover (no recovery hook, refresh failed, or
+   *  the retry 401'd again). */
+  async fetchDetailed(): Promise<
+    { outcome: "ok"; earnings: Earnings }
+    | { outcome: "401" | "error" }
+  > {
+    try {
+      const first = await this.fetchOnce();
+      if (first.outcome === "ok") return first;
+      // 401 path: refresh once + retry. Any other failure (network,
+      // 5xx, malformed body) reports "error" without retry — fail-fast on
+      // structural problems so the caller's `lastUsd` cache holds.
+      if (first.outcome === "401") {
+        if (this.onAuth401 && await this.onAuth401()) {
+          // A transient failure on the retry is NOT a session expiry —
+          // the refresh itself just succeeded, so pass `second` through.
+          const second = await this.fetchOnce();
+          return second;
+        }
+        return { outcome: "401" };
+      }
+      return { outcome: "error" };
+    } catch { return { outcome: "error" }; }
+  }
+
+  private async fetchOnce(): Promise<
+    { outcome: "ok"; earnings: Earnings }
+    | { outcome: "401" | "error" }
+  > {
+    try {
+      const t = this.token();
+      if (!t) return { outcome: "error" };
+      const r = await this.f(`${this.base}/v1/earnings`,
+        { headers: { authorization: `Bearer ${t}` } });
+      if (r.status === 401) return { outcome: "401" };
+      if (!r.ok) return { outcome: "error" };
+      const j = await r.json() as {
+        lifetime_usd?: string; today_usd?: string; cap?: unknown };
+      if (typeof j.lifetime_usd !== "string" || typeof j.today_usd !== "string")
+        return { outcome: "error" };
+      return { outcome: "ok",
+        earnings: { lifetimeUsd: j.lifetime_usd, todayUsd: j.today_usd,
+                    cap: parseCap(j.cap) } };
+    } catch { return { outcome: "error" }; }
+  }
+}
diff --git a/src/extension.ts b/src/extension.ts
new file mode 100644
index 0000000..6386065
--- /dev/null
+++ b/src/extension.ts
@@ -0,0 +1,880 @@
+import * as vscode from "vscode";
+import { homedir, release } from "node:os";
+import { join } from "node:path";
+import { existsSync, unlinkSync } from "node:fs";
+import { locateClaudeCode, locateClaudeCodeLog, locateClaudeCliLog } from "./locate";
+import { ClaudeCodeAdapter } from "./adapters/claude-code/adapter";
+import { CodexAdapter } from "./adapters/codex/adapter";
+import { ClaudeCliStatuslineAdapter } from "./adapters/claude-cli/adapter";
+import { locateCodexTarget } from "./adapters/registry";
+import type { TargetAdapter, PatchParams } from "./adapters/types";
+import { StatusBar } from "./statusbar";
+import { CapWarning } from "./activation/capWarning";
+import { LogTail } from "./activity/logTail";
+import { PortfolioClient, fetchPortfolioWithDemoFallback } from "./portfolio/client";
+import { MetricsClient, newMetricEventUuid } from "./metrics/client";
+import { AuthClient } from "./auth/client";
+import { KillSwitchClient, type KillState } from "./killswitch/client";
+import { FleetSignals, KILL_STALE_MS } from "./fleetSignals";
+import { setupSelfUpdate } from "./activation/selfUpdate";
+import { EarningsClient } from "./earnings/client";
+import { ConsentClient } from "./consent/client";
+import { maybePromptForConsent } from "./consent/prompt";
+import { DebugController } from "./debug";
+import { setupBootCanary } from "./activation/bootCanary";
+import { showInstallReloadNudge, showSignInReloadNudge }
+  from "./activation/reloadNudge";
+import { notifyIncompatible } from "./activation/incompatNotice";
+import { registerDiagnoseCommand } from "./activation/diagnose";
+import { setupDesyncDetector, type DesyncState } from "./activation/desyncDetector";
+import { shouldReassert } from "./reassert";
+import { registerCommands, restoreCodexSafe } from "./activation/commands";
+import { setupEarningsRefresh } from "./activation/earningsRefresh";
+import { setupWebviewInjection, type WebviewInjectionResult }
+  from "./activation/webviewInjection";
+import { setupCliSync } from "./activation/cliSync";
+import { setupCliTick } from "./activation/cliTick";
+import { createActivationContext, type ActivationContext } from "./activation/context";
+import { resetServingGate, wireServingGateEnabled, setKillPosture,
+  killPosture, canPatch, servingSuspended, servingVerdict }
+  from "./servingGate";
+import { TestHooks } from "./testHooks";
+import { buildLabel, buildVersion } from "./buildinfo";
+import { dlog, debugEnabled, codexEnabled, codexDisabled, codexCliEnabled,
+         testHooksEnabled } from "./log";
+import { codexDiscoveryEnabled } from "./activation/codexFallback";
+import { webviewMode } from "./modes";
+import { SessionState } from "./sessionState";
+import { watchFile as nodeWatchFile, readFileSync, statSync } from "node:fs";
+import { reloadSentinelPath, parseSentinel, decideReload } from "./reloadSignal";
+import { readConfig, resolveBackendBase, resolveUpdateBase, configPath,
+  ensureConfigFile, DEFAULT_POLL_MS } from "./config";
+import { isLoopbackBase } from "./util/loopback";
+import { errMsg } from "./util/errMsg";
+
+const CFG = readConfig();
+
+const BASE = (() => {
+  const v = resolveBackendBase(CFG,
+    process.env.KICKBACKS_BASE || process.env.VIBE_ADS_BASE);
+  if (v.startsWith("http://")) {
+    const looplike = isLoopbackBase(v);
+    if (!looplike) {
+      // eslint-disable-next-line no-console
+      console.error(`Kickbacks: refusing non-loopback HTTP base "${v}". ` +
+        `Set VIBE_ADS_BASE (or ~/.vibe-ads/config.json) to https://...`);
+      return "https://invalid.example.invalid";
+    }
+  }
+  return v;
+})();
+
+const UPDATE_BASE = resolveUpdateBase(CFG, process.env.KICKBACKS_UPDATE_BASE);
+
+// Client-environment fingerprint sent on every metrics beacon so the backend
+// can segment ad traffic by client type (admin Traffic). Transparent and
+// minimal: os/arch/os_version/editor only — host/mode/version are derived
+// server-side from the surface. Nothing here is hidden or obfuscated.
+function clientEnv(): Record<string, unknown> {
+  try {
+    return {
+      os: process.platform,        // win32 / darwin / linux
+      arch: process.arch,          // x64 / arm64
+      os_version: release(),       // e.g. "10.0.26200"
+      editor: vscode.env.appName,  // "Visual Studio Code" / "Cursor" / "Windsurf" / …
+    };
+  } catch { return {}; }
+}
+
+interface Wiring {
+  adapter: TargetAdapter;
+  codexAdapter?: TargetAdapter | null;
+  statusBar: { set: (s: unknown) => void; dispose: () => void };
+  capWarning: { show: (c: unknown) => void; hide: () => void; dispose: () => void };
+  watchFileFn: typeof import("node:fs").watchFile;
+  killed?: boolean;
+  /** Test-only: shrink the serving bring-up retry base delay (audit #5). */
+  servingRetryBaseMs?: number;
+}
+let override: Partial<Wiring> | null = null;
+export function __wireForTest(w: Partial<Wiring>): void { override = w; }
+
+const watchFileImpl = (): typeof import("node:fs").watchFile =>
+  override?.watchFileFn ?? nodeWatchFile;
+
+// ─── Activation Context ───────────────────────────────────────────────
+let actx: ActivationContext = createActivationContext();
+
+/** Persisted last-CONFIRMED-kill marker (audit #19). Set when /v1/killswitch
+ *  returns 200 killed:true, cleared by a later 200 killed:false. Read at the
+ *  top of the NEXT activation so no writer patches before the first live
+ *  kill check — without blocking boot on a network round-trip. */
+const KILL_CONFIRMED_KEY = "kickbacks.kill.confirmed";
+
+export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
+  try {
+    actx = createActivationContext();
+    resetServingGate();
+
+    const target = locateClaudeCode();
+    const adapter: TargetAdapter = override?.adapter ??
+      new ClaudeCodeAdapter(target || "/__vibe_ads_no_target__");
+    actx.ccAdapter = adapter;
+
+    // Claude preflight, hoisted above Codex resolution: the codex-fallback
+    // policy below needs to know whether a compatible Claude Code exists on
+    // this machine. Read-only; bootCanary re-runs its own copy later.
+    const pf = adapter.preflight();
+
+    // S9: resolve the optional Codex target. Discovery = the explicit opt-in
+    // (codexEnabled) OR the claude-incompatible FALLBACK: with no compatible
+    // Claude Code here there is nothing of ours to crash (the S9 guard
+    // protects dual-install machines, which stay opt-in-only), and without
+    // the fallback a Codex-only install is dead weight — red "incompatible"
+    // bar, no sign-in, no serving. Explicit opt-out (codexDisabled) beats
+    // both. See activation/codexFallback.ts.
+    const codexDiscovery = codexDiscoveryEnabled({
+      optIn: codexEnabled(), optOut: codexDisabled(),
+      claudeCompatible: pf.compatible });
+    actx.codexAdapter = override
+      ? (override.codexAdapter ?? null)
+      : (codexDiscovery
+          ? (() => {
+              try {
+                const ct = locateCodexTarget();
+                return ct ? new CodexAdapter(ct) : null;
+              } catch { return null; }
+            })()
+          : null);
+    const codexAdapter = actx.codexAdapter;
+    // Single guarded Codex preflight, shared by the activation gate, the
+    // bootCanary auto-enable widening, and the ccVersion label below.
+    const codexPf = (() => {
+      try { return codexAdapter?.preflight() ?? null; } catch { return null; }
+    })();
+    const claudeOk = pf.compatible;
+    const codexOk = codexPf?.compatible === true;
+    const statusBar = override?.statusBar ?? new StatusBar();
+    // Owned by the extension context: disposing with it is the only teardown
+    // path that reaches the bar item — deactivate() works off actx and never
+    // sees this object, so without this push a disable-without-reload strands
+    // the item in the status bar.
+    ctx.subscriptions.push(statusBar);
+
+    // Separate red pill that surfaces an hourly/daily earning-cap. Independent
+    // of statusBar (own item); same context-owned disposal so a disable-without-
+    // reload doesn't strand it.
+    const capWarning = override?.capWarning ?? new CapWarning();
+    ctx.subscriptions.push(capWarning);
+
+    const session = new SessionState();
+
+    // Manual admin/debug override.
+    actx.debugCtl = new DebugController(adapter, ctx,
+      (on) => {
+        statusBar.set({ kind: "debug", on });
+        session.set({ injectionOn: on });
+      });
+    const debugCtl = actx.debugCtl;
+    debugCtl.setCodexAdapter(codexAdapter);
+
+    // Serving gate inputs (wave 2). The user-master-toggle input is the
+    // DebugController's persisted intent: ON, or an auto-enable-eligible
+    // state (fresh install / sign-out pause). A deliberate "Disable
+    // Kickbacks" / Restore command reads disabled here and sticks; the
+    // signed-out demo flow (K_ON forced false but K_PRESIGNOUT remembered)
+    // stays enabled — the Wave-1 sign-out contract.
+    wireServingGateEnabled(
+      () => debugCtl.on() || debugCtl.shouldAutoEnableOnSignIn());
+    // Boot gate (audit #19): a kill CONFIRMED in a prior session blocks all
+    // patch writers from the first instruction — bootCanary and the initial
+    // webview apply consult the gate — until a live 200 killed:false clears
+    // it. The live check below stays async; boot never blocks on network.
+    if (ctx.globalState.get<boolean>(KILL_CONFIRMED_KEY) === true) {
+      setKillPosture("confirmed");
+      dlog("ext", "kill.persisted", { posture: "confirmed" });
+    }
+
+    const dm = () => debugCtl?.openMenu();
+    const ec = async () => { try { await debugCtl?.editConfig(); } catch { /* ok */ } };
+    ctx.subscriptions.push(
+      vscode.commands.registerCommand("kickbacks.debugMenu", dm),
+      vscode.commands.registerCommand("vibe-ads.debugMenu", dm),
+      vscode.commands.registerCommand("kickbacks.editConfig", ec),
+      // Register the diagnose command BEFORE the preflight early-return so it's
+      // available precisely when a build reports incompatible.
+      // The Codex adapter is located for DIAGNOSIS even when the serving
+      // policy keeps discovery off (the dual-install default) — otherwise the
+      // report is silent about Codex exactly when a user asks why ads don't
+      // show next to it (BUG-001).
+      ...registerDiagnoseCommand(adapter, {
+        adapter: codexAdapter ?? (() => {
+          try {
+            const ct = locateCodexTarget();
+            return ct ? new CodexAdapter(ct) : null;
+          } catch { return null; }
+        })(),
+        policy: { discoveryEnabled: codexDiscovery, optIn: codexEnabled(),
+                  optOut: codexDisabled(), claudeCompatible: pf.compatible },
+      }),
+    );
+
+    // Test hook injection commands (before preflight early-return).
+    if (testHooksEnabled()) {
+      ctx.subscriptions.push(
+        vscode.commands.registerCommand("kickbacks.test.disableInjection",
+          async () => {
+            dlog("ext", "testhook.setInjection.fire", { on: false });
+            await debugCtl?.setOn(false);
+            dlog("ext", "testhook.setInjection.done",
+              { on: false, hasDebugCtl: !!debugCtl });
+          }),
+        vscode.commands.registerCommand("kickbacks.test.enableInjection",
+          async () => {
+            dlog("ext", "testhook.setInjection.fire", { on: true });
+            await debugCtl?.setOn(true);
+            dlog("ext", "testhook.setInjection.done",
+              { on: true, hasDebugCtl: !!debugCtl });
+          }));
+      void vscode.commands.executeCommand(
+        "setContext", "kickbacks.test.enabled", true);
+      dlog("ext", "testhook.injection.enabled", {});
+    }
+
+    // ─── Reload sentinel watcher ────────────────────────────────────
+    try {
+      const trig = reloadSentinelPath();
+      const runningVersion = buildVersion();
+      const armedAt = Date.now();
+      const LAST_HANDLED_KEY = "vibe-ads.reload.lastHandledMtimeMs";
+      const RESTART_ATTEMPT_CAP = 3;
+      let restartAttempts = 0;
+      const persistedHandledMtime = Number(ctx.globalState.get<number>(LAST_HANDLED_KEY) || 0);
+      let highestHandledMtime = Math.max(armedAt, persistedHandledMtime);
+      watchFileImpl()(trig, { interval: 1000 }, (curr) => {
+        let raw: string;
+        try { raw = readFileSync(trig, "utf8"); } catch { return; }
+        const payload = parseSentinel(raw);
+        if (!payload) return;
+        if (curr.mtimeMs <= highestHandledMtime) {
+          dlog("ext", "reload.skew_ignored", { mtime: curr.mtimeMs,
+            highestHandled: highestHandledMtime });
+          return;
+        }
+        const decision = decideReload({
+          mtimeMs: curr.mtimeMs, armedAt,
+          sentinelVersion: payload.version, runningVersion,
+          debug: debugEnabled() });
+        dlog("ext", "reload.decision", { decision, mtime: curr.mtimeMs,
+          armedAt, sentinelVersion: payload.version, runningVersion,
+          attempts: restartAttempts });
+        if (decision !== "none") {
+          if (restartAttempts >= RESTART_ATTEMPT_CAP) {
+            dlog("ext", "reload.cap_hit", { cap: RESTART_ATTEMPT_CAP,
+              mtime: curr.mtimeMs });
+            return;
+          }
+          restartAttempts++;
+          highestHandledMtime = curr.mtimeMs;
+          void ctx.globalState.update(LAST_HANDLED_KEY, curr.mtimeMs);
+          vscode.commands.executeCommand("workbench.action.restartExtensionHost");
+        }
+      });
+    } catch { /* watcher is best-effort; never disturb activation */ }
+
+    // ─── Config-file watcher ────────────────────────────────────────
+    // Restart only on a genuine CONTENT edit. Baseline is the raw file text
+    // (same readFileSync(..., "utf8") readConfig uses), captured lazily on
+    // first existence: file CREATION (ensureConfigFile materializing the
+    // template on the first "Edit Vibe-Ads config…") just adopts the new
+    // content, and a touch / no-op save compares equal — neither may restart
+    // the entire extension host out from under the user (audit #35).
+    try {
+      const cfgPath = configPath();
+      const readCfgRaw = (): string | null => {
+        try { return readFileSync(cfgPath, "utf8"); } catch { return null; }
+      };
+      let lastCfgContent = readCfgRaw();   // null while absent
+      watchFileImpl()(cfgPath, { interval: 2000 }, (curr) => {
+        if (!curr.mtimeMs) return;         // still absent / deleted
+        const content = readCfgRaw();
+        if (content === null) return;      // raced a delete
+        const baseline = lastCfgContent;
+        lastCfgContent = content;
+        if (baseline === null || content === baseline) return; // create/touch
+        dlog("ext", "config.changed", { mtime: curr.mtimeMs });
+        vscode.commands.executeCommand("workbench.action.restartExtensionHost");
+      });
+    } catch { /* never block activation */ }
+
+    // ─── Local-source update ────────────────────────────────────────
+    const localVsixPath = CFG.localVsixPath;
+    let lastLocalVsixMtime = 0;
+    if (localVsixPath) {
+      try {
+        lastLocalVsixMtime = statSync(localVsixPath).mtimeMs;
+      } catch { /* missing initially is fine */ }
+    }
+
+    dlog("ext", "activate", { target: !!target, build: buildLabel(),
+      debug: debugEnabled() });
+
+    // Boot canary. `anyTargetCompatible` widens the clean-boot auto-enable
+    // to Codex-only machines (their K_ON would otherwise never persist).
+    const { firstRun } = await setupBootCanary(adapter, debugCtl, ctx,
+      claudeOk || codexOk);
+
+    dlog("ext", "preflight",
+      { compatible: pf.compatible, version: pf.version, reason: pf.reason,
+        codexCompatible: codexOk, codexVersion: codexPf?.version ?? null,
+        codexFallback: codexDiscovery && !codexEnabled() });
+    if (!claudeOk && !codexOk) {
+      statusBar.set({ kind: "incompatible", version: pf.version ?? "unknown" });
+      notifyIncompatible(ctx, adapter, pf);
+      // Audit #22: this early return used to strand a previously-patched
+      // ~/.claude/settings.json forever — no cliSync runs on this path and
+      // deactivate()'s CLI restore is null-guarded away. Construct the CLI
+      // adapter and run its key-scoped restore once to clean any stale
+      // statusLine/spinnerVerbs ad (a no-op when no backup marker exists),
+      // then leave it wired so deactivate() can still restore. Full CLI
+      // serving is intentionally NOT brought up on this path.
+      try {
+        actx.cliStatus = new ClaudeCliStatuslineAdapter(
+          join(homedir(), ".claude", "settings.json"));
+        const r = actx.cliStatus.restore();
+        dlog("ext", "cli.strandRestore", { restored: r.restored });
+      } catch { /* best-effort; never disturb the early return */ }
+      // Codex strand restore (symmetric with audit #22): a previously-served
+      // Codex shimmer patch + K_ON=true gets stranded when this machine later
+      // turns both-incompatible (e.g. ~/.vibe-ads/codex.disabled set after a
+      // codex-only serving run) — deactivate() skips restore while
+      // userWantsPatched, and this early return skips all serving teardown.
+      // Construct fresh from the locator (codexAdapter is null when discovery
+      // is off); restore() is a no-op without a backup marker.
+      try {
+        const ct = locateCodexTarget();
+        if (ct) {
+          const r = new CodexAdapter(ct).restore();
+          dlog("ext", "codex.strandRestore", { restored: r.restored });
+        }
+      } catch { /* best-effort; never disturb the early return */ }
+      return;
+    }
+
+    // First-run install nudge: the patch just landed (bootCanary auto-enable)
+    // but the running Claude Code webview predates it — zero earnings until a
+    // window reload. Sticky red status bar + modal toast steer the user there.
+    // Gated on the patch actually being ON (a failed apply means a reload
+    // wouldn't help). The sticky lock clears itself on the reload (fresh
+    // activation re-creates the StatusBar).
+    if (firstRun && debugCtl.on()) {
+      dlog("ext", "installNudge.show", {});
+      statusBar.set({ kind: "needs-reload" });
+      void showInstallReloadNudge();
+    }
+
+    // ─── Auth / clients ─────────────────────────────────────────────
+    const auth = new AuthClient(BASE, ctx);
+    await auth.loadCached();
+    session.set({
+      signedIn: !!auth.accessToken(),
+      injectionOn: debugCtl.on(),
+    });
+    debugCtl.setAuth({
+      signedIn: () => auth.signedIn(),
+      storageInfo: () => auth.storageInfo(),
+      signOut: () => auth.signOut(),
+    });
+    debugCtl.setSessionSnap(() => session.get());
+    // Fleet-signal store: receives the killswitch verdict + balances the
+    // backend piggybacks on portfolio/metrics responses, so the standalone
+    // /v1/killswitch + /v1/earnings pollers below can stand down while the
+    // piggybacked data is fresh (fleet-chattiness fix, 2026-06-12).
+    const fleetSignals = new FleetSignals();
+    const portfolio = new PortfolioClient(BASE, () => auth.accessToken(),
+      undefined, fleetSignals);
+    const metrics = new MetricsClient(BASE, () => auth.accessToken(),
+      () => auth.clientId(), buildVersion(), undefined, clientEnv(),
+      fleetSignals);
+    const kill = new KillSwitchClient(BASE);
+    const { updater } = setupSelfUpdate(
+      ctx, UPDATE_BASE, buildVersion(), localVsixPath, lastLocalVsixMtime,
+      watchFileImpl(), actx.timers, CFG.updatePollIntervalMs);
+    const logTail = new LogTail(locateClaudeCodeLog);
+    // Terminal-side activity signal (newest entrypoint:"cli" transcript):
+    // feeds the statusbar ad's CLI path and the statusline view-tick loop.
+    // A separate LogTail so the overlay/desync watchdog keeps its strict
+    // panel-only signal (audit #24).
+    const cliTail = new LogTail(locateClaudeCliLog);
+    const earningsClient = new EarningsClient(BASE,
+      () => auth.accessToken(), fetch, async () => auth.refresh());
+    const consentClient = new ConsentClient(BASE, () => auth.accessToken());
+    void maybePromptForConsent({
+      client: consentClient, ctx, vsc: vscode,
+      dlog: (msg) => dlog("ext", "consent", { msg }),
+    });
+    // Host-version label. Claude-compatible machines report the CC version
+    // exactly as before. Codex-only machines report a PREFIXED label
+    // ("codex/<ver>") — self-describing in fleet/metrics analytics, and
+    // independently kill-targetable (the killswitch version scope is an
+    // exact opaque-string match server-side), never colliding with a CC
+    // semver key.
+    const ccVersion = claudeOk
+      ? (pf.version ?? "unknown")
+      : `codex/${codexPf?.version ?? "unknown"}`;
+    session.set({ ccVersion });
+
+    // ─── Earnings ───────────────────────────────────────────────────
+    // The status-bar item is the BALANCE surface, full stop (product
+    // decision 2026-06-10): it always shows the earnings/identity states
+    // painted by showActive, never ad creative. Ads live on the spinner
+    // verb, the in-window overlay, and the TUI statusline (cliTick bills
+    // that one) — so no isAdShowing arbiter is wired here anymore.
+    const { showActive, scheduleEarningsRefresh } = setupEarningsRefresh(
+      // `undefined` keeps isAdShowing's default; capWarning is the new arg.
+      auth, earningsClient, session, statusBar, ccVersion, ctx, undefined,
+      capWarning, fleetSignals);
+
+    // ─── Portfolio ──────────────────────────────────────────────────
+    // Signed in → the real, user-crediting portfolio. Signed out (incl. a
+    // present-but-rejected/expired token) → the public DEMO portfolio (real
+    // engine ads stamped demo:true). fetchPortfolioWithDemoFallback forces a
+    // refresh when a cached-but-dead token yields no ad: re-mint → real ads, or
+    // clear → demo. See its docstring for why this keeps every surface aligned.
+    // `let`, not `const`: the serving bring-up retry below (audit #5)
+    // re-fetches the portfolio when activation found no ad.
+    let portfolioResp = await fetchPortfolioWithDemoFallback(
+      portfolio, auth, ccVersion);
+    let ad = portfolioResp?.ad ?? null;
+    let viewThresholdMs = portfolioResp?.viewThresholdMs ?? 3000;
+    session.set({ hasAd: !!ad });
+
+    // Lazy portfolio resolve for the debug closure.
+    let pendingPortfolioFetch: Promise<typeof ad> | null = null;
+    const resolveAdForBilling = async (): Promise<typeof ad> => {
+      if (ad) return ad;
+      if (!auth.accessToken()) return null;
+      if (pendingPortfolioFetch) return pendingPortfolioFetch;
+      pendingPortfolioFetch = (async () => {
+        try {
+          let r = await portfolio.fetchPortfolio(ccVersion);
+          if (!r?.ad) {
+            const refreshed = await auth.refresh();
+            if (refreshed) r = await portfolio.fetchPortfolio(ccVersion);
+          }
+          if (r?.ad) {
+            ad = r.ad;
+            session.set({ hasAd: true, signedIn: true, authHealthy: "ok" });
+            debugCtl.setPortfolioAd(r.ad.adText, r.ad.clickUrl || "");
+            dlog("ext", "portfolio.lazy_resolved", { adId: r.ad.adId });
+          }
+          return ad;
+        } finally {
+          pendingPortfolioFetch = null;
+        }
+      })();
+      return pendingPortfolioFetch;
+    };
+
+    // Debug-mode metrics sender.
+    if (ad) {
+      debugCtl.setPortfolioAd(ad.adText, ad.clickUrl || "");
+    }
+    debugCtl.setMetricsSender((k, p) => {
+      void (async () => {
+        const a = await resolveAdForBilling();
+        if (!a || !auth.accessToken()) return;
+        const debugCorr = "debug." + (a.adId || "no-ad").slice(0, 8)
+          + "." + Math.random().toString(36).slice(2, 6);
+        const eventUuid = p.eventUuid || newMetricEventUuid();
+        dlog("ext", "metric.send", { event: k, adId: a.adId,
+          surface: p.surface, visibleMs: p.visibleMs, eventUuid },
+          { corr: debugCorr });
+        metrics.send(k, {
+          adId: a.adId,
+          campaignId: a.campaignId,
+          ccVersion,
+          corr: debugCorr,
+          sessionToken: a.sessionToken,
+          ...p,
+          eventUuid,
+        });
+        if (k === "view_threshold_met" || k === "click"
+            || k === "impression_viewable" || k === "error_impression") {
+          scheduleEarningsRefresh();
+        }
+      })();
+    });
+
+    // Block-desync diagnostic state.
+    const desyncState: DesyncState = { lastApplyAt: 0, lastBlockStartAt: 0 };
+
+    // Kill-switch state. Starts true when a prior session persisted a
+    // CONFIRMED kill (boot gate, audit #19); the first checkKill below
+    // resolves the live posture.
+    let killed = killPosture() !== "clear";
+
+    // Live ref for the e2e test hooks to read the loopback details after
+    // they're minted below. Stays null on builds that never patch the webview.
+    let lbInfo: { port: number; base: string } | null = null;
+
+    // Test hooks controller.
+    const testHooks = new TestHooks(metrics, portfolio, earningsClient, () => ({
+      ad,
+      signedIn: !!auth.accessToken(),
+      killed,
+      ccVersion,
+      viewThresholdMs,
+      loopback: lbInfo,
+    }), scheduleEarningsRefresh);
+
+    // Kill hysteresis (wave 2, audit #3/#6/#9/#19):
+    //   CONFIRMED (200 killed:true)  → restore every surface, halt all patch
+    //     writers via the gate, persist the flag so the NEXT boot is gated.
+    //   OFFLINE (error / non-200)    → freeze: no restore, NO new writes
+    //     (fail-closed), keep the offline paint, keep checking.
+    //   RECOVERY (200 killed:false)  → clear both; writers resume next tick.
+    //
+    // Verdicts now arrive on TWO paths: the standalone /v1/killswitch poll
+    // (the only path allowed to produce the OFFLINE posture — a carrier
+    // failure is not evidence about the kill table) and the piggybacked
+    // `kill` field off fresh portfolio/metrics 200s (fleetSignals; always
+    // confirmed-or-clear, never offline). applyKillVerdict is the single
+    // state machine both feed.
+    const applyKillVerdict = async (ks: KillState): Promise<void> => {
+      killed = ks.killed;
+      session.set({ killed });
+      if (ks.killed && !ks.confirmed) {
+        // Offline-unsure: freeze the current on-disk state. The gate's
+        // "offline" posture blocks every writer; nothing is restored, so a
+        // wifi blip never churns the user's Claude Code install.
+        setKillPosture("offline");
+        statusBar.set({ kind: "offline" });
+        return;
+      }
+      if (ks.killed) {
+        setKillPosture("confirmed");
+        // Best-effort: a Memento rejection on the 30s interval path must not
+        // become an unhandled rejection (the restore below still runs).
+        try { await ctx.globalState.update(KILL_CONFIRMED_KEY, true); }
+        catch { /* best-effort */ }
+        adapter.restore();
+        restoreCodexSafe(codexAdapter);
+        actx.cliStatus?.restore();
+        statusBar.set({ kind: "killed" });
+        return;
+      }
+      setKillPosture("clear");
+      if (ctx.globalState.get<boolean>(KILL_CONFIRMED_KEY) === true) {
+        try { await ctx.globalState.update(KILL_CONFIRMED_KEY, undefined); }
+        catch { /* best-effort; retried on the next clear tick */ }
+        dlog("ext", "kill.cleared", {});
+      }
+    };
+    const checkKill = async () => {
+      if (override?.killed !== undefined) {
+        await applyKillVerdict({ killed: !!override.killed,
+          confirmed: !!override.killed, offline: false });
+        return;
+      }
+      // Staleness gate: while piggybacked verdicts are fresh the standalone
+      // poll stands down (steady-state /v1/killswitch traffic → ~0 on a new
+      // backend). On an old backend no piggybacked verdict ever arrives, so
+      // this degenerates to exactly the previous 30s poll.
+      if (fleetSignals.killFreshWithin(KILL_STALE_MS)) return;
+      const ks = await kill.checkOnce(ccVersion, ad?.campaignId || "");
+      await applyKillVerdict(ks);
+    };
+    // Piggybacked verdicts apply the moment they arrive (registration also
+    // replays the verdict buffered from the activation portfolio fetch
+    // above, which ran before this machinery existed).
+    fleetSignals.onKillVerdict((ks) => {
+      if (override?.killed !== undefined) return; // test override wins
+      void applyKillVerdict(ks);
+    });
+    await checkKill();
+
+    // ─── Webview injection ──────────────────────────────────────────
+    const adRef = { get current() { return ad; }, set current(v) { ad = v; } };
+    const killedRef = { get current() { return killed; }, set current(v) { killed = v; } };
+
+    // Lets the ad-apply choke point (adRotation.applyAd) poke the CLI sync the
+    // moment an ad changes — sign-in demo→real swap or a rotation — instead of
+    // waiting up to 60s for cliSync's own timer. Wired to cliSync.syncNow below
+    // (setupCliSync runs after this); until then it's a safe no-op, and the
+    // initial ad is covered by setupCliSync's own first sync.
+    const cliResync = { run: () => {} };
+
+    // The serving bring-up, re-callable by the retry loop below (audit #5).
+    // `killed` is derived from the LIVE kill posture (identical to the
+    // checkKill snapshot at the initial call) so a retried attempt after a
+    // recovery doesn't replay the boot-time value; portfolioResp /
+    // viewThresholdMs are read at call time (a retry refreshes them first).
+    let wvResult: WebviewInjectionResult = { lbInfo: null,
+      reapplyCodex: null, cycleReassert: null, refreshPortfolioNow: null };
+    const bringUpServing = async (): Promise<void> => {
+      wvResult = await setupWebviewInjection({
+        ctx, actx, adapter, auth, debugCtl, session, portfolio,
+        metrics, logTail, testHooks, statusBar, ccVersion,
+        killed: killPosture() !== "clear", killedRef, adRef,
+        portfolioResp, viewThresholdMs,
+        statusBarShowActive: showActive,
+        scheduleEarningsRefresh,
+        desyncState,
+        claudeCompatible: claudeOk,
+        onAdApplied: () => cliResync.run(),
+      });
+      lbInfo = wvResult.lbInfo;
+    };
+    await bringUpServing();
+
+    if (ad && override?.killed !== true && webviewMode() === "off") {
+      adapter.restore();
+      restoreCodexSafe(codexAdapter);
+      dlog("ext", "webview.forced-off", {});
+    }
+
+    // ─── Guaranteed startup reassert (prime) ────────────────────────
+    // Prime the invisible loopback connect-src CSP patch on EVERY boot —
+    // even with no ad in hand and even signed out — so the surface is ready
+    // the instant an ad arrives (no waiting for the 60s reassert tick or a
+    // manual reload). Idempotent + invisible: when an ad was present the
+    // applyPatch above already inserted this, so prime() is a cheap no-op.
+    // The kill-switch, a crash-canary suspension, and an off webviewMode
+    // still win (prime directive: a killed / opted-out / crash-suspect
+    // install must never have CC files touched).
+    if (!killed && !servingSuspended() && webviewMode() === "on") {
+      // Claude prime only when the target is genuinely patchable: CSP-priming
+      // a present-but-incompatible CC would touch its files for a webview we
+      // will never inject (codex-only proceed path).
+      if (claudeOk) {
+        try { adapter.prime?.(); } catch { /* prime directive */ }
+      }
+      try { codexAdapter?.prime?.(); } catch { /* prime directive */ }
+    }
+
+    // ─── CLI sync ───────────────────────────────────────────────────
+    const cliSync = setupCliSync({
+      actx, ctx, adapter, auth, metrics, debugCtl, ccVersion,
+      adRef, killedRef,
+      overrideKilled: override?.killed,
+      // Lazy: a retried bring-up (audit #5) replaces wvResult, and the CLI
+      // sync's Codex reassert must follow the live value, not the boot one.
+      reapplyCodex: () => wvResult.reapplyCodex?.(),
+    });
+    // Now that the CLI sync exists, point the ad-apply hook at it so every
+    // subsequent applyAd re-syncs the CLI surface immediately.
+    cliResync.run = cliSync.syncNow;
+
+    // ─── Status bar ─────────────────────────────────────────────────
+    // No setupStatusBarAd here (removed 2026-06-10): the VS Code status
+    // bar shows the balance at all times and never serves ads. The module
+    // stays in src/activation/ unimported (and unit-tested) should the
+    // surface ever come back; billing for TUI users runs through the
+    // statusline cliTick below, panel users through the overlay.
+
+    // ─── Statusline view ticks (TUI billing) ────────────────────────
+    // The dwell loop for the terminal statusline surface — without it a
+    // TUI-only user sees ads all day and never earns (cliSync emits
+    // impressions only). Gated on a live terminal turn via cliTail.
+    setupCliTick({
+      cliTail,
+      metrics,
+      adRef,
+      killedRef,
+      signedIn: () => !!auth.accessToken(),
+      surfaceApplied: () => {
+        try { return actx.cliStatus?.preflight().compatible ?? false; }
+        catch { return false; }
+      },
+      ccVersion,
+      timers: actx.timers,
+    });
+
+    // ─── Periodic timers ────────────────────────────────────────────
+    actx.timers.push(setInterval(checkKill, 30_000));
+    actx.timers.push(setInterval(() => void showActive(), 30_000));
+    actx.timers.push(setInterval(() => void debugCtl?.reassertTick(), 60_000));
+
+    // Tiered desync self-heal. The drift-only reasserts above can't see a
+    // "patched file but webview cached the pre-patch module" desync; this
+    // watchdog escalates (cyclePatch → webview reload → window-reload toast)
+    // ONLY when CC is actively in use (independent transcript-mtime signal)
+    // yet our overlay telemetry has gone silent — never when simply idle.
+    const hardReassert = (): void => {
+      try {
+        const c = debugCtl.cyclePatch();        // debug-injection path
+        if (!c.ok) wvResult.cycleReassert?.();  // production server-ad path
+      } catch { /* prime directive */ }
+    };
+    setupDesyncDetector(desyncState, actx.timers, {
+      ccActivityAgeMs: () => logTail.activityAgeMs(),
+      // canPatch() folds in the serving gate (kill posture, master toggle,
+      // canary suspension) so the watchdog can never "heal" a gated install.
+      healthy: () => canPatch() && shouldReassert({
+        haveAd: !!adRef.current,
+        killed: killedRef.current,
+      }),
+      hardReassert,
+      // True while the CC panel is in an active tool_use turn (e.g. waiting
+      // on a long-running sub-agent). Disruptive escalation (reload, toast)
+      // is deferred so we never interrupt an in-progress session. null when
+      // the transcript can't be read (treated as not active — safe default).
+      ccTurnActive: () => {
+        try {
+          const a = logTail.current();
+          return a !== null && a.done === false ? true : null;
+        } catch { return null; }
+      },
+    });
+
+    // Login trigger: reassert the patch immediately after a successful
+    // interactive sign-in (don't wait up to 60s for the next reassert tick).
+    auth.setOnSignedIn(() => {
+      try {
+        void debugCtl.reapplyIfOn();   // debug-injection path
+        hardReassert();                // production path (health-gated no-op if no ad)
+        // Swap any signed-out DEMO ad for a real, user-crediting one NOW, so
+        // post-sign-in impressions bill under the real session token instead
+        // of 403-ing on the authed endpoint with a stale demo token. `force`
+        // re-applies even when the adId set is unchanged. No-op (null) when the
+        // overlay never set up (no ad at activation).
+        void wvResult.refreshPortfolioNow?.(true);
+        // The live swap above is best-effort; a reload is the path that
+        // always works. Tell the user every time they sign in.
+        void showSignInReloadNudge();
+      } catch { /* prime directive */ }
+    });
+
+    // ─── Serving bring-up retry (audit #5) ──────────────────────────
+    // Activation while the backend flaps (documented cold-start 502/503),
+    // momentarily-empty inventory, or a fail-safed kill probe left the
+    // bring-up above returning nulls — and pre-fix NOTHING retried: serving
+    // stayed dead until a window reload. Retry with bounded exponential
+    // backoff (30s → 60s → 120s …, capped at 5min, indefinitely at the
+    // cap). While the serving gate says killed/disabled/suspended/offline
+    // the attempt is SKIPPED but the loop stays alive, so a recovery
+    // (checkKill clearing the posture) brings serving up in-session
+    // without a reload. Ends permanently on success; every pending timer
+    // lives in actx.timers so deactivate() disposes it.
+    const servingUp = (): boolean => wvResult.refreshPortfolioNow !== null;
+    const RETRY_CAP_MS = 5 * 60_000;
+    let retryDelayMs = override?.servingRetryBaseMs ?? 30_000;
+    const retryActx = actx;   // a newer activation owns its own loop
+    const scheduleServingRetry = (): void => {
+      actx.timers.push(setTimeout(() => void retryServing(), retryDelayMs));
+      retryDelayMs = Math.min(retryDelayMs * 2, RETRY_CAP_MS);
+    };
+    const retryServing = async (): Promise<void> => {
+      try {
+        if (actx !== retryActx || servingUp()) return;
+        if (servingVerdict() !== "write" || webviewMode() !== "on") {
+          dlog("ext", "serving.retry.gated", { verdict: servingVerdict() });
+        } else {
+          if (!adRef.current) {
+            const r = await fetchPortfolioWithDemoFallback(
+              portfolio, auth, ccVersion);
+            if (r?.ad) {
+              portfolioResp = r;
+              viewThresholdMs = r.viewThresholdMs;
+              ad = r.ad;
+              session.set({ hasAd: true });
+              debugCtl.setPortfolioAd(r.ad.adText, r.ad.clickUrl || "");
+            }
+          }
+          if (adRef.current && actx === retryActx) {
+            await bringUpServing();
+            if (servingUp()) {
+              dlog("ext", "serving.retry.up", { port: lbInfo?.port });
+              cliResync.run();   // fresh ad → CLI surface now, not in ≤60s
+              return;            // success — the loop ends permanently
+            }
+          }
+        }
+      } catch (e) {
+        dlog("ext", "serving.retry.error", { msg: errMsg(e) });
+      }
+      scheduleServingRetry();
+    };
+    if (!servingUp() && webviewMode() === "on") {
+      dlog("ext", "serving.retry.armed", { inMs: retryDelayMs });
+      scheduleServingRetry();
+    }
+
+    // ─── Command registration ───────────────────────────────────────
+    registerCommands(ctx, adapter, codexAdapter, auth, debugCtl, statusBar,
+      session, updater, ccVersion, showActive);
+
+    // ─── E2E test hooks ─────────────────────────────────────────────
+    if (testHooksEnabled()) {
+      testHooks.registerCommands(ctx);
+      ctx.subscriptions.push(
+        vscode.commands.registerCommand("kickbacks.test.refreshStatusBar",
+          async () => { await showActive(); }));
+      void vscode.commands.executeCommand(
+        "setContext", "kickbacks.test.enabled", true);
+      dlog("ext", "testhook.enabled", {});
+    }
+  } catch (e) {
+    try {
+      dlog("ext", "activate.fatal", {
+        msg: errMsg(e, 300),
+        stack: (e instanceof Error && e.stack ? e.stack : "").split("\n")[0],
+      });
+    } catch { /* dlog itself must never throw */ }
+  }
+}
+
+/** Bound a loopback stop so a hung http.Server.close (e.g. an in-flight
+ *  webview request) can never exhaust VS Code's deactivation budget
+ *  (audit #36). The orphaned close keeps draining in the background. */
+const STOP_BUDGET_MS = 2_000;
+async function boundedStop(p: Promise<unknown>): Promise<void> {
+  let t: NodeJS.Timeout | undefined;
+  try {
+    await Promise.race([
+      p.catch(() => { /* ignore */ }),
+      new Promise<void>((r) => { t = setTimeout(r, STOP_BUDGET_MS); }),
+    ]);
+  } finally { if (t) clearTimeout(t); }
+}
+
+export async function deactivate(): Promise<void> {
+  for (const t of actx.timers) clearInterval(t);
+  actx.timers.length = 0;
+  try {
+    const canaryPath = join(homedir(), ".vibe-ads", "boot.canary");
+    if (existsSync(canaryPath)) unlinkSync(canaryPath);
+  } catch { /* best-effort */ }
+  const userWantsPatched = !!actx.debugCtl?.on();
+  // Irreversible user-file restores FIRST (audit #36): a hung loopback close
+  // must never leave CC/Codex/settings.json patched after uninstall.
+  if (!userWantsPatched) {
+    try {
+      if (actx.ccAdapter) actx.ccAdapter.restore({ keepCsp: true });
+      else {
+        const target = locateClaudeCode();
+        if (target) new ClaudeCodeAdapter(target).restore({ keepCsp: true });
+      }
+    } catch { /* ignore */ }
+  }
+  try { actx.cliStatus?.restore(); } catch { /* ignore */ }
+  try { actx.codexCliStatus?.restore(); } catch { /* ignore */ }
+  if (!userWantsPatched) {
+    try {
+      if (actx.codexAdapter) actx.codexAdapter.restore({ keepCsp: true });
+      else {
+        const ct = locateCodexTarget();
+        if (ct) new CodexAdapter(ct).restore({ keepCsp: true });
+      }
+    } catch { /* ignore */ }
+  }
+  // Loopback stops LAST, each time-bounded so deactivate always completes.
+  if (actx.loopback) { await boundedStop(actx.loopback.stop()); actx.loopback = null; }
+  if (actx.debugCtl) { await boundedStop(actx.debugCtl.dispose()); actx.debugCtl = null; }
+  actx.cliStatus = null;
+  actx.codexCliStatus = null;
+  actx.ccAdapter = null;
+  actx.codexAdapter = null;
+  override = null;
+}
diff --git a/src/fleetSignals.ts b/src/fleetSignals.ts
new file mode 100644
index 0000000..0bb47f2
--- /dev/null
+++ b/src/fleetSignals.ts
@@ -0,0 +1,152 @@
+import type { KillState } from "./killswitch/client";
+import type { EarningCap } from "./earnings/client";
+import { parseCap } from "./earnings/client";
+
+/** Fleet-chattiness fix (2026-06-12): the backend piggybacks the killswitch
+ *  verdict (`kill`) and the status-bar earnings (`balances`, now incl. `cap`)
+ *  on the portfolio + metrics responses the extension already fetches. This
+ *  store receives those signals from the wire clients and lets the standalone
+ *  GET /v1/killswitch and GET /v1/earnings pollers stand down while the
+ *  piggybacked data is fresh — together those polls were ~60% of backend
+ *  traffic at fleet scale.
+ *
+ *  Trust rules (the load-bearing part):
+ *    - Only a FRESH 2xx with a parseable field may produce a signal. Carrier
+ *      failures (401/5xx/network/missing field/warm-cache fallback) produce
+ *      NOTHING here — in particular they must never set the kill `offline`
+ *      posture; only the standalone fallback poller may do that. An auth
+ *      failure is not evidence about the kill table.
+ *    - A parsed kill verdict gets `confirmed: killed` (it came from a 200),
+ *      matching KillSwitchClient's 200-path semantics exactly.
+ *    - `capCapable` records whether the carrier had the `cap` KEY at all:
+ *      key-present (even when null) = piggyback-capable backend; key-absent =
+ *      old backend, so the earnings poller must keep running. */
+
+/** How long a piggybacked kill verdict suppresses the standalone
+ *  /v1/killswitch poll. 90s = one missed 60s portfolio refresh plus slack;
+ *  an idle-but-open editor's worst-case kill latency becomes ~65s (60s
+ *  portfolio + 5s server cache) with the fallback poll resuming inside 90s.
+ *  Tuning knob — drop to 45_000 if that idle window ever matters more than
+ *  the request savings. */
+export const KILL_STALE_MS = 90_000;
+
+/** How long piggybacked balances satisfy the status bar before showActive
+ *  falls back to a real GET /v1/earnings. Same 90s logic as the kill gate. */
+export const EARNINGS_STALE_MS = 90_000;
+
+export interface EarningsSnapshot {
+  lifetimeUsd: string;
+  todayUsd: string;
+  /** Defensive-parsed cap; undefined = under both ceilings or unparseable. */
+  cap?: EarningCap;
+  /** True when the carrier response had the `cap` key — the new-backend
+   *  marker. While false the standalone /v1/earnings poll must continue
+   *  (its response is the only source of the cap pill on old backends). */
+  capCapable: boolean;
+  updatedAt: number;
+}
+
+/** Defensive parse of a piggybacked `kill` field. Returns undefined for
+ *  null / missing / malformed input — never a fail-safe verdict: an absent
+ *  field only means "this carrier doesn't speak the protocol", which the
+ *  staleness-gated fallback poller covers. Exported for unit tests. */
+export function parseKillField(raw: unknown): KillState | undefined {
+  if (!raw || typeof raw !== "object") return undefined;
+  const k = raw as { killed?: unknown; scope?: unknown; reason?: unknown };
+  if (typeof k.killed !== "boolean") return undefined;
+  return {
+    killed: k.killed,
+    confirmed: k.killed, // fresh 200 ⇒ same trust as KillSwitchClient's 200
+    scope: typeof k.scope === "string" ? k.scope : undefined,
+    reason: typeof k.reason === "string" ? k.reason : undefined,
+    offline: false,
+  };
+}
+
+/** Defensive parse of a piggybacked `balances` block. Returns undefined on
+ *  anything malformed — a bad block must never poison the status bar. */
+export function parseBalancesField(raw: unknown): EarningsSnapshot | undefined {
+  if (!raw || typeof raw !== "object") return undefined;
+  const b = raw as { lifetime_usd?: unknown; today_usd?: unknown };
+  if (typeof b.lifetime_usd !== "string" || typeof b.today_usd !== "string")
+    return undefined;
+  return {
+    lifetimeUsd: b.lifetime_usd,
+    todayUsd: b.today_usd,
+    cap: parseCap((raw as { cap?: unknown }).cap),
+    capCapable: "cap" in (raw as Record<string, unknown>),
+    updatedAt: Date.now(),
+  };
+}
+
+// Sign-out hook, module-scoped to match noteMetricsSignOut /
+// clearAdRotationOnSignOut: exactly one FleetSignals serves an extension
+// host, and cmdSignOut must drop the old identity's balances so the next
+// paint can't show the previous user's money.
+let liveSignOutClear: (() => void) | null = null;
+export function noteFleetSignalsSignOut(): void {
+  liveSignOutClear?.();
+}
+
+export class FleetSignals {
+  private lastKillVerdictAt = 0;
+  private lastKillVerdict: KillState | null = null;
+  private earnings: EarningsSnapshot | null = null;
+  private killSink: ((ks: KillState) => void) | null = null;
+  private earningsSink: (() => void) | null = null;
+
+  constructor() {
+    liveSignOutClear = () => this.clearEarnings();
+  }
+
+  /** Wire-client entry point: raw `kill` field off a fresh 2xx body. */
+  noteKill(raw: unknown): void {
+    const ks = parseKillField(raw);
+    if (!ks) return;
+    this.lastKillVerdictAt = Date.now();
+    this.lastKillVerdict = ks;
+    try { this.killSink?.(ks); } catch { /* sink must never break a fetch */ }
+  }
+
+  /** Wire-client entry point: raw `balances` field off a fresh AUTHED 2xx. */
+  noteBalances(raw: unknown): void {
+    const snap = parseBalancesField(raw);
+    if (!snap) return;
+    this.earnings = snap;
+    try { this.earningsSink?.(); } catch { /* never break a fetch */ }
+  }
+
+  /** True while the standalone killswitch poll may stand down. */
+  killFreshWithin(ms: number): boolean {
+    return this.lastKillVerdictAt > 0
+      && Date.now() - this.lastKillVerdictAt < ms;
+  }
+
+  earningsSnapshot(): EarningsSnapshot | null {
+    return this.earnings;
+  }
+
+  earningsFreshWithin(ms: number): boolean {
+    return !!this.earnings && Date.now() - this.earnings.updatedAt < ms;
+  }
+
+  /** Sign-out teardown: piggybacked balances belong to the old identity. */
+  clearEarnings(): void {
+    this.earnings = null;
+  }
+
+  /** Registers the verdict sink and immediately replays a buffered fresh
+   *  verdict: activation fetches the first portfolio BEFORE the kill
+   *  machinery exists, so without the replay that boot verdict would both
+   *  suppress the standalone poll (fresh timestamp) AND never be applied. */
+  onKillVerdict(sink: (ks: KillState) => void): void {
+    this.killSink = sink;
+    if (this.lastKillVerdict && this.killFreshWithin(KILL_STALE_MS)) {
+      try { sink(this.lastKillVerdict); } catch { /* never break wiring */ }
+    }
+  }
+
+  onEarningsUpdated(sink: () => void): void {
+    this.earningsSink = sink;
+  }
+}
diff --git a/src/killswitch/client.ts b/src/killswitch/client.ts
new file mode 100644
index 0000000..b573c75
--- /dev/null
+++ b/src/killswitch/client.ts
@@ -0,0 +1,58 @@
+import { timeoutFetch } from "../util/http";
+
+type Fetch = typeof fetch;
+export interface KillState {
+  killed: boolean;
+  /** wave-2 kill hysteresis (audit #3/#6/#9): true ONLY when an HTTP 200
+   *  actually said killed:true. A confirmed kill is the restore-everything
+   *  signal; an UNCONFIRMED kill (offline fail-safe) must FREEZE instead —
+   *  no restore, no new writes — so a wifi blip never starts the
+   *  restore→re-patch oscillation on the user's Claude Code install. */
+  confirmed: boolean;
+  scope?: string;
+  reason?: string;
+  /** wave-2A-F06: distinguishes a fail-safe "treated as killed because
+   *  unreachable" from a real backend killed: true response. Callers can
+   *  render an "offline" status (status bar already has the `kind:"offline"`
+   *  variant unused pre-fix) instead of the alarming "killed" badge that
+   *  used to flicker on every brief network blip. Offline still implies
+   *  killed for the purposes of "is it safe to serve an ad" (fail-safe
+   *  posture for NEW writes) — but never `confirmed`, so it never restores. */
+  offline?: boolean;
+}
+
+/** Polls GET /v1/killswitch. Fail-safe: any error => killed (matches the S2
+ *  resolve_killed posture — never serve under a kill). When the error was
+ *  unreachability rather than a real backend kill, `offline: true` is set
+ *  (and `confirmed` stays false) so the caller can FREEZE rather than
+ *  restore, and the status bar can render distinct UX. */
+export class KillSwitchClient {
+  constructor(private base: string, private f: Fetch = timeoutFetch(15000)) {}
+
+  async checkOnce(ccVersion: string, campaignId: string): Promise<KillState> {
+    try {
+      const r = await this.f(
+        `${this.base}/v1/killswitch?version=${encodeURIComponent(ccVersion)}` +
+        `&campaign=${encodeURIComponent(campaignId)}`);
+      if (!r.ok) {
+        // 5xx or 4xx -> offline-equivalent (backend reachable but not
+        // returning the contract). Still fail-safe killed, never confirmed.
+        return { killed: true, confirmed: false, offline: true,
+                 reason: `status ${r.status}` };
+      }
+      const j = await r.json() as KillState;
+      return {
+        killed: !!j.killed,
+        confirmed: !!j.killed,
+        scope: j.scope,
+        reason: j.reason,
+        offline: false,
+      };
+    } catch (e) {
+      // Network/DNS error -> truly offline. Caller freezes; kill posture
+      // preserved for NEW writes only.
+      return { killed: true, confirmed: false, offline: true,
+               reason: `fail-safe: ${String(e)}` };
+    }
+  }
+}
diff --git a/src/locate.ts b/src/locate.ts
new file mode 100644
index 0000000..79002d7
--- /dev/null
+++ b/src/locate.ts
@@ -0,0 +1,175 @@
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { readdirSync, existsSync, statSync, openSync, readSync,
+         closeSync } from "node:fs";
+import { compareClaudeCodeInstall } from "./util/claudeCodeVersion";
+
+// readdir-based glob for `<root>/anthropic.claude-code-*/webview/index.js`.
+// Swapped from `node:fs`'s `globSync` (the installed @types/node@20 lacks the
+// declaration though Node 22 has it at runtime). Returns absolute paths; never throws.
+export function globClaudeCode(root: string): string[] {
+  try {
+    if (!existsSync(root)) return [];
+    const out: string[] = [];
+    for (const name of readdirSync(root)) {
+      if (!name.startsWith("anthropic.claude-code-")) continue;
+      const idx = join(root, name, "webview", "index.js");
+      if (existsSync(idx)) out.push(idx);
+    }
+    return out;
+  } catch { return []; }
+}
+
+export function locateClaudeCode(): string | null {
+  // Explicit escape hatch for S5 matrix / manual smoke / portable installs.
+  const explicit = process.env.KICKBACKS_CC_TARGET
+    || process.env.VIBE_ADS_CC_TARGET;
+  if (explicit && existsSync(explicit)) return explicit;
+  // Covers local (.vscode/.vscode-insiders/.cursor) AND remote/server hosts
+  // (Remote-SSH, dev containers, vscode.dev) where extensions live under
+  // *-server/. Keep in sync with ROOTS in adapters/registry.ts.
+  for (const root of [join(homedir(), ".vscode", "extensions"),
+                       join(homedir(), ".vscode-insiders", "extensions"),
+                       join(homedir(), ".vscode-server", "extensions"),
+                       join(homedir(), ".vscode-server-insiders", "extensions"),
+                       join(homedir(), ".cursor", "extensions"),
+                       join(homedir(), ".cursor-server", "extensions")]) {
+    try {
+      const hits = globClaudeCode(root).sort(compareClaudeCodeInstall);
+      if (hits.length) return hits[hits.length - 1];
+    } catch { /* ignore */ }
+  }
+  return null;
+}
+
+/** Best-effort `entrypoint` tag of a session transcript: "claude-vscode" for
+ *  the interactive VS Code panel, "cli" for terminal sessions. Reads the head
+ *  of the file and returns the first record's top-level `entrypoint` string,
+ *  or null when untagged/unreadable (older CC builds; queue-operation lines
+ *  carry no tag, so we scan past them). Line-parsed JSON — a transcript whose
+ *  message TEXT merely quotes `"entrypoint":"cli"` cannot spoof the tag.
+ *  Never throws. */
+export function transcriptEntrypoint(path: string): string | null {
+  const hit = entrypointCache.get(path);
+  if (hit !== undefined) return hit;
+  const tag = readTranscriptEntrypoint(path);
+  if (tag !== null) {
+    if (entrypointCache.size >= ENTRYPOINT_CACHE_MAX) {
+      const oldest = entrypointCache.keys().next().value as string;
+      entrypointCache.delete(oldest);
+    }
+    entrypointCache.set(path, tag);
+  }
+  return tag;
+}
+
+// Per-path tag memo: a transcript's entrypoint tag is written once at session
+// start and never changes, so a TAGGED result is cacheable for the process
+// lifetime — this keeps the repeated locate/cliSessionActive probes (statusbar
+// poll, 60s cliSync tick) from re-reading the same heads. Untagged (null)
+// results are NOT cached: a brand-new session's head may not be flushed yet.
+const entrypointCache = new Map<string, string>();
+const ENTRYPOINT_CACHE_MAX = 256;
+
+function readTranscriptEntrypoint(path: string): string | null {
+  try {
+    const fd = openSync(path, "r");
+    let text: string;
+    try {
+      const buf = Buffer.alloc(16 * 1024);
+      const n = readSync(fd, buf, 0, buf.length, 0);
+      text = buf.toString("utf8", 0, n);
+    } finally { closeSync(fd); }
+    for (const ln of text.split("\n")) {
+      if (!ln) continue;
+      let o: Record<string, unknown>;
+      try { o = JSON.parse(ln) as Record<string, unknown>; }
+      catch { continue; }                  // sliced tail line / junk
+      if (typeof o.entrypoint === "string") return o.entrypoint;
+    }
+    return null;
+  } catch { return null; }
+}
+
+// Mtime-sorted (newest first) scan of every transcript under
+// ~/.claude/projects — shared by the per-surface resolvers below.
+function scanTranscripts(): { p: string; m: number }[] {
+  const root = join(homedir(), ".claude", "projects");
+  if (!existsSync(root)) return [];
+  const cands: { p: string; m: number }[] = [];
+  for (const proj of readdirSync(root)) {
+    let entries: string[];
+    try { entries = readdirSync(join(root, proj)); } catch { continue; }
+    for (const f of entries) {
+      if (!f.endsWith(".jsonl")) continue;
+      const p = join(root, proj, f);
+      try { cands.push({ p, m: statSync(p).mtimeMs }); } catch { /* ignore */ }
+    }
+  }
+  cands.sort((a, b) => b.m - a.m);
+  return cands;
+}
+
+// Discover Claude Code's live JSONL transcript. CRITICAL: multiple Claude
+// sessions can share a cwd (e.g. an interactive VS Code session AND a CLI/
+// agent session). "Newest jsonl" alone tails whichever moved last — often the
+// wrong one, so `done`/tool track a different session. We therefore prefer the
+// newest transcript whose `entrypoint` is "claude-vscode" (the interactive VS
+// Code instance whose webview we patch); fall back to newest-overall only if
+// none are tagged (older CC builds). VIBE_ADS_CC_LOG overrides. "" => LogTail
+// yields null => block self-simulates.
+export function locateClaudeCodeLog(): string {
+  const explicit = process.env.KICKBACKS_CC_LOG
+    || process.env.VIBE_ADS_CC_LOG;
+  if (explicit && existsSync(explicit)) return explicit;
+  try {
+    const cands = scanTranscripts();
+    if (!cands.length) return "";
+    // Documented filter (audit #24): newest transcript positively tagged
+    // entrypoint:"claude-vscode" wins; fall back to the newest UNTAGGED one
+    // (older CC builds); when every probed candidate is tagged as another
+    // surface (e.g. "cli") return "" — no signal beats a wrong signal (a
+    // terminal session would feed the desync watchdog a false "CC active").
+    // Probes are bounded to the newest few: anything older isn't live.
+    // NOTE: when multiple VS Code sessions share a cwd they're all tagged
+    // "claude-vscode" with NO filesystem signal identifying which belongs to
+    // this window — newest-mtime stays the best available proxy among them.
+    let newestUntagged = "";
+    for (const c of cands.slice(0, 20)) {
+      const tag = transcriptEntrypoint(c.p);
+      if (tag === "claude-vscode") return c.p;
+      if (tag === null && !newestUntagged) newestUntagged = c.p;
+    }
+    return newestUntagged;
+  } catch { return ""; }
+}
+
+// The TERMINAL-side mirror of locateClaudeCodeLog: newest transcript
+// positively tagged entrypoint:"cli" (a `claude` TUI session); falls back to
+// the newest UNTAGGED transcript when no "cli" tag is found (older CC builds
+// write no tag — pre-fix this resolver refused them, so a TUI user on an
+// untagged build saw statusline/spinner ads all day and NEVER earned: the
+// 2026-06-11 launch-day cohort, 26 users at $0. The impression counter
+// (cliSessionActive) was already fail-open for untagged, so the books showed
+// impressions-without-ticks — exactly the Steven signature). Positively
+// OTHER-tagged transcripts (sdk-*, desktop, claude-vscode…) are still never
+// claimed: those sessions render no statusline, so ticking them would bill a
+// surface nobody sees. Untagged MAY double-claim with locateClaudeCodeLog's
+// own untagged fallback; that is financially safe — billable view events
+// share the server-side per-(user,ad) cooldown bucket across surfaces, so a
+// cross-surface double-tick is written as a zero-debit audit row.
+// Consumers: the statusline view-tick loop (cliTick).
+export function locateClaudeCliLog(): string {
+  const explicit = process.env.KICKBACKS_CLI_LOG;
+  if (explicit && existsSync(explicit)) return explicit;
+  try {
+    const cands = scanTranscripts();
+    let newestUntagged = "";
+    for (const c of cands.slice(0, 20)) {
+      const tag = transcriptEntrypoint(c.p);
+      if (tag === "cli") return c.p;
+      if (tag === null && !newestUntagged) newestUntagged = c.p;
+    }
+    return newestUntagged;
+  } catch { return ""; }
+}
diff --git a/src/log.ts b/src/log.ts
new file mode 100644
index 0000000..3becf42
--- /dev/null
+++ b/src/log.ts
@@ -0,0 +1,267 @@
+import { appendFileSync, mkdirSync, existsSync, readFileSync,
+         writeFileSync, unlinkSync, statSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { codexBuildOptIn, testHooksBuildOptIn, verboseBuild } from "./buildflags";
+
+/** Headless-diagnosable debug log. One timestamped JSONL line per event, from
+ *  BOTH the extension host (src:"ext") and the injected webview block
+ *  (src:"webview", relayed through the loopback /log route). Written to a
+ *  fixed server-side path so a headless agent / CI / Docker run can read the
+ *  whole lifecycle with no human watching a screen.
+ *
+ *  LOGGING POLICY (two tiers):
+ *   - Lifecycle events (auth / self-update / activation health — see
+ *     `isLifecycleEvent`) ALWAYS log, even in a stock install. They are
+ *     low-volume and PII-free, and make incidents like a failed silent
+ *     token-refresh-on-update diagnosable from ANY user's machine without
+ *     asking them to flip a flag first. The rolling trim (MAX_LOG_LINES)
+ *     keeps the file bounded.
+ *   - The verbose firehose (per-impression metrics, webview relay via
+ *     `dlogRaw`, render plumbing) stays OFF by default (prime directive: no
+ *     overhead in normal use) and is opt-in via EITHER `KICKBACKS_DEBUG`
+ *     (or `VIBE_ADS_DEBUG`) truthy OR a `~/.vibe-ads/debug.enabled` sentinel
+ *     — the sentinel is the headless toggle (create/remove from a shell, no
+ *     rebuild, no UI).
+ *  Never throws. */
+const DIR = join(homedir(), ".vibe-ads");
+const LOG = join(DIR, "debug.log");
+const SENTINEL = join(DIR, "debug.enabled");
+const CONFIG = join(DIR, "config.json");
+
+// Short cache so dlog (called often, gated by debugEnabled()) doesn't
+// readFileSync the config on every event. Saving config.json restarts the
+// extension host anyway (config watcher in extension.ts), so TTL is belt-
+// and-suspenders — the cache will be torn down before it can go stale.
+let _cfgCheckedAt = 0;
+let _cfgDebug = false;
+function configSaysDebug(): boolean {
+  const now = Date.now();
+  if (now - _cfgCheckedAt < 5000) return _cfgDebug;
+  _cfgCheckedAt = now;
+  try {
+    const raw = readFileSync(CONFIG, "utf8");
+    _cfgDebug = (JSON.parse(raw) as { debugMode?: unknown })?.debugMode === true;
+  } catch { _cfgDebug = false; /* absent or malformed -> off */ }
+  return _cfgDebug;
+}
+
+export function debugEnabled(): boolean {
+  try {
+    // Build-time verbose flag (esbuild define).
+    if (verboseBuild()) return true;
+    // Env-var opt-in: both legacy and new prefixes.
+    if (process.env.KICKBACKS_DEBUG === "1"
+        || process.env.VIBE_ADS_DEBUG === "1") return true;
+    // Sentinel file: ~/.vibe-ads/debug.enabled.
+    if (existsSync(SENTINEL)) return true;
+    // Config JSON: debugMode: true.
+    return configSaysDebug();
+  } catch { return false; }
+}
+
+// S9 Codex dual-target opt-in. PRODUCTION DEFAULT IS OFF on machines running
+// a compatible Claude Code (prime directive: it crashed Claude Code once and
+// the dual-install path is not yet manually smoke-verified). This only
+// enables Codex auto-discovery for an explicit opt-in: `VIBE_ADS_CODEX=1`
+// OR a `~/.vibe-ads/codex.enabled` sentinel — the sentinel is the headless,
+// no-relaunch toggle for smoke-testing (create/remove from a shell, reload).
+// Never throws. Do NOT widen this to a default-on until smoke + a
+// can't-crash-CC verification both pass.
+//
+// NOTE: this is the raw opt-IN only. The CLAUDE-INCOMPATIBLE FALLBACK (Codex
+// discovery turns on by itself when no compatible Claude Code target exists —
+// there is nothing of ours to crash on such a machine) is composed in
+// extension.ts via activation/codexFallback.codexDiscoveryEnabled(), where
+// both preflights are visible. codexDisabled() below beats both.
+const CODEX_SENTINEL = join(DIR, "codex.enabled");
+export function codexEnabled(): boolean {
+  try {
+    if (codexBuildOptIn()) return true;
+    if (process.env.KICKBACKS_CODEX === "1"
+        || process.env.VIBE_ADS_CODEX === "1") return true;
+    return existsSync(CODEX_SENTINEL);
+  } catch { return false; }
+}
+
+// Explicit local opt-OUT for Codex targeting. Beats BOTH the opt-in above and
+// the claude-incompatible fallback (composed in extension.ts). KICKBACKS_CODEX=0
+// / VIBE_ADS_CODEX=0, or a ~/.vibe-ads/codex.disabled sentinel — the headless
+// one-liner support remediation (create from a shell, reload, done). Never
+// throws.
+const CODEX_DISABLED_SENTINEL = join(DIR, "codex.disabled");
+export function codexDisabled(): boolean {
+  try {
+    if (process.env.KICKBACKS_CODEX === "0"
+        || process.env.VIBE_ADS_CODEX === "0") return true;
+    return existsSync(CODEX_DISABLED_SENTINEL);
+  } catch { return false; }
+}
+
+// Codex CLI wrapper opt-in. PRODUCTION DEFAULT IS OFF — replacing the
+// npm-generated codex.cmd is reversible but high-blast-radius (every
+// `codex` invocation on the box) so it must be flipped on explicitly:
+// VIBE_ADS_CODEX_CLI=1 OR a ~/.vibe-ads/codex-cli.enabled sentinel.
+// Mirrors the codexEnabled() gate pattern. Never throws.
+const CODEX_CLI_SENTINEL = join(DIR, "codex-cli.enabled");
+export function codexCliEnabled(): boolean {
+  try {
+    if (process.env.KICKBACKS_CODEX_CLI === "1"
+        || process.env.VIBE_ADS_CODEX_CLI === "1") return true;
+    return existsSync(CODEX_CLI_SENTINEL);
+  } catch { return false; }
+}
+
+// E2E test-hook surface (kickbacks.test.* commands). OFF by default: a stock
+// install must never expose forge-click commands in the command palette. Two
+// opt-ins, mirroring the debug/codex pattern: env VIBE_ADS_TEST_HOOKS truthy,
+// or a ~/.vibe-ads/test-hooks.enabled sentinel for headless toggling. Never
+// throws — if the filesystem read fails, the hooks stay disabled.
+const TEST_HOOKS_SENTINEL = join(DIR, "test-hooks.enabled");
+export function testHooksEnabled(): boolean {
+  try {
+    if (testHooksBuildOptIn()) return true;
+    if ((process.env.KICKBACKS_TEST_HOOKS || process.env.VIBE_ADS_TEST_HOOKS)
+        && (process.env.KICKBACKS_TEST_HOOKS || process.env.VIBE_ADS_TEST_HOOKS) !== "0")
+      return true;
+    return existsSync(TEST_HOOKS_SENTINEL);
+  } catch { return false; }
+}
+
+// E2E-only advertiser-icon override. The closed-loop e2e (test-stack/e2e)
+// screenshot-verifies that a custom advertiser icon (the `data:` URI the
+// backend inlines from GCS — see the CSP/data-URI icon fix) actually renders
+// in the spinner overlay, NOT just the inline-"K" fallback. The debug-
+// injection ad is icon-less by default, so this env var lets the harness feed
+// a known inline icon to the debug ad. HARD-GATED to `data:image/...` URIs
+// only — never an external URL — so there is zero SSRF/exfiltration surface
+// and production (which never sets this var) is completely unaffected. Returns
+// "" when unset or not a data:image URI, in which case the ad renders the "K"
+// fallback exactly as before. Never throws.
+export function debugIconDataUri(): string {
+  try {
+    const v = process.env.KICKBACKS_E2E_ICON_DATA_URI
+      || process.env.VIBE_ADS_E2E_ICON_DATA_URI || "";
+    return /^data:image\//i.test(v) ? v : "";
+  } catch { return ""; }
+}
+
+// Rolling-trim configuration. Without this the log grows unbounded — a
+// long-lived session can produce thousands of view_tick lines an hour and
+// the file becomes unwieldy for grep + slow for the `/debug_logs`
+// monitors. Keep the last MAX_LOG_LINES lines; check every N writes so
+// the hot path stays append-only.
+export const MAX_LOG_LINES = 1000;
+const TRIM_CHECK_INTERVAL = 50;        // writes between size checks
+const TRIM_MIN_BYTES = 64 * 1024;      // skip the read if file is small enough
+let _writesSinceCheck = 0;
+
+/** Sliding-window trim: if the log has more than MAX_LOG_LINES lines,
+ *  drop the oldest until the count is back at MAX_LOG_LINES. Throttled
+ *  via a counter so dlog() stays append-only most of the time.
+ *
+ *  Writes directly to LOG via `writeFileSync` (overwrite-in-place). An
+ *  earlier version used `temp + renameSync` for atomicity, but the
+ *  rename fails on Windows whenever ANY process holds a handle to
+ *  LOG — including VS Code's editor when the user opens debug.log to
+ *  read it, OR a `tail -F` in another terminal. Symptom was a stuck
+ *  `debug.log.trimming` temp file at trim-time size while debug.log
+ *  kept growing unbounded. Direct overwrite is non-atomic (a crash
+ *  mid-write could truncate the file), but for a rolling debug log
+ *  that tradeoff is acceptable in exchange for the trim actually
+ *  working under normal Windows usage. Best-effort; never throws.
+ *
+ *  Also cleans up any prior `.trimming` temp from the old rename-based
+ *  implementation so the user's home dir doesn't accumulate stale
+ *  artifacts after the upgrade. */
+function maybeTrimLog(): void {
+  _writesSinceCheck++;
+  if (_writesSinceCheck < TRIM_CHECK_INTERVAL) return;
+  _writesSinceCheck = 0;
+  try {
+    // Cheap size gate: skip the read for files that obviously can't
+    // exceed MAX_LOG_LINES yet. 1000 × ~200 B/line ≈ 200KB; 64KB is a
+    // safe lower bound. Avoids ~99% of unnecessary reads.
+    const size = statSync(LOG).size;
+    if (size < TRIM_MIN_BYTES) return;
+    const raw = readFileSync(LOG, "utf8");
+    // Count newlines without an intermediate array allocation.
+    let nl = 0;
+    for (let i = 0; i < raw.length; i++) if (raw.charCodeAt(i) === 10) nl++;
+    if (nl <= MAX_LOG_LINES) return;
+    // Split on \n; for a file ending in \n the last entry is "" (the
+    // trailing empty after the final newline). Keep the last
+    // MAX_LOG_LINES entries plus the trailing empty so the join
+    // reconstructs a file with the same trailing-newline shape.
+    const arr = raw.split("\n");
+    const trimmed = arr.slice(arr.length - 1 - MAX_LOG_LINES);
+    writeFileSync(LOG, trimmed.join("\n"));
+    // Clean up any leftover .trimming temp from the old atomic-rename
+    // implementation. Idempotent unlink (no-op if absent).
+    try { unlinkSync(LOG + ".trimming"); } catch { /* never existed or already gone */ }
+  } catch { /* trim is best-effort; never disrupt the extension */ }
+}
+
+// Lifecycle allowlist: these events log even when the verbose firehose is
+// off (see the LOGGING POLICY header). Keep it to LOW-VOLUME, PII-FREE
+// auth / self-update / activation-health signals — never per-impression or
+// per-render events. Exact singletons + family prefixes; the prefixes cover
+// the `selfupdate.*` / `boot.cycle.*` variants and FUTURE `auth.*` events
+// (the auth client's refresh path is silent today — when it gains logging,
+// those events are lifecycle automatically). Exported for the unit test that
+// guards this set against drift.
+export const LIFECYCLE_EVENTS: ReadonlySet<string> = new Set([
+  "activate", "activate.fatal", "preflight",
+  "session.state", "cli.spinnerVerbs",
+]);
+const LIFECYCLE_PREFIXES = ["selfupdate.", "auth.", "boot.cycle."] as const;
+export function isLifecycleEvent(evt: string): boolean {
+  return LIFECYCLE_EVENTS.has(evt)
+    || LIFECYCLE_PREFIXES.some((p) => evt.startsWith(p));
+}
+
+let warned = false;
+/** Append one structured line. `data` is JSON-serialized defensively.
+ *  Writes when the verbose firehose is enabled OR the event is a lifecycle
+ *  event (always-on). */
+export function dlog(src: "ext" | "webview", evt: string,
+                     data?: Record<string, unknown>,
+                     opts?: { level?: "info" | "debug"; corr?: string }): void {
+  try {
+    if (!debugEnabled() && !isLifecycleEvent(evt)) return;
+    mkdirSync(DIR, { recursive: true });
+    const level = opts?.level ?? "info";
+    const corr = opts?.corr ? opts.corr : "-";
+    let payload = "";
+    try { payload = data ? " " + JSON.stringify(data) : ""; }
+    catch { payload = ' {"_unserializable":true}'; }
+    appendFileSync(LOG,
+      new Date().toISOString() + " [" + src + "] " + level + " " + corr
+        + " " + evt + payload + "\n");
+    maybeTrimLog();
+  } catch {
+    // Logging must never disrupt the extension. Note once to stderr only.
+    if (!warned) { warned = true; try { process.stderr.write("vibe-ads: dlog disabled\n"); } catch { /* ignore */ } }
+  }
+}
+
+/** Persist a raw line the webview relayed (already JSON text). */
+export function dlogRaw(line: string): void {
+  try {
+    if (!debugEnabled()) return;
+    mkdirSync(DIR, { recursive: true });
+    appendFileSync(LOG,
+      new Date().toISOString() + " [webview] info - " + line.slice(0, 8000) + "\n");
+    maybeTrimLog();
+  } catch { /* never disrupt */ }
+}
+
+/** TEST-ONLY: force a trim pass and reset the throttle. Lets the test
+ *  suite assert the line-cap behaviour without writing 50× to push past
+ *  the throttle. Production callers should never use this. */
+export function _forceTrimLogForTest(): void {
+  _writesSinceCheck = TRIM_CHECK_INTERVAL;
+  maybeTrimLog();
+}
+
+export const LOG_PATH = LOG;
diff --git a/src/loopback.ts b/src/loopback.ts
new file mode 100644
index 0000000..f592902
--- /dev/null
+++ b/src/loopback.ts
@@ -0,0 +1,348 @@
+import * as vscode from "vscode";
+import { createServer, Server } from "node:http";
+import { randomBytes } from "node:crypto";
+import { dlog, dlogRaw } from "./log";
+import { type AdSurface, parseAdSurface } from "./types/surface";
+
+/** The base URL the WEBVIEW must use to reach this loopback. On VS Code
+ *  Remote/Server the webview runs on the *client*, so raw 127.0.0.1 (the
+ *  remote host's loopback) is unreachable; vscode.env.asExternalUri returns a
+ *  tunneled URL the client can reach (identity no-op on local desktop).
+ *  Always includes the /vibe-ads/<token> path so the block uses it verbatim.
+ *  Falls back to 127.0.0.1 if the API is unavailable/throws. */
+export async function resolveLoopbackBase(
+  port: number, token: string): Promise<string> {
+  const local = `http://127.0.0.1:${port}`;
+  try {
+    const ext = await vscode.env.asExternalUri(vscode.Uri.parse(local));
+    return ext.toString().replace(/\/+$/, "") + "/vibe-ads/" + token;
+  } catch {
+    return local + "/vibe-ads/" + token;
+  }
+}
+
+export type LoopbackMetricKind =
+  | "impression_rendered"
+  | "impression_viewable"
+  | "view_tick"
+  | "view_threshold_met"
+  | "error_impression";
+
+export interface LoopbackMetricPayload {
+  surface?: AdSurface;
+  visibleMs?: number;
+  sessionNonce?: string;
+  eventUuid?: string;
+  viewable?: boolean;
+  viewPct?: number;
+  viewMs?: number;
+  /** The ad the webview CLAIMS this event belongs to (`ad=` query param on
+   *  the block's metric GETs). For up to one 10s /ad poll after a rotation
+   *  the webview is still running the OLD ad's view sessions, so the host
+   *  must not stamp attribution from its current activeAd (audit #17). The
+   *  deployed block sends the ad TEXT here (its view sessions key on AD);
+   *  hosts resolve either form against their recently-served registry.
+   *  Deliberately NOT named adId: downstream senders spread this payload
+   *  over already-resolved billing fields. */
+  claimedAdId?: string;
+}
+
+export interface LoopbackAdPayload {
+  adText: string;
+  clickUrl: string;
+  iconUrl: string;
+  adId: string;
+  campaignId: string;
+}
+
+export interface LoopbackHandlers {
+  onEvent: (kind: LoopbackMetricKind, payload: LoopbackMetricPayload) => void;
+  onClick: (clickToken: string,
+    surface?: AdSurface,
+    visibleMs?: number,
+    eventUuid?: string,
+    /** `ad=` query param — see LoopbackMetricPayload.claimedAdId. */
+    claimedAdId?: string) => void;
+  getActivity: () => Record<string, unknown>;
+  getCurrentAd: () => LoopbackAdPayload | null;
+  /** Optional: every webview-relayed /log POST is mirrored here in addition
+   *  to being appended to ~/.vibe-ads/debug.log via dlogRaw. Lets extension-
+   *  scope code observe block lifecycle events (e.g., `block.start`) so we
+   *  can detect "patch applied but webview never picked it up" desync
+   *  without having to re-parse the debug.log file. Safe to leave unset. */
+  onWebviewLog?: (raw: string) => void;
+  /** Optional E2E driver surface. When set, the loopback exposes
+   *  `GET /test/<name>?…` routes that delegate to this handler. Used by the
+   *  kickbacks.test.* hooks so external scripts (curl, CI runners) can fire
+   *  impressions / clicks / views and read the result without needing a
+   *  running VS Code command host. Token-gated by the same loopback prefix.
+   *  Self-gated at the handler level (testHooksEnabled() inside TestHooks),
+   *  so removing the sentinel mid-session disables the routes on next call. */
+  onTestRoute?: (name: string, params: URLSearchParams) =>
+    Promise<{ status: number; body: unknown }>;
+}
+
+/** Loopback startup options. When `token` and/or `preferredPort` are
+ *  passed, the loopback reuses them — the URL baked into the webview
+ *  block stays stable across activations, so a webview that loaded a
+ *  prior session's patched index.js can still reach the current
+ *  ext-host. Without this, every ext-host restart produced a new token
+ *  + port, the running webview's anchors pointed at a torn-down
+ *  loopback, and clicks + telemetry vanished silently (the "patched
+ *  but webview cached" desync). Both default to per-call random + OS-
+ *  assigned, preserving the old behavior for callers that don't opt in. */
+export interface LoopbackStartOpts {
+  /** Reuse a stable token instead of minting a fresh random one each
+   *  call. Caller is responsible for persistence (typically
+   *  globalState). When omitted, a per-call random token is used. */
+  token?: string;
+  /** Try this port first; if `EADDRINUSE`, retry the next N ports
+   *  (`preferredPortRange`, default 4) and finally fall back to OS-
+   *  assigned (port=0). When omitted, OS-assigned is used directly. */
+  preferredPort?: number;
+  /** How many sequential ports to try starting at `preferredPort`
+   *  before giving up and falling back to OS-assigned. */
+  preferredPortRange?: number;
+}
+
+export class Loopback {
+  private server: Server | null = null;
+  private token = "";
+  constructor(private h: LoopbackHandlers) {}
+
+  /** True while the underlying HTTP server is bound and accepting
+   *  connections. Lets bootLoopback() detect a stale shared server (owner
+   *  already stopped) and fall through to a fresh bind. */
+  isRunning(): boolean { return !!this.server && this.server.listening; }
+
+  /** The handler set this instance was constructed with. Read by
+   *  bootLoopback() to lift a later caller's wiring onto the ONE shared
+   *  server (audit #7). */
+  handlers(): LoopbackHandlers { return this.h; }
+
+  /** Live handler swap (audit #7): the request closure dispatches through
+   *  `this.h` at request time, so replacing it re-routes every route —
+   *  /ad, /activity, the metric relays, /click, /log, /test — without
+   *  rebinding the port. Used by bootLoopback() so the production webview
+   *  wiring can take over ad-serving from the debug stub on the single
+   *  shared server instead of EADDRINUSE-ing onto stablePort+1. */
+  setHandlers(h: LoopbackHandlers): void { this.h = h; }
+
+  start(opts: LoopbackStartOpts = {}): Promise<{ port: number; token: string }> {
+    this.token = opts.token && /^[0-9a-f]{16,}$/i.test(opts.token)
+      ? opts.token
+      : randomBytes(16).toString("hex");
+    const prefix = `/vibe-ads/${this.token}/`;
+    this.server = createServer((req, res) => {
+      // 2A-M02: bound per-connection lifetime. A misbehaving webview that
+      // opens a request and never sends data (or sends headers and stalls)
+      // would otherwise park a connection until process exit, leaking fds.
+      // Loopback is 127.0.0.1-only and token-gated; values are picked to
+      // tolerate slow disks under /activity (LogTail.current readSync).
+      try {
+        if (typeof req.setTimeout === "function") req.setTimeout(10000);
+      } catch { /* never block the request loop on timeout config */ }
+      try {
+        // CORS: in VS Code 1.120+ the chat webview runs from a
+        // `vscode-webview://` origin and Chromium enforces CORS on its fetch
+        // to this http loopback. Without ACAO every relay (impressions,
+        // clicks, activity, debug log) is browser-blocked → billing telemetry
+        // silently dies. `dlog`'s `content-type: application/json` POST is a
+        // non-simple request, so the webview fires an OPTIONS preflight first;
+        // it must succeed independently of the token path. Loopback is bound
+        // to 127.0.0.1 and still token-gated for real routes, so `*` here only
+        // widens who may *read the response*, not who can reach the port.
+        res.setHeader("Access-Control-Allow-Origin", "*");
+        if (req.method === "OPTIONS") {
+          res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+          res.setHeader("Access-Control-Allow-Headers", "content-type");
+          res.setHeader("Access-Control-Max-Age", "86400");
+          res.statusCode = 204; res.end(); return;
+        }
+        const url = new URL(req.url || "/", "http://127.0.0.1");
+        if (!url.pathname.startsWith(prefix)) { res.statusCode = 404; res.end(); return; }
+        const route = url.pathname.slice(prefix.length);
+        if (route === "activity") {
+          res.setHeader("content-type", "application/json");
+          res.end(JSON.stringify(this.h.getActivity())); return;
+        }
+        if (route === "ad") {
+          res.setHeader("content-type", "application/json");
+          res.end(JSON.stringify(this.h.getCurrentAd() ?? {})); return;
+        }
+        if (route === "impression_rendered" || route === "impression_viewable"
+            || route === "view_tick" || route === "view_threshold_met"
+            || route === "error_impression") {
+          // Mirror the loopback.click trace: every impression / view-
+          // tick that reaches the extension leaves a log entry BEFORE
+          // dedupe + onEvent dispatch, so an operator grepping the
+          // debug log can see what the webview actually pinged
+          // (separate from what the dedupe layer let through).
+          const payload = metricPayload(route, url);
+          dlog("ext", "loopback.event",
+            { route, surface: payload.surface || "",
+              visibleMs: payload.visibleMs, eventUuid: payload.eventUuid },
+            { corr: url.searchParams.get("corr") || "" });
+          this.h.onEvent(route, payload);
+          res.statusCode = 204; res.end(); return;
+        }
+        if (route === "click") {
+          const sRaw = url.searchParams.get("surface") || "";
+          const s = parseAdSurface(sRaw);
+          // visible_ms is included by the webview block so the extension
+          // can apply the click-threshold floor (anti-misclick / anti-bot).
+          // Parse defensively: NaN / negative => treat as 0 (unknown).
+          const vmsRaw = Number(url.searchParams.get("visible_ms") || "");
+          const visibleMs = Number.isFinite(vmsRaw) && vmsRaw >= 0
+            ? Math.floor(vmsRaw) : 0;
+          const eventUuid = parseEventUuid(url.searchParams.get("event_uuid"));
+          // Audit #17: lift the claimed ad (when the block sends it) so the
+          // host can bill a click on the OLD ad's anchor — emitted during the
+          // 10s /ad poll lag after a rotation — to the OLD campaign.
+          const claimedAdId = url.searchParams.get("ad") || undefined;
+          dlog("ext", "loopback.click", { ct: url.searchParams.get("ct") || "",
+            surface: s || "", visibleMs, eventUuid },
+            { corr: url.searchParams.get("corr") || "" });
+          this.h.onClick(url.searchParams.get("ct") || "", s, visibleMs,
+            eventUuid, claimedAdId);
+          res.statusCode = 204;
+          res.end(); return;
+        }
+        if (route === "log") {
+          // Webview block relays its lifecycle here (the only channel out of
+          // the sandbox). Proves the loopback is reachable when lines appear.
+          // 2A-M01: cap BEFORE concatenation, not after. The prior
+          // body += c; if (body.length > 16000) req.destroy() let a single
+          // chunk push body up to chunk-size + 16000 before destroying.
+          let body = "";
+          let overflowed = false;
+          req.on("data", (c) => {
+            if (overflowed) return;
+            const cs = String(c);
+            if (body.length + cs.length > 16000) {
+              overflowed = true;
+              req.destroy();
+              return;
+            }
+            body += cs;
+          });
+          req.on("end", () => {
+            if (overflowed) return;
+            dlogRaw(body);
+            try { this.h.onWebviewLog?.(body); }
+            catch { /* observer best-effort; never affects the log relay */ }
+          });
+          res.statusCode = 204; res.end(); return;
+        }
+        // E2E test-hook driver routes: GET /test/<name>?…. Only handled when
+        // a host wired an onTestRoute (see LoopbackHandlers). The handler is
+        // itself responsible for the testHooksEnabled() gate — returns 403
+        // with ok:false when off, so a removed sentinel mid-session is
+        // observed on the very next call without restarting the server.
+        if (route.startsWith("test/") && this.h.onTestRoute) {
+          const name = route.slice("test/".length);
+          this.h.onTestRoute(name, url.searchParams).then((r) => {
+            res.statusCode = r.status;
+            res.setHeader("content-type", "application/json");
+            res.end(JSON.stringify(r.body));
+          }).catch((e) => {
+            dlog("ext", "loopback.test_route_error",
+              { name, msg: e instanceof Error ? e.message : String(e) });
+            try { res.statusCode = 500; res.end(); } catch { /* ignore */ }
+          });
+          return;
+        }
+        res.statusCode = 404; res.end();
+      } catch (e) {
+        dlog("ext", "loopback.handler_error",
+          { msg: e instanceof Error ? e.message : String(e) });
+        try { res.statusCode = 500; res.end(); } catch { /* ignore */ }
+      }
+    });
+    // Build the candidate port list: preferredPort (if any) + N-1
+    // sequential fallbacks, then 0 (OS-assigned) as the final fallback.
+    // Each EADDRINUSE moves to the next candidate; any other listen
+    // error resolves with port=-1 (same fail-safe shape as before).
+    const candidates: number[] = [];
+    if (typeof opts.preferredPort === "number" && opts.preferredPort > 0) {
+      const range = Math.max(1, opts.preferredPortRange ?? 4);
+      for (let i = 0; i < range; i++)
+        candidates.push(opts.preferredPort + i);
+    }
+    candidates.push(0);
+
+    return new Promise((resolveP) => {
+      const tryNext = (i: number): void => {
+        if (!this.server) return resolveP({ port: -1, token: this.token });
+        const port = candidates[i];
+        const onErr = (e: NodeJS.ErrnoException): void => {
+          // EADDRINUSE on a preferred port → try the next candidate.
+          // Any other error → give up (same fail-safe shape as the
+          // pre-refactor handler).
+          if (e && (e.code === "EADDRINUSE" || e.code === "EACCES")
+              && i + 1 < candidates.length) {
+            try { this.server!.removeListener("error", onErr); }
+            catch { /* ignore */ }
+            // The server is already closed by the failed listen; we can
+            // re-issue listen() on the same server instance.
+            tryNext(i + 1);
+          } else {
+            resolveP({ port: -1, token: this.token });
+          }
+        };
+        this.server!.once("error", onErr);
+        this.server!.listen(port, "127.0.0.1", () => {
+          try { this.server!.removeListener("error", onErr); }
+          catch { /* ignore */ }
+          const addr = this.server!.address();
+          const bound = typeof addr === "object" && addr ? addr.port : -1;
+          resolveP({ port: bound, token: this.token });
+        });
+      };
+      tryNext(0);
+    });
+  }
+
+  stop(): Promise<void> {
+    return new Promise((resolveP) => {
+      if (!this.server) return resolveP();
+      this.server.close(() => resolveP());
+      this.server = null;
+    });
+  }
+}
+
+const EVENT_UUID_RE =
+  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function parseEventUuid(raw: string | null): string | undefined {
+  return raw && EVENT_UUID_RE.test(raw) ? raw : undefined;
+}
+
+function metricPayload(route: string, url: URL): LoopbackMetricPayload {
+  const surfaceRaw = url.searchParams.get("surface") || "";
+  const surface = parseAdSurface(surfaceRaw);
+  const visible = Number(url.searchParams.get("visible_ms") || "");
+  const visibleMs = Number.isFinite(visible) && visible >= 0
+    ? Math.floor(visible)
+    : undefined;
+  const sessionNonce = url.searchParams.get("session") || undefined;
+  const eventUuid = parseEventUuid(url.searchParams.get("event_uuid"));
+  const claimedAdId = url.searchParams.get("ad") || undefined;
+  const payload: LoopbackMetricPayload = {};
+  if (surface) payload.surface = surface;
+  if (typeof visibleMs === "number") payload.visibleMs = visibleMs;
+  if (sessionNonce) payload.sessionNonce = sessionNonce;
+  if (eventUuid) payload.eventUuid = eventUuid;
+  if (claimedAdId) payload.claimedAdId = claimedAdId;
+  if (route === "view_threshold_met" || route === "error_impression") {
+    // error_impression is the 5 s safety-net cap from block.asset.js
+    // (MAX_SESSION_MS). It represents a session that DID stay visible —
+    // the bill should reflect a fully-viewable impression, same shape
+    // as view_threshold_met.
+    payload.viewable = true;
+    payload.viewPct = 100;
+    if (typeof visibleMs === "number") payload.viewMs = visibleMs;
+  }
+  return payload;
+}
diff --git a/src/metrics/client.ts b/src/metrics/client.ts
new file mode 100644
index 0000000..8d884dd
--- /dev/null
+++ b/src/metrics/client.ts
@@ -0,0 +1,155 @@
+import { randomUUID } from "node:crypto";
+import { type AdSurface } from "../types/surface";
+import { dlog } from "../log";
+import { timeoutFetch } from "../util/http";
+
+export type { AdSurface };
+
+type Fetch = typeof fetch;
+// W3 viewership: `view_tick` is a 5s heartbeat fired while an ad accumulates
+// visible time; `view_threshold_met` fires exactly once per ad-surface-
+// session when cumulative visible time crosses the configured threshold
+// (default 15s, server-overridable via portfolio.view_threshold_seconds).
+// `error_impression` is the MAX_SESSION_MS safety-net fire (default 5 s) —
+// once per session if the natural session-close never lands so a stuck ad
+// still bills. Billable view-family events share a backend cooldown bucket so
+// only one credit can move inside each cooldown window.
+export type MetricEvent =
+  | "impression_rendered"
+  | "impression_viewable"
+  | "prompt_view"
+  | "click"
+  | "view_tick"
+  | "view_threshold_met"
+  | "error_impression";
+
+const EVENT_UUID_RE =
+  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+export function newMetricEventUuid(): string {
+  return randomUUID();
+}
+
+// Deliberate sign-out hook: cmdSignOut calls noteMetricsSignOut() so the
+// `demoted:true` stamp (which marks mid-session token DEATH) is not applied
+// to demo traffic after an intentional sign-out. Module-scoped, matching the
+// adRotation clear hook: exactly one MetricsClient serves an extension host.
+let liveSignOutReset: (() => void) | null = null;
+export function noteMetricsSignOut(): void {
+  liveSignOutReset?.();
+}
+
+/** POSTs the S2 /v1/metrics contract (required keys
+ *  event_type,ad_id,campaign_id,client_id,ts,nonce; server-authoritative on
+ *  tier/measurement). Best-effort: never throws. */
+export class MetricsClient {
+  // Demo-route demotion tracking: routing keys purely on token absence, so a
+  // mid-session token death silently re-routes REAL ad ids to the demo
+  // endpoint. `wasAuthed` lets a demoted (was-signed-in) send be stamped so
+  // it stays distinguishable from genuine signed-out demo traffic;
+  // `demotionLogged` makes the first demotion of each outage observable in
+  // the debug log. Routing itself (money semantics) is unchanged.
+  private wasAuthed = false;
+  private demotionLogged = false;
+  constructor(private base: string, private token: () => string | null,
+              private clientId: () => string, private extVersion: string,
+              private f: Fetch = timeoutFetch(15000),
+              // Client-environment fingerprint (os/arch/os_version/editor),
+              // computed once at activation and sent on every beacon so the
+              // backend can segment ad traffic by client type. Transparent —
+              // see app/admin Traffic; nothing here is hidden or obfuscated.
+              private clientEnv?: Record<string, unknown>,
+              // Fleet-signal sink: the backend piggybacks `kill` (every
+              // response) and `balances` (authed + billed) on the beacon
+              // response, replacing standalone polls. Optional + best-effort.
+              private signals: { noteKill(raw: unknown): void;
+                                 noteBalances(raw: unknown): void }
+                | null = null) {
+    liveSignOutReset = () => {
+      this.wasAuthed = false;
+      this.demotionLogged = false;
+    };
+  }
+
+  async send(event: MetricEvent,
+             a: { adId: string; campaignId: string; ccVersion: string;
+                  corr?: string; surface?: AdSurface; visibleMs?: number;
+                  sessionNonce?: string; viewable?: boolean;
+                  viewPct?: number; viewMs?: number;
+                  sessionToken?: string; eventUuid?: string }): Promise<void> {
+    try {
+      const eventUuid = a.eventUuid && EVENT_UUID_RE.test(a.eventUuid)
+        ? a.eventUuid
+        : newMetricEventUuid();
+      const body: Record<string, unknown> = {
+        event_type: event, ad_id: a.adId, campaign_id: a.campaignId,
+        client_id: this.clientId(), ts: new Date().toISOString(),
+        claude_code_version: a.ccVersion, extension_version: this.extVersion,
+        nonce: eventUuid,
+      };
+      // W3 viewership additions — only included when present so we don't
+      // pollute legacy event shapes (impression_*, click) with empty fields.
+      if (a.surface) body.surface = a.surface;
+      if (typeof a.visibleMs === "number") body.visible_ms = a.visibleMs;
+      if (a.sessionNonce) body.session_nonce = a.sessionNonce;
+      if (typeof a.viewable === "boolean") body.viewable = a.viewable;
+      if (typeof a.viewPct === "number") body.view_pct = a.viewPct;
+      if (typeof a.viewMs === "number") body.view_ms = a.viewMs;
+      if (a.sessionToken) body.session_token = a.sessionToken;
+      if (this.clientEnv) body.ext = this.clientEnv;
+      const t = this.token();
+      // Demo routing: a tokenless (signed-out) send goes to the public
+      // /v1/metrics/demo — it charges the advertiser but credits no user (the
+      // platform keeps 100%). The signed-in path is byte-identical to before.
+      // The `client_id` already in `body` is the demo identity anchor that the
+      // backend pairs with the demo session token. One switch here covers every
+      // surface (overlay + status bar); signed-out used to send nothing at all.
+      const path = t ? "/v1/metrics" : "/v1/metrics/demo";
+      if (t) {
+        this.wasAuthed = true;
+        this.demotionLogged = false;
+      } else {
+        // Stamp every demo-route send inside `ext` — the only schema-allowed
+        // free-form field (the backend 400s unknown top-level keys). A send
+        // demoted by a mid-session token death additionally carries
+        // `demoted:true` so analytics can tell it apart from genuine
+        // signed-out demo traffic.
+        const demoted = this.wasAuthed;
+        body.ext = { ...(this.clientEnv ?? {}), demo: true,
+          ...(demoted ? { demoted: true } : {}) };
+        if (demoted && !this.demotionLogged) {
+          this.demotionLogged = true;
+          dlog("ext", "metric.demo_demoted", { event, adId: a.adId });
+        }
+      }
+      const res = await this.f(`${this.base}${path}`, {
+        method: "POST",
+        headers: { "content-type": "application/json",
+          ...(t ? { authorization: `Bearer ${t}` } : {}),
+          // W1 rename: send the new header AND the legacy one for one release
+          // so the backend dual-accept can be staged independently.
+          ...(a.corr ? { "X-Kickbacks-Corr": a.corr, "X-Vibe-Corr": a.corr } : {}) },
+        body: JSON.stringify(body),
+      });
+      if (!res.ok) {
+        dlog("ext", "metric.send_failed", { status: res.status, event });
+      } else if (this.signals) {
+        // Fleet signals: only a FRESH 2xx body may produce a verdict; a
+        // failed/old-backend response body (empty, non-JSON, no fields)
+        // simply yields nothing. Balances only when this send was authed —
+        // a demo-route response never carries them and must never repaint
+        // a signed-out status bar.
+        try {
+          const j = await res.json() as { kill?: unknown; balances?: unknown };
+          this.signals.noteKill(j?.kill);
+          if (t) this.signals.noteBalances(j?.balances);
+        } catch { /* old backend / empty body — no signal */ }
+      }
+    } catch (e) {
+      // Still best-effort (never throws past here) — but a network-level
+      // failure must stay greppable, not a total observability blackout.
+      dlog("ext", "metric.send_error",
+        { event, msg: e instanceof Error ? e.message : String(e) });
+    }
+  }
+}
diff --git a/src/metrics/dedupe.ts b/src/metrics/dedupe.ts
new file mode 100644
index 0000000..07e16df
--- /dev/null
+++ b/src/metrics/dedupe.ts
@@ -0,0 +1,16 @@
+/** One impression per (kind, surface, adId) for the lifetime of a loopback
+ *  session. Same ad on overlay vs banner vs codex_overlay vs statusline are
+ *  distinct visual impressions and each fires once. Server-side credit_gate
+ *  (keyed by user+ad+event_type) still prevents the user from being
+ *  double-credited if the same ad shows on multiple surfaces. Clicks are NOT
+ *  routed through this. */
+export class ImpressionDedupe {
+  private readonly seen = new Set<string>();
+  shouldSend(kind: string, adId: string, surface?: string): boolean {
+    const key = kind + ":" + (surface || "default") + ":" + adId;
+    if (this.seen.has(key)) return false;
+    this.seen.add(key);
+    return true;
+  }
+  reset(): void { this.seen.clear(); }
+}
diff --git a/src/modes.ts b/src/modes.ts
new file mode 100644
index 0000000..b580311
--- /dev/null
+++ b/src/modes.ts
@@ -0,0 +1,57 @@
+import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { BannerOverride } from "./banner";
+
+/** Dev-only overrides for the three injection surfaces, backed by sentinel
+ *  files under ~/.vibe-ads/ (headless parity with debug.enabled / banner.txt).
+ *  Every function is guarded — fs failure ⇒ safe default, never throws. */
+function dir(): string { return join(homedir(), ".vibe-ads"); }
+
+function ensureDir(): void {
+  try { mkdirSync(dir(), { recursive: true }); } catch { /* ignore */ }
+}
+
+/** Presence of <name> ⇒ true. */
+function present(name: string): boolean {
+  try { return existsSync(join(dir(), name)); } catch { return false; }
+}
+
+function setPresent(name: string, on: boolean): void {
+  try {
+    const p = join(dir(), name);
+    if (on) { ensureDir(); writeFileSync(p, ""); }
+    else rmSync(p, { force: true });
+  } catch { /* ignore */ }
+}
+
+/** Default ON; ~/.vibe-ads/webview.off forces OFF. */
+export function webviewMode(): "on" | "off" {
+  return present("webview.off") ? "off" : "on";
+}
+export function setWebviewMode(on: boolean): void {
+  setPresent("webview.off", !on);
+}
+
+/** Default ON; ~/.vibe-ads/cli.off forces OFF. */
+export function cliMode(): "on" | "off" {
+  return present("cli.off") ? "off" : "on";
+}
+export function setCliMode(on: boolean): void {
+  setPresent("cli.off", !on);
+}
+
+/** Default "server"; ~/.vibe-ads/banner.mode content ∈ {server,on,off}. */
+export function bannerOverride(): BannerOverride {
+  try {
+    const raw = readFileSync(join(dir(), "banner.mode"), "utf8").trim();
+    return raw === "on" || raw === "off" || raw === "server" ? raw : "server";
+  } catch { return "server"; }
+}
+export function setBannerOverride(o: BannerOverride): void {
+  try {
+    const p = join(dir(), "banner.mode");
+    if (o === "server") rmSync(p, { force: true });
+    else { ensureDir(); writeFileSync(p, o); }
+  } catch { /* ignore */ }
+}
diff --git a/src/portfolio/client.ts b/src/portfolio/client.ts
new file mode 100644
index 0000000..f9999ab
--- /dev/null
+++ b/src/portfolio/client.ts
@@ -0,0 +1,233 @@
+import { dlog } from "../log";
+import { timeoutFetch } from "../util/http";
+
+export interface PatchAd {
+  adId: string; campaignId: string; adText: string;
+  iconRef: string; iconUrl: string; clickUrl: string; bannerEnabled: boolean;
+  sessionToken: string;
+  /** True when this ad came from the signed-out DEMO portfolio. Demo ads
+   *  render on every surface exactly like a real ad and their click opens the
+   *  real advertiser URL, but their metrics route to /v1/metrics/demo (the
+   *  advertiser is charged, no user is credited). Absent/false ⇒ a normal,
+   *  user-crediting ad. */
+  demo?: boolean;
+}
+
+export interface PortfolioBalances {
+  lifetimeUsd: string;
+  todayUsd: string;
+  lastUpdatedMs: number;
+}
+
+export interface PortfolioResponse {
+  ad: PatchAd | null;
+  ads: PatchAd[];
+  queueId: string;
+  ttlMs: number;
+  rotationIntervalMs: number;
+  viewThresholdMs: number;
+  balances: PortfolioBalances | null;
+}
+
+type Fetch = typeof fetch;
+
+/** The slice of FleetSignals the wire clients feed (fleet-chattiness fix):
+ *  raw piggybacked fields off a FRESH 2xx body. Implementations parse
+ *  defensively and must never throw back into the fetch path. */
+export interface FleetSignalSink {
+  noteKill(raw: unknown): void;
+  noteBalances(raw: unknown): void;
+}
+
+const DEFAULT_VIEW_THRESHOLD_MS = 3_000;
+
+function safeHttpUrl(value: string): string {
+  try {
+    const u = new URL(value);
+    return (u.protocol === "https:" || u.protocol === "http:") ? u.toString() : "";
+  } catch {
+    return "";
+  }
+}
+
+/** Consumes S2's /v1/portfolio shape. W4-extended fields are optional so an
+ *  older backend still works: queue_id, view_threshold_seconds, balances. */
+export class PortfolioClient {
+  private cache: { resp: PortfolioResponse; expiresAt: number } | null = null;
+  private demoCache: { resp: PortfolioResponse; expiresAt: number } | null = null;
+  constructor(private base: string, private token: () => string | null,
+              private f: Fetch = timeoutFetch(15000),
+              private signals: FleetSignalSink | null = null) {}
+
+  async fetchAd(ccVersion: string): Promise<PatchAd | null> {
+    const r = await this.fetchPortfolio(ccVersion);
+    return r?.ad ?? null;
+  }
+
+  /** W4 queue-aware fetch. Returns the full response so callers can manage
+   *  a local queue (drain to depth N, then refetch) and surface the server-
+   *  authoritative balances + view threshold. `campaignId` (the active ad's
+   *  campaign, optional) scopes the piggybacked kill verdict so campaign
+   *  kills propagate exactly like the standalone /v1/killswitch poll. */
+  async fetchPortfolio(ccVersion: string,
+                       campaignId = ""): Promise<PortfolioResponse | null> {
+    const url = `${this.base}/v1/portfolio?claude_code_version=${encodeURIComponent(ccVersion)}`
+      + (campaignId ? `&campaign=${encodeURIComponent(campaignId)}` : "");
+    return this._fetch(url, this.authHeaders(), false, () => this.cache,
+      (c) => { this.cache = c; });
+  }
+
+  /** Signed-out PREVIEW fetch: hits the public /v1/portfolio/demo with NO auth
+   *  header. Returns real engine ads (each stamped `demo: true`) whose session
+   *  tokens are bound to the demo:<clientId> namespace — so the surfaces render
+   *  exactly like the live product but metrics route to /v1/metrics/demo
+   *  (advertiser charged, no user credit). `clientId` is the stable device id
+   *  (auth.clientId()); without it the server returns an empty portfolio. */
+  async fetchDemoPortfolio(ccVersion: string, clientId: string,
+                           campaignId = ""): Promise<PortfolioResponse | null> {
+    const url = `${this.base}/v1/portfolio/demo`
+      + `?claude_code_version=${encodeURIComponent(ccVersion)}`
+      + `&client_id=${encodeURIComponent(clientId)}`
+      + (campaignId ? `&campaign=${encodeURIComponent(campaignId)}` : "");
+    return this._fetch(url, {}, true, () => this.demoCache,
+      (c) => { this.demoCache = c; });
+  }
+
+  private async _fetch(
+    url: string, headers: Record<string, string>, demo: boolean,
+    getCache: () => { resp: PortfolioResponse; expiresAt: number } | null,
+    setCache: (c: { resp: PortfolioResponse; expiresAt: number }) => void,
+  ): Promise<PortfolioResponse | null> {
+    try {
+      const r = await this.f(url, { headers });
+      if (!r.ok) throw new Error(`portfolio ${r.status}`);
+      const body = await r.json() as {
+        ttl_seconds: number;
+        view_threshold_seconds?: number;
+        rotation_interval_seconds?: number;
+        queue_id?: string;
+        balances?: { lifetime_usd?: string; today_usd?: string;
+                     last_updated_ms?: number };
+        ads: { ad_id: string; campaign_id: string; title_text: string;
+               icon_ref: string; icon_url?: string; click_url: string;
+               banner_enabled?: boolean; session_token?: string }[];
+      };
+      // Fleet signals fire HERE — the fresh-2xx parse path — and nowhere
+      // else. Critically NOT from the warm-cache fallback below (stale data
+      // must never refresh a kill verdict's timestamp) and BEFORE any
+      // caller-side early-return on empty inventory (a global kill empties
+      // the ad list — exactly the moment the verdict matters most).
+      this.signals?.noteKill((body as { kill?: unknown }).kill);
+      if (!demo) this.signals?.noteBalances(
+        (body as { balances?: unknown }).balances);
+      const ads: PatchAd[] = (body.ads || []).map((a) => ({
+        adId: a.ad_id, campaignId: a.campaign_id,
+        adText: a.title_text,
+        iconRef: a.icon_ref, iconUrl: a.icon_url || "", clickUrl: safeHttpUrl(a.click_url),
+        bannerEnabled: a.banner_enabled === true,
+        sessionToken: a.session_token || "",
+        ...(demo ? { demo: true } : {}),
+      }));
+      const balances: PortfolioBalances | null = body.balances
+        && typeof body.balances.lifetime_usd === "string"
+        && typeof body.balances.today_usd === "string"
+        ? { lifetimeUsd: body.balances.lifetime_usd,
+            todayUsd: body.balances.today_usd,
+            lastUpdatedMs: body.balances.last_updated_ms ?? Date.now() }
+        : null;
+      // Clamp server-supplied timing knobs (audit 2A-06). These drive
+      // setInterval periods and, via applyPatch, full rewrites of CC's 4.9 MB
+      // webview index.js. An out-of-range value (a bug or a hostile backend
+      // sending rotation_interval_seconds: 0.001) would otherwise become a
+      // per-millisecond disk-write loop on the host. viewThresholdMs is already
+      // floored downstream; rotation + ttl were not.
+      const ROTATION_FLOOR_MS = 15_000;          // never rewrite CC's file faster
+      const TTL_CEIL_MS = 60 * 60_000;           // 1h cap on cache lifetime
+      const rotationSec = body.rotation_interval_seconds;
+      const rawRotationMs = rotationSec ? rotationSec * 1000 : 120_000;
+      const rawTtlMs = (body.ttl_seconds || 0) * 1000;
+      const resp: PortfolioResponse = {
+        ad: ads[0] ?? null,
+        ads,
+        queueId: body.queue_id || "",
+        ttlMs: Math.min(TTL_CEIL_MS, Math.max(0, rawTtlMs)),
+        rotationIntervalMs: Math.max(ROTATION_FLOOR_MS, rawRotationMs),
+        viewThresholdMs: (body.view_threshold_seconds
+          ? body.view_threshold_seconds * 1000
+          : DEFAULT_VIEW_THRESHOLD_MS),
+        balances,
+      };
+      setCache({ resp, expiresAt: Date.now() + resp.ttlMs });
+      return resp;
+    } catch {
+      const c = getCache();
+      if (c && Date.now() < c.expiresAt) return c.resp;
+      return null;
+    }
+  }
+
+  private authHeaders(): Record<string, string> {
+    const t = this.token();
+    return t ? { authorization: `Bearer ${t}` } : {};
+  }
+}
+
+/** The slice of AuthClient that demo-fallback resolution needs. */
+export interface DemoFallbackAuth {
+  accessToken(): string | null;
+  clientId(): string;
+  refresh(): Promise<boolean>;
+}
+
+/** Resolve the activation-time portfolio, falling back to the DEMO portfolio
+ *  when the user is effectively signed out — INCLUDING the "token present but
+ *  rejected" case. loadCached() trusts a cached access token without validating
+ *  it, so a stale/expired token makes accessToken() truthy and the first real
+ *  fetch 401s → no ad → dead surface (the user sees the sign-in prompt, never
+ *  the demo preview). Here, when signed-in yields no ad we force one refresh:
+ *  it either re-mints a valid token (→ real, user-crediting ads), clears the
+ *  dead token on an explicit rejection (accessToken() → null → demo, so all the
+ *  downstream token-based routing — metrics → /v1/metrics/demo, rotation → demo
+ *  — lines up consistently), or fails TRANSIENTLY (offline/5xx: token kept,
+ *  null returned, NO demo demotion — audit #10). A genuinely signed-out caller
+ *  (no token) goes straight to demo and never triggers the refresh. Never
+ *  throws beyond its callees. */
+export async function fetchPortfolioWithDemoFallback(
+  portfolio: PortfolioClient,
+  auth: DemoFallbackAuth,
+  ccVersion: string,
+  campaignId = "",
+): Promise<PortfolioResponse | null> {
+  if (!auth.accessToken()) {
+    return portfolio.fetchDemoPortfolio(ccVersion, auth.clientId(), campaignId);
+  }
+  // Signed in (token present). A NON-NULL response — including a valid-but-empty
+  // portfolio (ads: []) — is authoritative; keep it. Empty inventory is normal
+  // and must NOT trigger a refresh: a failed refresh can clear the token, so
+  // doing it on every empty poll would needlessly sign a valid user out. Only a
+  // HARD null (network failure / non-2xx like 401, with no warm cache) is
+  // suspect — the cached access token may be dead (loadCached trusts it
+  // unvalidated). Then force one refresh: re-mint (→ real ads) or clear
+  // (→ accessToken() null → demo), which keeps every downstream token-based
+  // surface aligned.
+  const resp = await portfolio.fetchPortfolio(ccVersion, campaignId);
+  if (resp) return resp;
+  const refreshed = await auth.refresh();
+  if (refreshed) {
+    dlog("ext", "portfolio.demo_fallback", { reason: "token-revived" });
+    return portfolio.fetchPortfolio(ccVersion, campaignId);
+  }
+  // Failed refresh: TRANSIENT vs FATAL (audit #10). refresh() keeps the
+  // access token on transport failures (offline / timeout / 5xx) and clears
+  // it only on an explicit server rejection. A still-present token means the
+  // session may be perfectly valid (e.g. VS Code restored before Wi-Fi) —
+  // do NOT demote to demo (demo metrics credit no user); serve nothing this
+  // tick and let the caller's next refresh retry naturally. A cleared token
+  // is authoritative: route to demo, aligned with every downstream surface.
+  if (auth.accessToken()) {
+    dlog("ext", "portfolio.demo_fallback", { reason: "transient-refresh" });
+    return null;
+  }
+  dlog("ext", "portfolio.demo_fallback", { reason: "dead-token" });
+  return portfolio.fetchDemoPortfolio(ccVersion, auth.clientId(), campaignId);
+}
diff --git a/src/reassert.ts b/src/reassert.ts
new file mode 100644
index 0000000..5d43ae7
--- /dev/null
+++ b/src/reassert.ts
@@ -0,0 +1,88 @@
+/** Whether the injected webview patch should be (re)asserted on this timer
+ *  tick. The extension reapplies the block on an interval so a Claude Code
+ *  self-update / relaunch that silently overwrites index.js is healed without
+ *  the user doing a manual reload — but ONLY while the extension is healthy:
+ *  an ad in hand and not kill-switched. Pure; the single source of truth for
+ *  the reassert health gate. applyPatch itself is idempotent (writes only when
+ *  the file actually drifted), so a steady state is a cheap no-op and this
+ *  never fights the kill-switch (gated on `killed`).
+ *
+ *  Sign-in is intentionally NOT part of the gate: a signed-out user holding a
+ *  DEMO ad must reassert too, so the preview self-heals like the real product.
+ *  `haveAd` already implies an ad is in hand (real or demo) — when signed out
+ *  with no demo ad it is false, so the prior signed-out behaviour is preserved.
+ */
+export function shouldReassert(s: {
+  haveAd: boolean;
+  killed: boolean;
+}): boolean {
+  return s.haveAd && !s.killed;
+}
+
+/** Tiered self-heal policy for the "patched file on disk but the webview
+ *  cached the pre-patch module" desync (block.desync) — the failure mode
+ *  where ads silently stop rendering even though `isPatched()` is true, so
+ *  the cheap drift-only reasserts (reassertWebview / reassertTick) can't see
+ *  it. THIS is the case that forced a manual "Re-apply patch now".
+ *
+ *  Escalation ladder, cheapest + least disruptive first, and ONLY when there
+ *  is positive evidence the user is actively using Claude Code (recent
+ *  transcript writes) yet our overlay telemetry has gone silent. When the
+ *  user is simply idle there is NO block.start to expect — that is not a
+ *  desync, so we must not disrupt. Pure + the single source of truth so the
+ *  cadence stays testable and deliberately non-aggressive. */
+export const DESYNC_DEFAULTS = {
+  // CC transcript written within this window => the user is actively using
+  // Claude Code right now (independent of our overlay).
+  ccActiveMs: 120_000,
+  // Overlay silent at least this long (while CC is active) => treat as a
+  // real desync worth healing. Patience knob: high enough that a briefly
+  // unfocused panel or a normal lull never trips it.
+  silenceMs: 300_000,
+  toastCooldownMs: 30 * 60_000,
+} as const;
+
+export type DesyncAction = "none" | "cycle" | "reload" | "toast";
+
+export function desyncDecision(i: {
+  now: number;
+  startedAt: number;            // detector start (activation) — the silence floor
+  lastApplyAt: number;
+  lastBlockStartAt: number;     // last overlay render = our telemetry heartbeat
+  ccActivityAgeMs: number | null; // age of last CC transcript write; null = unknown
+  healthy: boolean;             // shouldReassert(): signed-in + have-ad + not-killed
+  cyclePatchTried: boolean;
+  reloadTried: boolean;
+  toastShownAt: number;
+  /** True while CC is in an active tool_use turn (e.g. a long-running
+   *  sub-agent the orchestrator is waiting on). Disruptive escalation
+   *  (reload, toast) is deferred until the turn completes so we never
+   *  interrupt an ongoing task. cycle (file-identity nudge only) is still
+   *  allowed. null/undefined = unknown → treated as not active. */
+  ccTurnActive?: boolean | null;
+}, k: { ccActiveMs: number; silenceMs: number; toastCooldownMs: number } = DESYNC_DEFAULTS):
+  { action: DesyncAction; reason: string } {
+  if (!i.healthy) return { action: "none", reason: "unhealthy" };
+  if (i.lastApplyAt === 0) return { action: "none", reason: "no-apply" };
+  // Overlay rendered since the last apply => in sync, nothing to heal.
+  if (i.lastBlockStartAt >= i.lastApplyAt) return { action: "none", reason: "in-sync" };
+  // Idle gate (the key non-aggression rule): act ONLY when CC is actively in
+  // use. No/unknown activity => a missing block.start is EXPECTED, not a desync.
+  if (i.ccActivityAgeMs == null || i.ccActivityAgeMs > k.ccActiveMs)
+    return { action: "none", reason: "cc-idle" };
+  // Patience: require sustained overlay silence before any disruption.
+  const silentMs = i.now - Math.max(i.lastBlockStartAt, i.startedAt);
+  if (silentMs < k.silenceMs) return { action: "none", reason: "within-grace" };
+  // Escalation ladder: cheap file-identity nudge, then webview reload, then
+  // (last resort, user-consented) a window reload.
+  if (!i.cyclePatchTried) return { action: "cycle", reason: "escalate" };
+  // Defer disruptive actions while a CC turn is actively running (sub-agent).
+  // reloadWebviewContent would interrupt the task; the toast would mislead
+  // the user into thinking a reload is safe right now. cycle (already fired)
+  // only touches the file identity — non-disruptive. When the turn finishes,
+  // the next tick re-evaluates and escalates to reload if still needed.
+  if (i.ccTurnActive === true) return { action: "none", reason: "cc-turn-active" };
+  if (!i.reloadTried) return { action: "reload", reason: "escalate" };
+  if (i.now - i.toastShownAt >= k.toastCooldownMs) return { action: "toast", reason: "escalate" };
+  return { action: "none", reason: "cooldown" };
+}
diff --git a/src/reloadSignal.ts b/src/reloadSignal.ts
new file mode 100644
index 0000000..3b32d9b
--- /dev/null
+++ b/src/reloadSignal.ts
@@ -0,0 +1,35 @@
+import { join } from "node:path";
+import { homedir } from "node:os";
+
+export interface ReloadSentinel { version: string; ts: string; }
+
+/** Fixed sentinel path shared by the deploy script and the extension watcher. */
+export function reloadSentinelPath(): string {
+  return join(homedir(), ".vibe-ads", "reload");
+}
+
+/** Parse the sentinel payload. Returns null for any malformed/half-written
+ *  input or a payload missing `version` — callers must never throw on this. */
+export function parseSentinel(raw: string): ReloadSentinel | null {
+  try {
+    const o = JSON.parse(raw) as Partial<ReloadSentinel>;
+    if (!o || typeof o.version !== "string" || o.version.length === 0) return null;
+    return { version: o.version, ts: typeof o.ts === "string" ? o.ts : "" };
+  } catch { return null; }
+}
+
+export type ReloadDecision = "reload-now" | "nudge" | "none";
+
+/** Pure decision: act only when the sentinel was touched strictly after this
+ *  activation (armedAt) AND its version differs from the running build. Both
+ *  actionable results ("reload-now"/"nudge") now map to the same soft
+ *  ext-host restart in the consumer (no window reload, no prompt); the enum
+ *  is kept for back-compat with existing callers/tests. "none" = no-op. */
+export function decideReload(p: {
+  mtimeMs: number; armedAt: number;
+  sentinelVersion: string; runningVersion: string; debug: boolean;
+}): ReloadDecision {
+  if (p.mtimeMs <= p.armedAt) return "none";
+  if (p.sentinelVersion === p.runningVersion) return "none";
+  return p.debug ? "reload-now" : "nudge";
+}
diff --git a/src/servingGate.ts b/src/servingGate.ts
new file mode 100644
index 0000000..61ee456
--- /dev/null
+++ b/src/servingGate.ts
@@ -0,0 +1,107 @@
+/** Central serving gate — the ONE predicate every ad-patch writer consults
+ *  before touching user files or handing an ad to a webview (wave 2, audit
+ *  2026-06-09 findings #3/#4/#6/#9/#14/#19).
+ *
+ *  Pre-fix, several independent timers (the 60s debug reassert, the rotation
+ *  tick, the 60s cliSync loop, boot-canary patching, the webview reasserts,
+ *  the live loopback /ad route) each re-applied the ad patch without
+ *  consulting the global gates, so a killed or user-disabled install
+ *  oscillated restore→re-patch forever. The gate owns the three inputs and
+ *  every writer asks it first:
+ *
+ *    1. kill posture — "clear" (healthy), "confirmed" (a 200 from
+ *       /v1/killswitch said killed:true), or "offline" (the endpoint was
+ *       unreachable / non-200 — we genuinely don't know). The split is the
+ *       kill HYSTERESIS: confirmed ⇒ restore everything; offline ⇒ FREEZE
+ *       (no restore, no new writes) so a wifi blip never churns the user's
+ *       Claude Code install while staying fail-closed for new writes.
+ *    2. the user master toggle — wired by extension.ts to the
+ *       DebugController's persisted intent. A deliberate "Disable Kickbacks"
+ *       / Restore command reads disabled here; the sign-out pause (K_ON
+ *       forced false but K_PRESIGNOUT remembered) stays ENABLED so the
+ *       signed-out demo flow keeps working (the Wave-1 sign-out contract).
+ *    3. crash-canary suspension — "skip automatic patch this run". Session-
+ *       scoped; lifted only by an explicit user re-enable (setOn(true)),
+ *       matching the canary toast's wording.
+ *
+ *  Module-scoped singleton (same idiom as adRotation's sign-out hook):
+ *  exactly one serving posture exists per extension host, and the writers
+ *  live in modules that don't all share an ActivationContext. activate()
+ *  resets it; tests reset via resetServingGate(). Every read is guarded —
+ *  the gate itself can never throw into a writer (prime directive). */
+
+export type KillPosture = "clear" | "confirmed" | "offline";
+
+/** What a periodic writer should do this tick:
+ *    "write"   — healthy + enabled + not suspended ⇒ normal apply path.
+ *    "freeze"  — offline-unsure or canary-suspended ⇒ neither write NOR
+ *                restore (keep the current on-disk state, keep checking).
+ *    "restore" — confirmed kill or user-disabled ⇒ tear the patch down. */
+export type ServingVerdict = "write" | "freeze" | "restore";
+
+const state = {
+  kill: "clear" as KillPosture,
+  enabled: (() => true) as () => boolean,
+  suspended: false,
+};
+
+function safeEnabled(): boolean {
+  try { return state.enabled(); } catch { return false; }
+}
+
+/** Back to the boot defaults (healthy, enabled, not suspended). Called at
+ *  the top of activate() so a reloaded host never inherits stale posture. */
+export function resetServingGate(): void {
+  state.kill = "clear";
+  state.enabled = () => true;
+  state.suspended = false;
+}
+
+/** Wire the user-master-toggle input (extension.ts: the DebugController's
+ *  persisted intent). Defaults to "enabled" so unit tests that exercise a
+ *  single subsystem in isolation see today's behavior unless they opt in. */
+export function wireServingGateEnabled(fn: () => boolean): void {
+  state.enabled = fn;
+}
+
+export function setKillPosture(p: KillPosture): void { state.kill = p; }
+export function killPosture(): KillPosture { return state.kill; }
+
+/** Crash-canary: no automatic patch writes for the rest of this session. */
+export function suspendServing(): void { state.suspended = true; }
+/** Explicit user re-enable (DebugController.setOn(true)) lifts it. */
+export function clearServingSuspension(): void { state.suspended = false; }
+export function servingSuspended(): boolean { return state.suspended; }
+
+/** Single source of truth for the per-tick writer decision. Precedence:
+ *  a CONFIRMED kill and a deliberate disable always win (restore); the
+ *  offline freeze and the canary suspension hold the line (no churn);
+ *  otherwise write. */
+export function servingVerdict(): ServingVerdict {
+  if (state.kill === "confirmed") return "restore";
+  if (!safeEnabled()) return "restore";
+  if (state.kill === "offline") return "freeze";
+  if (state.suspended) return "freeze";
+  return "write";
+}
+
+/** May a writer WRITE an ad patch (or start serving a new ad) right now? */
+export function canPatch(): boolean { return servingVerdict() === "write"; }
+
+/** May the live loopback keep handing /ad payloads to running webviews?
+ *  Stricter than "freeze": only a CONFIRMED kill or a deliberate disable
+ *  stops serving (the webview drops its overlay within one 10s poll).
+ *  Offline-unsure keeps serving the current ad — that's the freeze
+ *  contract — and the canary suspension only blocks PATCH writes (a stale
+ *  webview from a prior session may still legitimately poll). */
+export function canServeAds(): boolean {
+  return state.kill !== "confirmed" && safeEnabled();
+}
+
+/** Snapshot for UI painters that need to know WHY serving is off
+ *  (killed / offline / disabled-or-suspended). */
+export function servingGateSnapshot():
+  { kill: KillPosture; enabled: boolean; suspended: boolean } {
+  return { kill: state.kill, enabled: safeEnabled(),
+           suspended: state.suspended };
+}
diff --git a/src/sessionState.ts b/src/sessionState.ts
new file mode 100644
index 0000000..4bb0a9e
--- /dev/null
+++ b/src/sessionState.ts
@@ -0,0 +1,65 @@
+import { dlog } from "./log";
+
+/** Single source of truth for the cross-component session state that
+ *  several UI surfaces used to derive independently — and frequently
+ *  disagreed on (status bar reading auth.signedIn() while the menu
+ *  read K_ON, etc.). Every state-changing call site `set()`s into here;
+ *  every UI subscriber gets the new snapshot in one place. Each change
+ *  is also logged so `debug.log` shows a state-transition timeline. */
+export interface SessionSnapshot {
+  /** True iff we hold an access token the extension considers usable.
+   *  Driven by the AuthClient's in-memory `at`; goes false when refresh
+   *  fails or signOut runs. Does NOT prove the backend still accepts
+   *  the token — `authHealthy` reflects the last real call. */
+  signedIn: boolean;
+  /** Backend response status for the most recent authenticated call.
+   *  Tracking this separately from `signedIn` lets the UI distinguish
+   *  "we have a token but every call 401s" (the bug class the user
+   *  asked about) from a clean signed-out / signed-in state. */
+  authHealthy: "ok" | "401" | "unknown";
+  /** K_ON — the debug-injection master toggle. */
+  injectionOn: boolean;
+  /** True iff a kill switch (server or override) is engaged. */
+  killed: boolean;
+  /** True iff the most recent portfolio fetch returned a winning ad. */
+  hasAd: boolean;
+  /** Claude Code version (for the status bar version label). */
+  ccVersion: string;
+}
+
+const INITIAL: SessionSnapshot = {
+  signedIn: false, authHealthy: "unknown", injectionOn: false,
+  killed: false, hasAd: false, ccVersion: "unknown",
+};
+
+export class SessionState {
+  private snap: SessionSnapshot = { ...INITIAL };
+  private subs: Array<(s: SessionSnapshot) => void> = [];
+
+  get(): Readonly<SessionSnapshot> { return this.snap; }
+
+  set(patch: Partial<SessionSnapshot>): void {
+    // Cheap structural-equality check: avoid spamming debug.log when a
+    // no-op set comes through (showActive ticks, periodic kill polls).
+    const before = JSON.stringify(this.snap);
+    this.snap = { ...this.snap, ...patch };
+    const after = JSON.stringify(this.snap);
+    if (before === after) return;
+    dlog("ext", "session.state", this.snap as unknown as Record<string, unknown>);
+    for (const fn of this.subs) {
+      try { fn(this.snap); } catch { /* observers are best-effort */ }
+    }
+  }
+
+  onChange(fn: (s: SessionSnapshot) => void): void { this.subs.push(fn); }
+
+  /** Operator-readable single-line summary used in diagnostic dumps and
+   *  the debug menu's "Built …" info row context. */
+  describe(): string {
+    const s = this.snap;
+    const auth = s.signedIn ? (s.authHealthy === "401" ? "in (broken)" :
+      s.authHealthy === "ok" ? "in" : "in (unknown)") : "out";
+    return `auth: ${auth}  injection: ${s.injectionOn ? "on" : "off"}  ad: ${
+      s.hasAd ? "yes" : "no"}  killed: ${s.killed ? "yes" : "no"}  cc: ${s.ccVersion}`;
+  }
+}
diff --git a/src/statusbar.ts b/src/statusbar.ts
new file mode 100644
index 0000000..167f751
--- /dev/null
+++ b/src/statusbar.ts
@@ -0,0 +1,151 @@
+import * as vscode from "vscode";
+import { buildLabel } from "./buildinfo";
+
+export type SbState =
+  | { kind: "signed-out" }
+  | { kind: "active"; version: string; usd?: string; usdToday?: string }
+  | { kind: "incompatible"; version: string }
+  | { kind: "killed" }
+  | { kind: "offline" }
+  // `debug` renders exactly like `active` (the word "debug" is intentionally
+  // never surfaced in the status-bar text — operator-facing detail only).
+  | { kind: "debug"; on: boolean; version?: string; usd?: string; usdToday?: string }
+  | { kind: "ad"; adText: string }
+  // STICKY: the patch landed this session but the running Claude Code webview
+  // predates it — no ads (so no earnings) until a window reload. Once set,
+  // ROUTINE paints (ad / active / debug-ON earnings) are ignored so they
+  // can't bury the red "reload" call-to-action; safety/truth states (killed,
+  // offline, signed-out, incompatible, debug-OFF) always win and clear the
+  // lock — the bar must never hide a kill, an outage, or an auth loss behind
+  // the CTA (audit #29). The lock also clears with the reload itself (fresh
+  // activation).
+  | { kind: "needs-reload" };
+
+// Darker than the previous #3fb950 (GitHub success-emphasis green) so it
+// reads as confident-earning rather than a bright neon stripe.
+const GREEN = "#2ea043";
+// Used for states where the extension isn't earning (signed-out, injection
+// toggled off, kill-switch engaged, backend offline). Sits at the same
+// confidence level as GREEN — bright enough to flag the state at a glance
+// in the status bar without being a "broken" red.
+const RED = "#f85149";
+
+export class StatusBar {
+  // Right-aligned (priority high so it sits toward the leading edge of the
+  // right cluster).
+  private item = vscode.window.createStatusBarItem(
+    vscode.StatusBarAlignment.Right, 1000);
+  text = "";
+  constructor() { this.item.command = "kickbacks.debugMenu"; this.item.show(); }
+
+  // Always render the two-figure form. A missing figure (fetch failed, or
+  // signed-in before the first /v1/earnings poll) degrades to "$0.00" — the
+  // signed-in/active bar never shows a bare label. Note: a fetch failure is
+  // therefore indistinguishable from genuine zero earnings (deliberate, per
+  // product call — prefer always showing a dollar figure).
+  private earned(usd?: string, today?: string): string {
+    return ` ($${today ?? "0.00"} today · $${usd ?? "0.00"})`;
+  }
+
+  /** True once `needs-reload` has been shown; masks ROUTINE repaints only
+   *  (see the SbState docstring). */
+  private reloadLock = false;
+
+  /** Routine earning paints — the only kinds the reload lock may mask.
+   *  Everything else (killed, offline, signed-out, incompatible, debug-OFF)
+   *  is a safety/truth state the bar must never hide behind the CTA. */
+  private static isRoutine(s: SbState): boolean {
+    return s.kind === "ad" || s.kind === "active"
+      || (s.kind === "debug" && s.on !== false);
+  }
+
+  /** Returns false when the paint was suppressed by the reload lock —
+   *  statusBarAd checks this so an ad whose paint never landed is never
+   *  billed (no impression/view_tick for an undisplayed ad, audit #29). */
+  set(s: SbState): boolean {
+    if (this.reloadLock && s.kind !== "needs-reload") {
+      if (StatusBar.isRoutine(s)) return false;
+      // Safety/truth state: always paint, and release the lock so the
+      // recovery paints that follow (e.g. back-online "active") aren't
+      // dropped against a stale safety label.
+      this.reloadLock = false;
+    }
+    let color: string | undefined;
+    let background: vscode.ThemeColor | undefined;
+    let command = "kickbacks.debugMenu";
+    let tooltip = "Kickbacks";
+    switch (s.kind) {
+      case "signed-out":
+        // No icon — the "K" codicon doesn't exist in VS Code's default set,
+        // and a generic glyph would misrepresent the brand. Text only.
+        // RED to flag the not-earning state at a glance.
+        this.text = "Kickbacks: Sign in";
+        color = RED;
+        tooltip = "Click to sign in to Kickbacks";
+        break;
+      case "active":
+        this.text = `Kickbacks${this.earned(s.usd, s.usdToday)}`;
+        color = GREEN;
+        tooltip = `Kickbacks active${s.version ? ` · Claude Code ${s.version}` : ""}`
+          + ` · $${s.usdToday ?? "0.00"} today · $${s.usd ?? "0.00"} earned`
+          + " (display-only credit, payout TBD)";
+        break;
+      case "debug":
+        // Signed-in/debug: green when ON, red when OFF (user has the menu
+        // master switch flipped off — earnings paused).
+        if (s.on === false) {
+          this.text = "Kickbacks: Off";
+          color = RED;
+          tooltip = "Kickbacks is currently OFF — click to re-enable";
+        } else {
+          this.text = `Kickbacks${this.earned(s.usd, s.usdToday)}`;
+          color = GREEN;
+          tooltip = `Kickbacks active${s.version ? ` · Claude Code ${s.version}` : ""}`
+            + ` · $${s.usdToday ?? "0.00"} today · $${s.usd ?? "0.00"} earned`
+            + " (display-only credit, payout TBD)";
+        }
+        break;
+      case "incompatible":
+        this.text = `Kickbacks incompatible (${s.version})`;
+        break;
+      case "killed":
+        this.text = "Kickbacks killed";
+        color = RED;
+        break;
+      case "offline":
+        this.text = "Kickbacks offline";
+        color = RED;
+        break;
+      case "ad":
+        // Brand the status-bar ad with a pipe separator: "Kickbacks.ai  |  <ad>".
+        this.text = `Kickbacks.ai  |  ${s.adText}`;
+        color = GREEN;
+        tooltip = "Kickbacks ad";
+        break;
+      case "needs-reload":
+        this.reloadLock = true;
+        this.text = "$(warning) Kickbacks: RELOAD to earn money";
+        // errorBackground is the only red background VS Code lets a status-bar
+        // item paint; the theme pairs it with white foreground. Explicit white
+        // on top so a custom theme can't dim the call-to-action.
+        background = new vscode.ThemeColor("statusBarItem.errorBackground");
+        color = "#ffffff";
+        // One click = the reload itself (not the menu) — the whole point of
+        // this state is removing every step between the user and the reload.
+        command = "workbench.action.reloadWindow";
+        tooltip = "Kickbacks won't earn money until you reload — click to reload now";
+        break;
+    }
+    // The click opens the menu (GET PAID OUT / sign in / sign out live there)
+    // — except needs-reload, where the click IS the reload.
+    this.item.command = command;
+    this.item.backgroundColor = background;
+    this.item.color = color;
+    // Live build age in the tooltip — the truthful "how long ago published"
+    // the VS Code Installation panel can't show (it's not author-extensible).
+    this.item.tooltip = `${tooltip} · ${buildLabel()}`;
+    this.item.text = this.text;
+    return true;
+  }
+  dispose(): void { this.item.dispose(); }
+}
diff --git a/src/testHooks.ts b/src/testHooks.ts
new file mode 100644
index 0000000..f5aae15
--- /dev/null
+++ b/src/testHooks.ts
@@ -0,0 +1,354 @@
+import * as vscode from "vscode";
+import { randomBytes } from "node:crypto";
+import { MetricsClient, newMetricEventUuid, type AdSurface,
+  type MetricEvent } from "./metrics/client";
+import { PortfolioClient, type PatchAd } from "./portfolio/client";
+import { EarningsClient } from "./earnings/client";
+import { dlog, testHooksEnabled } from "./log";
+import { errMsg } from "./util/errMsg";
+
+/** Live context the test hooks read at fire-time. The activate path captures
+ *  these as closures over its locals so a hook always reflects the latest
+ *  ad / loopback / kill state without having to thread mutable refs. */
+export interface TestHooksContext {
+  ad: PatchAd | null;
+  signedIn: boolean;
+  killed: boolean;
+  ccVersion: string;
+  viewThresholdMs: number;
+  loopback: { port: number; base: string } | null;
+}
+
+export interface FireArgs {
+  /** Override surface; defaults to "overlay". */
+  surface?: AdSurface;
+  /** Override visibleMs on view-family events. */
+  visibleMs?: number;
+  /** Override ad id when no live ad is loaded. campaignId is paired. */
+  adId?: string;
+  campaignId?: string;
+  /** Override session token when firing with adId/campaignId override. */
+  sessionToken?: string;
+  /** Optional explicit correlation id; otherwise the hook mints one. */
+  corr?: string;
+  /** Click only: optional click token to relay (defaults to "test"). */
+  ct?: string;
+}
+
+export interface FireResult {
+  ok: boolean;
+  reason?: string;
+  /** What the hook actually sent to the backend (post-resolution). */
+  sent?: {
+    event: MetricEvent;
+    eventUuid: string;
+    adId: string;
+    campaignId: string;
+    surface: AdSurface;
+    corr: string;
+    visibleMs?: number;
+  };
+}
+
+export interface StateSnapshot {
+  enabled: boolean;
+  signedIn: boolean;
+  killed: boolean;
+  ccVersion: string;
+  viewThresholdMs: number;
+  ad: PatchAd | null;
+  loopback: { port: number; base: string } | null;
+  lastEvents: FireResult["sent"][];
+}
+
+const LOG_RING_CAP = 64;
+
+/** E2E driver for ledger / billing / telemetry. Exposes
+ *  `kickbacks.test.*` VS Code commands that fire impression / view / click
+ *  events through the SAME MetricsClient the loopback uses, so an end-to-end
+ *  test exercises the real backend path. Production-default OFF (sentinel
+ *  + env gate, see testHooksEnabled()). Hooks always honor the gate so an
+ *  enable-then-disable mid-session can't keep forging events. */
+export class TestHooks {
+  private readonly ring: FireResult["sent"][] = [];
+
+  constructor(
+    private readonly metrics: MetricsClient,
+    private readonly portfolio: PortfolioClient,
+    private readonly earnings: EarningsClient,
+    private readonly getContext: () => TestHooksContext,
+    private readonly onBillableEvent: (() => void) | null = null,
+  ) {}
+
+  private scheduleRefreshFor(event: MetricEvent): void {
+    if (event !== "view_threshold_met" && event !== "click"
+        && event !== "impression_viewable" && event !== "error_impression") {
+      return;
+    }
+    try { this.onBillableEvent?.(); } catch { /* test hooks must not disrupt */ }
+  }
+
+  private resolveAd(args: FireArgs): { adId: string; campaignId: string; sessionToken: string } | null {
+    if (args.adId && args.campaignId) {
+      return { adId: args.adId, campaignId: args.campaignId, sessionToken: args.sessionToken || "" };
+    }
+    const ad = this.getContext().ad;
+    if (ad) return { adId: ad.adId, campaignId: ad.campaignId, sessionToken: ad.sessionToken };
+    return null;
+  }
+
+  private mintCorr(adId: string, args: FireArgs): string {
+    if (args.corr) return args.corr;
+    return `${adId}.test.${randomBytes(3).toString("hex")}`;
+  }
+
+  private async fireMetric(
+    event: MetricEvent,
+    args: FireArgs,
+    payload: { visibleMs?: number; viewable?: boolean; viewPct?: number;
+               viewMs?: number } = {},
+  ): Promise<FireResult> {
+    if (!testHooksEnabled()) {
+      return { ok: false, reason: "test hooks disabled" };
+    }
+    const resolved = this.resolveAd(args);
+    if (!resolved) {
+      return { ok: false, reason: "no ad loaded and no adId/campaignId override" };
+    }
+    const surface: AdSurface = args.surface || "overlay";
+    const corr = this.mintCorr(resolved.adId, args);
+    const eventUuid = newMetricEventUuid();
+    const ctx = this.getContext();
+    const visibleMs = typeof args.visibleMs === "number"
+      ? args.visibleMs : payload.visibleMs;
+    try {
+      await this.metrics.send(event, {
+        adId: resolved.adId,
+        campaignId: resolved.campaignId,
+        ccVersion: ctx.ccVersion,
+        corr,
+        eventUuid,
+        surface,
+        sessionToken: resolved.sessionToken,
+        ...(typeof visibleMs === "number" ? { visibleMs } : {}),
+        ...(typeof payload.viewable === "boolean"
+          ? { viewable: payload.viewable } : {}),
+        ...(typeof payload.viewPct === "number"
+          ? { viewPct: payload.viewPct } : {}),
+        ...(typeof payload.viewMs === "number" ? { viewMs: payload.viewMs } : {}),
+      });
+    } catch (e) {
+      const reason = errMsg(e, 200);
+      dlog("ext", "testhook.send.err", { event, reason });
+      return { ok: false, reason };
+    }
+    const sent = {
+      event,
+      eventUuid,
+      adId: resolved.adId,
+      campaignId: resolved.campaignId,
+      surface,
+      corr,
+      ...(typeof visibleMs === "number" ? { visibleMs } : {}),
+    };
+    this.recordSent(sent);
+    dlog("ext", "testhook.fire", { event, eventUuid,
+      adId: resolved.adId, surface, corr });
+    this.scheduleRefreshFor(event);
+    return { ok: true, sent };
+  }
+
+  fireImpressionRendered(args: FireArgs = {}): Promise<FireResult> {
+    return this.fireMetric("impression_rendered", args);
+  }
+
+  fireImpressionViewable(args: FireArgs = {}): Promise<FireResult> {
+    return this.fireMetric("impression_viewable", args, {
+      viewable: true, viewPct: 100,
+    });
+  }
+
+  fireViewTick(args: FireArgs = {}): Promise<FireResult> {
+    return this.fireMetric("view_tick", args);
+  }
+
+  fireViewThresholdMet(args: FireArgs = {}): Promise<FireResult> {
+    const ctx = this.getContext();
+    const visibleMs = typeof args.visibleMs === "number"
+      ? args.visibleMs : ctx.viewThresholdMs;
+    return this.fireMetric("view_threshold_met",
+      { ...args, visibleMs },
+      { viewable: true, viewPct: 100, viewMs: visibleMs });
+  }
+
+  /** Fire the MAX_SESSION_MS safety-net event. Lets e2e harnesses drive
+   *  the 5 s stuck-session path without waiting on real wall-clock. */
+  fireErrorImpression(args: FireArgs = {}): Promise<FireResult> {
+    const visibleMs = typeof args.visibleMs === "number"
+      ? args.visibleMs : 5000;
+    return this.fireMetric("error_impression",
+      { ...args, visibleMs },
+      { viewable: true, viewPct: 100, viewMs: visibleMs });
+  }
+
+  async fireClick(args: FireArgs = {}): Promise<FireResult> {
+    if (!testHooksEnabled()) {
+      return { ok: false, reason: "test hooks disabled" };
+    }
+    const resolved = this.resolveAd(args);
+    if (!resolved) {
+      return { ok: false, reason: "no ad loaded and no adId/campaignId override" };
+    }
+    const surface: AdSurface = args.surface || "overlay";
+    const corr = this.mintCorr(resolved.adId, args);
+    const eventUuid = newMetricEventUuid();
+    const ctx = this.getContext();
+    try {
+      await this.metrics.send("click", {
+        adId: resolved.adId,
+        campaignId: resolved.campaignId,
+        ccVersion: ctx.ccVersion,
+        corr,
+        eventUuid,
+        surface,
+        sessionToken: resolved.sessionToken,
+      });
+    } catch (e) {
+      const reason = errMsg(e, 200);
+      dlog("ext", "testhook.click.err", { reason });
+      return { ok: false, reason };
+    }
+    const sent = {
+      event: "click" as MetricEvent,
+      eventUuid,
+      adId: resolved.adId,
+      campaignId: resolved.campaignId,
+      surface,
+      corr,
+    };
+    this.recordSent(sent);
+    dlog("ext", "testhook.click", { ct: args.ct || "test", eventUuid,
+      adId: resolved.adId, surface, corr });
+    this.scheduleRefreshFor("click");
+    return { ok: true, sent };
+  }
+
+  async refreshPortfolio(): Promise<PatchAd | null> {
+    if (!testHooksEnabled()) return null;
+    const ctx = this.getContext();
+    const r = await this.portfolio.fetchPortfolio(ctx.ccVersion);
+    return r?.ad ?? null;
+  }
+
+  async refreshEarnings(): Promise<{ lifetimeUsd: string;
+                                      todayUsd: string } | null> {
+    if (!testHooksEnabled()) return null;
+    return this.earnings.fetch();
+  }
+
+  getState(): StateSnapshot {
+    const ctx = this.getContext();
+    return {
+      enabled: testHooksEnabled(),
+      signedIn: ctx.signedIn,
+      killed: ctx.killed,
+      ccVersion: ctx.ccVersion,
+      viewThresholdMs: ctx.viewThresholdMs,
+      ad: ctx.ad,
+      loopback: ctx.loopback,
+      lastEvents: [...this.ring],
+    };
+  }
+
+  clearEventLog(): void { this.ring.length = 0; }
+
+  /** Dispatch a loopback /test/<name> route to the corresponding method.
+   *  Parses query-string args into FireArgs (visibleMs is numeric; everything
+   *  else is a string). Self-gated by testHooksEnabled() so a sentinel
+   *  removal mid-session disables the routes on the very next call (403). */
+  async handleTestRoute(name: string, params: URLSearchParams):
+    Promise<{ status: number; body: unknown }> {
+    if (!testHooksEnabled()) {
+      return { status: 403,
+        body: { ok: false, reason: "test hooks disabled" } };
+    }
+    const args: FireArgs = {};
+    const surface = params.get("surface");
+    if (surface) args.surface = surface as AdSurface;
+    const vm = params.get("visibleMs");
+    if (vm !== null) {
+      const n = Number(vm);
+      if (Number.isFinite(n)) args.visibleMs = n;
+    }
+    const adId = params.get("adId");
+    if (adId) args.adId = adId;
+    const campaignId = params.get("campaignId");
+    if (campaignId) args.campaignId = campaignId;
+    const corr = params.get("corr");
+    if (corr) args.corr = corr;
+    const ct = params.get("ct");
+    if (ct) args.ct = ct;
+    try {
+      switch (name) {
+        case "fireImpressionRendered":
+          return { status: 200, body: await this.fireImpressionRendered(args) };
+        case "fireImpressionViewable":
+          return { status: 200, body: await this.fireImpressionViewable(args) };
+        case "fireViewTick":
+          return { status: 200, body: await this.fireViewTick(args) };
+        case "fireViewThresholdMet":
+          return { status: 200, body: await this.fireViewThresholdMet(args) };
+        case "fireErrorImpression":
+          return { status: 200, body: await this.fireErrorImpression(args) };
+        case "fireClick":
+          return { status: 200, body: await this.fireClick(args) };
+        case "refreshPortfolio":
+          return { status: 200, body: await this.refreshPortfolio() };
+        case "refreshEarnings":
+          return { status: 200, body: await this.refreshEarnings() };
+        case "getState":
+          return { status: 200, body: this.getState() };
+        case "clearEventLog":
+          this.clearEventLog();
+          return { status: 200, body: { ok: true } };
+        default:
+          return { status: 404,
+            body: { ok: false, reason: `unknown route "${name}"` } };
+      }
+    } catch (e) {
+      const reason = errMsg(e, 200);
+      dlog("ext", "testhook.route.err", { name, reason });
+      return { status: 500, body: { ok: false, reason } };
+    }
+  }
+
+  private recordSent(sent: NonNullable<FireResult["sent"]>): void {
+    this.ring.push(sent);
+    while (this.ring.length > LOG_RING_CAP) this.ring.shift();
+  }
+
+  /** Wire VS Code commands. Only call when testHooksEnabled() is true; the
+   *  gate is also re-checked inside each fire method so a sentinel removal
+   *  mid-session disables the hooks immediately (next fire returns ok:false).
+   *  Commands accept an optional first arg `FireArgs` object so callers can
+   *  do `executeCommand("kickbacks.test.fireClick", { surface: "banner" })`. */
+  registerCommands(ctx: vscode.ExtensionContext): void {
+    const reg = (id: string, fn: (a?: FireArgs) => unknown) =>
+      ctx.subscriptions.push(vscode.commands.registerCommand(id, fn));
+    reg("kickbacks.test.fireImpressionRendered",
+      (a) => this.fireImpressionRendered(a || {}));
+    reg("kickbacks.test.fireImpressionViewable",
+      (a) => this.fireImpressionViewable(a || {}));
+    reg("kickbacks.test.fireViewTick", (a) => this.fireViewTick(a || {}));
+    reg("kickbacks.test.fireViewThresholdMet",
+      (a) => this.fireViewThresholdMet(a || {}));
+    reg("kickbacks.test.fireErrorImpression",
+      (a) => this.fireErrorImpression(a || {}));
+    reg("kickbacks.test.fireClick", (a) => this.fireClick(a || {}));
+    reg("kickbacks.test.refreshPortfolio", () => this.refreshPortfolio());
+    reg("kickbacks.test.refreshEarnings", () => this.refreshEarnings());
+    reg("kickbacks.test.getState", () => this.getState());
+    reg("kickbacks.test.clearEventLog", () => this.clearEventLog());
+    dlog("ext", "testhook.registered", {});
+  }
+}
diff --git a/src/types/surface.ts b/src/types/surface.ts
new file mode 100644
index 0000000..9e97e2a
--- /dev/null
+++ b/src/types/surface.ts
@@ -0,0 +1,5 @@
+export type AdSurface = "overlay" | "banner" | "codex_overlay" | "statusline" | "statusbar" | "spinner";
+export const AD_SURFACES: readonly AdSurface[] = ["overlay", "banner", "codex_overlay", "statusline", "statusbar", "spinner"] as const;
+export function parseAdSurface(raw: string): AdSurface | undefined {
+  return (AD_SURFACES as readonly string[]).includes(raw) ? (raw as AdSurface) : undefined;
+}
diff --git a/src/update/client.ts b/src/update/client.ts
new file mode 100644
index 0000000..be75c6d
--- /dev/null
+++ b/src/update/client.ts
@@ -0,0 +1,313 @@
+import { createHash, verify } from "node:crypto";
+import { dlog } from "../log";
+import { errMsg } from "../util/errMsg";
+import { timeoutFetch } from "../util/http";
+
+type Fetch = typeof fetch;
+type Installer = (vsix: ArrayBuffer) => Promise<void>;
+/** Persistence hooks so a given manifest version is install-attempted AT
+ *  MOST ONCE, ever. Without this, if installExtension+restartExtensionHost
+ *  fails to actually swap the running version, isNewer stays true forever →
+ *  download→install→restart→repeat = the endless restart loop. With it, a
+ *  non-converging version is tried once then left alone (safe-degraded).
+ *
+ *  wave-2A-F05: distinguish "permanent install attempt" from "transient
+ *  pre-install failure" (sha mismatch, body-read abort). The latter should
+ *  be retried after a short cooldown rather than burning the full
+ *  install-attempt slot (which would block recovery for 30 min). */
+interface AttemptGuard {
+  // The optional sha256 second arg is the manifest's pinned VSIX digest.
+  // Wave-2P-F01: keying ONLY on `version` lets a manifest flap between two
+  // artifacts that carry the same semver but different bytes (observed in
+  // prod: 0.3.54 with two distinct BUILD_TS-stamped builds re-installing
+  // each other every ~90s). Passing `sha256` lets the extension-side
+  // implementation key on (version, sha) so the second flap is suppressed
+  // until the cooldown expires. Optional + ignored-when-omitted to keep
+  // every existing call site / test guard binary-compatible.
+  attempted(version: string, sha256?: string): boolean;
+  markAttempted(version: string, sha256?: string): void;
+  /** Optional: SUCCESS record (trey-nag-loop 2026-06-11). `attempted` is
+   *  cooldown-bounded so a FAILED install can retry — but that same cooldown
+   *  re-ran SUCCESSFUL installs too: a user who dismissed the reload toast
+   *  got the identical artifact re-downloaded, re-installed and re-toasted
+   *  every ~31 min until they reloaded (observed: 20+ cycles / 10 h on one
+   *  machine). An artifact that installed without throwing is recorded here
+   *  and never re-attempted; only the wiring (selfUpdate.ts) clears the
+   *  record, at activation, when the install visibly failed to converge. */
+  installed?(version: string, sha256?: string): boolean;
+  markInstalled?(version: string, sha256?: string): void;
+  /** Optional: short cooldown for transient pre-install failures. When
+   *  omitted, transient failures are NOT retried-throttled and the next
+   *  90s poll re-tries immediately (pre-2A-F05 behavior). */
+  transientFailed?(version: string, sha256?: string): boolean;
+  markTransientFailed?(version: string, sha256?: string): void;
+  /** Optional: wave-2K-F03 LKG VSIX recording. Called after a successful
+   *  install so an activate.fatal bootstrap can restore the last-known-good
+   *  bytes. Bytes may be persisted to a tmp file path; only the path needs
+   *  to fit in globalState. */
+  recordLkg?(version: string, vsix: Buffer): void;
+}
+
+export function isNewer(a: string, b: string): boolean {
+  const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
+  for (let i = 0; i < 3; i++) {
+    if ((pa[i] || 0) > (pb[i] || 0)) return true;
+    if ((pa[i] || 0) < (pb[i] || 0)) return false;
+  }
+  return false;
+}
+
+// wave-2A-F01 — VSIX size sanity + signature scaffolding.
+//
+// A self-hosted manifest (no marketplace fallback) where the only integrity
+// check is sha256 vs the manifest itself is integrity-vs-the-manifest, not
+// authenticity. A backend compromise that controls the manifest also
+// controls the sha → a malicious VSIX ships silently. Two layers of fix:
+//
+// 1. UNCONDITIONAL minimum-size sanity. Rejects empty/garbage downloads
+//    (CDN partial response, ad-blocker stub, etc) before they reach the
+//    installer. 10 KiB is far below any real VSIX (89 KB at audit time).
+//
+// 2. FLAG-GATED Ed25519 signature verification over the manifest blob.
+//    When VIBE_ADS_REQUIRE_MANIFEST_SIG=1 and a public key is compiled in
+//    via VIBE_ADS_MANIFEST_PUBKEY_PEM (esbuild --define), the manifest
+//    MUST carry a base64 `signature` field that verifies against
+//    `${version}\n${sha256}\n${url}\n${rollback_to}` under the embedded
+//    public key (rollback_to is "" for a normal forward manifest).
+//    The flag is OFF by default so existing deployments keep working
+//    until a key story is set up (see docs/runbooks/extension-signing.md
+//    when it lands as part of the wave-2A-F01 operational follow-up).
+const MIN_VSIX_BYTES = 10 * 1024;
+
+declare const __MANIFEST_PUBKEY_PEM__: string | undefined;
+
+function _embeddedPubkeyPem(): string | null {
+  try {
+    // esbuild --define replaces __MANIFEST_PUBKEY_PEM__ at build time.
+    // When undefined the typeof guard short-circuits without ReferenceError.
+    if (typeof __MANIFEST_PUBKEY_PEM__ === "string" && __MANIFEST_PUBKEY_PEM__.length > 0) {
+      return __MANIFEST_PUBKEY_PEM__;
+    }
+  } catch { /* swallow; treated as unset */ }
+  return null;
+}
+
+export function _verifyManifestSignature(
+  m: { version: string; sha256: string; url: string; signature?: string;
+       rollback_to?: string },
+  pubkeyPem: string,
+): boolean {
+  if (!m.signature) return false;
+  try {
+    // Locked signed-payload format: `version\nsha256\nurl\nrollback_to`.
+    // rollback_to is "" for a normal forward manifest (deploy.mjs signs it
+    // empty); including it in the signed bytes prevents a compromised manifest
+    // from grafting an unsigned `rollback_to` to force a downgrade to an old
+    // (genuinely signed but known-vuln) version.
+    return verify(
+      null,
+      Buffer.from(`${m.version}\n${m.sha256}\n${m.url}\n${m.rollback_to ?? ""}`),
+      pubkeyPem,
+      Buffer.from(m.signature, "base64"),
+    );
+  } catch { return false; }
+}
+
+// wave-2A-F01 layer 3 — VSIX download-origin pin. The sha256 above proves the
+// downloaded bytes match the manifest, but a compromised manifest controls
+// BOTH `url` AND `sha256`, so on its own the pin can be redirected to an
+// attacker-controlled host (supply-chain RCE on every install). Restrict the
+// download origin to the published `kickbacks-vsix` GCS bucket in production,
+// while still allowing a self-hosted DEV manifest to serve the VSIX from its
+// OWN origin (same host as `base`) or loopback — if an attacker can MITM that
+// they already control the manifest + sha, so it grants no new capability. An
+// out-of-allowlist url is rejected BEFORE the fetch (safe-degraded: the
+// self-update no-ops rather than fetching/installing an untrusted artifact).
+export function _vsixUrlAllowed(rawUrl: string, base: string): boolean {
+  let u: URL;
+  try { u = new URL(rawUrl); } catch { return false; }
+  const host = u.hostname.toLowerCase();
+  // Dev / self-host escape hatches.
+  if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true;
+  try {
+    if (host === new URL(base).hostname.toLowerCase()) return true;
+  } catch { /* base isn't a URL — fall through to the prod allowlist */ }
+  // Production: HTTPS to the published kickbacks-vsix GCS bucket only.
+  if (u.protocol !== "https:") return false;
+  if (host === "kickbacks-vsix.storage.googleapis.com") return true;
+  if (host === "kickbacks-vsix.storage.cloud.google.com") return true;
+  if ((host === "storage.googleapis.com" || host === "storage.cloud.google.com")
+      && u.pathname.startsWith("/kickbacks-vsix/")) return true;
+  return false;
+}
+
+/** Polls /v1/ext/manifest; sha256-verifies the VSIX before install. Returns
+ *  true iff an install happened. Never throws. */
+export class UpdateClient {
+  // wave-2A-M4: surface when self-update has been silently failing for a
+  // while. Pre-fix, a peer left on a stale build had zero signal — every
+  // failure path returns false with no log. Counter resets on success; at
+  // each 3-poll multiple we emit a dlog so an operator running with
+  // debugMode: true can see "self-update is wedged" without instrumenting
+  // anything else.
+  private consecutiveFails = 0;
+  /** Optional user-facing "new version detected" notifier. Fires AFTER
+   *  the version + sig + sha gates pass and BEFORE the install starts —
+   *  so the user sees "Kickbacks: v0.3.85 available, installing…" while
+   *  the install runs, not after the extension host has restarted on
+   *  top of them. Never throws (best-effort). */
+  private onUpdateAvailable?: (info: { version: string;
+                                       current: string;
+                                       rollback: boolean }) => void;
+  constructor(private base: string, private current: string,
+              private f: Fetch = timeoutFetch(120000), private install: Installer = async () => {},
+              private guard?: AttemptGuard,
+              onUpdateAvailable?: (info: { version: string;
+                                            current: string;
+                                            rollback: boolean }) => void) {
+    this.onUpdateAvailable = onUpdateAvailable;
+  }
+
+  private noteFail(reason: string, extra: Record<string, unknown> = {}): false {
+    this.consecutiveFails++;
+    if (this.consecutiveFails > 0 && this.consecutiveFails % 3 === 0) {
+      dlog("ext", "selfupdate.failed",
+        { reason, base: this.base, current: this.current,
+          consecutiveFails: this.consecutiveFails, ...extra });
+    }
+    return false;
+  }
+
+  // audit-2026-06-09 #31: single-flight guard. checkOnce is driven by a
+  // fire-and-forget 90s setInterval (selfUpdate.ts); the attempt fence is
+  // only written AFTER the download completes (markAttempted below), so a
+  // VSIX download slower than the poll period let the next tick re-enter,
+  // double-download and double-install the same artifact. An overlapping
+  // poll now returns false immediately; cooldown/attempt-guard semantics
+  // for SEQUENTIAL polls are unchanged.
+  private inFlight = false;
+
+  async checkOnce(): Promise<boolean> {
+    if (this.inFlight) return false;
+    this.inFlight = true;
+    try { return await this.checkOnceInner(); }
+    finally { this.inFlight = false; }
+  }
+
+  private async checkOnceInner(): Promise<boolean> {
+    try {
+      const m = await (await this.f(`${this.base}/v1/ext/manifest`)).json() as
+        { version: string; sha256: string; url: string; signature?: string;
+          rollback_to?: string };
+      // wave-2K-F02: rollback contract. When the server-published manifest
+      // sets `rollback_to: <currently-running-version>`, this client treats
+      // the manifest version as installable even when it's <= current --
+      // overriding isNewer() so a true downgrade is possible. Sha pin still
+      // applies; the attempt-guard still applies.
+      const isRollbackForUs = !!m.rollback_to
+        && m.rollback_to === this.current
+        && m.version !== this.current;
+      if (!isRollbackForUs && !isNewer(m.version, this.current)) return false;
+      // Already-installed artifact (success record): the running version
+      // only changes on window reload, so "manifest newer than current"
+      // stays true indefinitely after a successful install. Without this
+      // gate the attempted-cooldown below re-installs + re-toasts the SAME
+      // artifact every time it expires (the Trey nag loop). Checked before
+      // the cooldown slots so success suppression is not time-bounded.
+      if (this.guard?.installed?.(m.version, m.sha256)) return false;
+      // One install attempt per (version, sha), ever — the restart-loop
+      // guard. Wave-2P-F01: previously keyed on version only, which let a
+      // manifest carrying the same semver with a different VSIX flap-install
+      // every poll. Threading m.sha256 to every guard call site means the
+      // *artifact* is what the cooldown actually fences; manifests that
+      // genuinely re-serve the same bytes still hit the existing slot.
+      if (this.guard && this.guard.attempted(m.version, m.sha256)) return false;
+      // wave-2A-F05 — short transient cooldown for pre-install failures
+      // (sha mismatch, body-read abort). The 90s poll means a flapping CDN
+      // would otherwise burn bandwidth + CPU every 90s indefinitely.
+      if (this.guard?.transientFailed?.(m.version, m.sha256)) return false;
+      // wave-2A-F01: flag-gated manifest signature verification.
+      const pubkey = _embeddedPubkeyPem();
+      const requireSig = !!pubkey
+        || process.env.KICKBACKS_REQUIRE_MANIFEST_SIG === "1"
+        || process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG === "1";
+      if (requireSig) {
+        if (!pubkey) {                          // flag on but no embedded key
+          this.guard?.markTransientFailed?.(m.version, m.sha256);
+          return this.noteFail("missing-pubkey", { version: m.version });
+        }
+        if (!_verifyManifestSignature(m, pubkey)) {
+          this.guard?.markTransientFailed?.(m.version, m.sha256);
+          return this.noteFail("sig-verify", { version: m.version });
+        }
+      }
+      // Supply-chain pin: never fetch a VSIX from an origin outside the
+      // published bucket (or a dev self-host). Transient so a later corrected
+      // manifest still recovers, but the download itself is refused here.
+      if (!_vsixUrlAllowed(m.url, this.base)) {
+        this.guard?.markTransientFailed?.(m.version, m.sha256);
+        return this.noteFail("vsix-url-blocked", { version: m.version, url: m.url });
+      }
+      let ab: ArrayBuffer;
+      try {
+        ab = await (await this.f(m.url)).arrayBuffer();
+      } catch {
+        // Body-read abort: transient. wave-2A-M4: this is the canonical
+        // "manifest URL unreachable" case — a peer pointing at a manifest
+        // that has localhost:6080 will hit this every poll and silently
+        // stay stale forever pre-fix.
+        this.guard?.markTransientFailed?.(m.version, m.sha256);
+        return this.noteFail("vsix-fetch", { version: m.version, url: m.url });
+      }
+      // wave-2A-F01: minimum-size sanity, unconditional. Transient — a
+      // partial/CDN-stub response should be retried after backoff, not
+      // burn the permanent install slot.
+      if (ab.byteLength < MIN_VSIX_BYTES) {
+        this.guard?.markTransientFailed?.(m.version, m.sha256);
+        return this.noteFail("vsix-too-small",
+          { version: m.version, bytes: ab.byteLength });
+      }
+      const got = createHash("sha256").update(Buffer.from(ab)).digest("hex");
+      if (got !== m.sha256) {
+        // tamper / corruption -> transient (a flapping CDN or in-flight
+        // corruption should recover; a persistent attacker still burns
+        // only the cooldown rate, not the network).
+        this.guard?.markTransientFailed?.(m.version, m.sha256);
+        return this.noteFail("sha-mismatch", { version: m.version });
+      }
+      // Notify the user a new version is about to install (toast). Fires
+      // BEFORE markAttempted/install so the user sees the notice in the
+      // few seconds the install + restart take. Best-effort: a thrown
+      // notifier never blocks the install path.
+      try {
+        this.onUpdateAvailable?.(
+          { version: m.version, current: this.current, rollback: isRollbackForUs });
+      } catch { /* notifier is best-effort */ }
+      // Mark BEFORE installing/restarting: install() triggers an ext-host
+      // restart that can kill this context before a post-install write
+      // lands, so a non-converging restart must be fenced here. The guard
+      // is cooldown-bounded (see extension.ts) so a TRANSIENT install
+      // failure still retries on a later poll instead of bricking forever.
+      if (this.guard) this.guard.markAttempted(m.version, m.sha256);
+      await this.install(ab);
+      // install() resolved without throwing -> record success so this
+      // artifact is never re-attempted (the attempted slot above only
+      // rate-limits FAILED installs, which skip this line by throwing).
+      try { this.guard?.markInstalled?.(m.version, m.sha256); }
+      catch { /* best-effort */ }
+      // wave-2K-F03: stash the freshly-installed VSIX bytes as last-known-
+      // good so an `activate.fatal` (wave-2A-F03 dlog) bootstrap can roll
+      // back to it without operator intervention. Persisted by the
+      // extension's wired guard (extension.ts); ignored if the guard
+      // doesn't implement the hook (older callers).
+      try { this.guard?.recordLkg?.(m.version, Buffer.from(ab)); } catch { /* obs only */ }
+      this.consecutiveFails = 0;       // wave-2A-M4: success resets counter
+      return true;
+    } catch (e) {
+      // Outer catch: manifest fetch threw (most common case for the M4
+      // silent-degradation finding — peer can't reach the manifest URL).
+      return this.noteFail("manifest-fetch",
+        { msg: errMsg(e) });
+    }
+  }
+}
diff --git a/src/util/asset.ts b/src/util/asset.ts
new file mode 100644
index 0000000..52fb083
--- /dev/null
+++ b/src/util/asset.ts
@@ -0,0 +1,10 @@
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+
+export function resolveAsset(baseDir: string, subpath: string, ...names: [string, ...string[]]): string {
+  for (const name of names) {
+    const cands = [join(baseDir, name), join(baseDir, subpath, name)];
+    for (const c of cands) if (existsSync(c)) return c;
+  }
+  return join(baseDir, names[0]);
+}
diff --git a/src/util/claudeCodeVersion.ts b/src/util/claudeCodeVersion.ts
new file mode 100644
index 0000000..50e4749
--- /dev/null
+++ b/src/util/claudeCodeVersion.ts
@@ -0,0 +1,31 @@
+const CC_VERSION_RE =
+  /anthropic\.claude-code-(\d+)\.(\d+)\.(\d+)(?:[-/\\]|$)/;
+
+type Semver = [number, number, number];
+
+function parseClaudeCodeVersion(value: string): Semver | null {
+  const m = CC_VERSION_RE.exec(value);
+  if (!m) return null;
+  return [Number(m[1]), Number(m[2]), Number(m[3])];
+}
+
+/** Ascending comparator for Claude Code extension dirs/paths.
+ *
+ * VS Code leaves older extension directories behind. A plain lexicographic
+ * sort treats 2.1.99 as newer than 2.1.162, so locate by semver when the
+ * standard anthropic.claude-code-* shape is present and fall back to lexical
+ * ordering only for unexpected names.
+ */
+export function compareClaudeCodeInstall(a: string, b: string): number {
+  const av = parseClaudeCodeVersion(a);
+  const bv = parseClaudeCodeVersion(b);
+  if (av && bv) {
+    for (let i = 0; i < 3; i++) {
+      if (av[i] !== bv[i]) return av[i] - bv[i];
+    }
+    return a.localeCompare(b);
+  }
+  if (av) return 1;
+  if (bv) return -1;
+  return a.localeCompare(b);
+}
diff --git a/src/util/crypto.ts b/src/util/crypto.ts
new file mode 100644
index 0000000..38b30e9
--- /dev/null
+++ b/src/util/crypto.ts
@@ -0,0 +1,4 @@
+import { createHash } from "node:crypto";
+export function sha256(buf: Buffer): string {
+  return createHash("sha256").update(buf).digest("hex");
+}
diff --git a/src/util/errMsg.ts b/src/util/errMsg.ts
new file mode 100644
index 0000000..01d7478
--- /dev/null
+++ b/src/util/errMsg.ts
@@ -0,0 +1,6 @@
+/** Extract a human-readable error message from an unknown throw value.
+ *  Caps the result at `max` characters so log lines stay bounded. Used
+ *  throughout the extension in place of the duplicated inline pattern. */
+export function errMsg(e: unknown, max = 160): string {
+  return String((e as { message?: string })?.message ?? e).slice(0, max);
+}
diff --git a/src/util/http.ts b/src/util/http.ts
new file mode 100644
index 0000000..96e2fc1
--- /dev/null
+++ b/src/util/http.ts
@@ -0,0 +1,32 @@
+// Shared fetch wrapper that applies a request timeout (audit 2A-01 / EXT-04 /
+// 2J-09). None of the extension's HTTP clients passed an AbortSignal, so a
+// black-holed backend connection (no FIN, slow drip) would hang forever:
+// activation awaits portfolio + killswitch serially, the auth refresh
+// single-flight parks every later caller behind the one stuck promise, and
+// stuck keep-alive sockets accumulate against the 30s/60s/90s timers.
+//
+// Each client takes an injectable `f: Fetch` (tests pass a mock); only the
+// DEFAULT is wrapped, so production gets a timeout while test injection is
+// unchanged. A caller that supplies its own `signal` keeps it.
+
+type Fetch = typeof fetch;
+
+export function timeoutFetch(ms: number): Fetch {
+  return ((input: any, init?: any) => {
+    const existing = init && init.signal;
+    let signal = existing;
+    if (!signal) {
+      try {
+        // Node 18+/modern Electron expose AbortSignal.timeout; guard so an
+        // older host degrades to the prior (no-timeout) behaviour rather than
+        // throwing during activation.
+        signal = (AbortSignal as any).timeout
+          ? (AbortSignal as any).timeout(ms)
+          : undefined;
+      } catch {
+        signal = undefined;
+      }
+    }
+    return fetch(input, { ...(init || {}), signal });
+  }) as Fetch;
+}
diff --git a/src/util/loopback.ts b/src/util/loopback.ts
new file mode 100644
index 0000000..bb06a38
--- /dev/null
+++ b/src/util/loopback.ts
@@ -0,0 +1,13 @@
+/** Loopback-base check used by extension.ts to refuse non-loopback HTTP
+ *  bases (wave-2B-F01).
+ *
+ *  `new URL(base).hostname` handles `[::1]` bracket-stripping natively.
+ *  Never throws (malformed URL => not loopback => safe-closed). */
+export function isLoopbackBase(base: string): boolean {
+  try {
+    const host = (new URL(base).hostname || "").toLowerCase();
+    return host === "localhost" || host === "127.0.0.1" || host === "::1";
+  } catch {
+    return false;
+  }
+}
diff --git a/src/util/loopbackBoot.ts b/src/util/loopbackBoot.ts
new file mode 100644
index 0000000..32bd560
--- /dev/null
+++ b/src/util/loopbackBoot.ts
@@ -0,0 +1,105 @@
+import type * as vscode from "vscode";
+import { randomBytes } from "node:crypto";
+import type { Loopback } from "../loopback";
+import { resolveLoopbackBase } from "../loopback";
+
+const STABLE_TOKEN_KEY = "kickbacks.loopback.token";
+const STABLE_PORT_KEY = "kickbacks.loopback.port";
+
+export interface LoopbackBootResult { port: number; token: string; base: string; }
+
+export interface LoopbackBootOpts {
+  /** A SECONDARY registrant (the DebugController's stub wiring) shares the
+   *  server but never displaces a primary's handlers. The production webview
+   *  wiring (the default — primary) always takes the routes over, even from
+   *  an earlier-booted debug stub. */
+  secondary?: boolean;
+}
+
+/** ONE loopback server per extension host (audit #7). Pre-fix every normal
+ *  boot ran TWO: the boot-canary debug apply() bound the persisted stable
+ *  port P first and held it all session, so the production loopback
+ *  EADDRINUSE'd onto P+1 and OVERWROTE the "stable" port — the port crept
+ *  +1 per session, and a stale webview from the prior session (whose patch
+ *  baked port P) reconnected to the DEBUG server, whose wiring dropped
+ *  signed-out demo billing and blinded the desync watchdog. Now the first
+ *  successful boot owns the single server; every later caller SHARES it
+ *  via a live handler swap instead of binding a second port. */
+let shared: {
+  owner: Loopback; result: LoopbackBootResult; primary: boolean;
+} | null = null;
+// In-flight first-bind latch: two CONCURRENT first callers (an unawaited
+// debug apply racing the serving-retry loop, audit #5) could both observe
+// shared=null and mint two servers — the exact two-server state audit #7
+// removes. The later caller awaits the in-flight bind, then re-checks the
+// shared fast path.
+let starting: Promise<LoopbackBootResult> | null = null;
+
+/** Test-only: forget the in-process shared server (simulates an ext-host
+ *  restart, where module state is rebuilt from scratch). */
+export function resetSharedLoopbackForTest(): void {
+  shared = null;
+  starting = null;
+}
+
+/** Shared-server fast path (audit #7). `typeof` guards keep this safe when
+ *  a test mocks ../loopback with a reduced Loopback surface — those fall
+ *  through to the fresh-boot path (the pre-fix behavior). */
+function tryShared(
+  lb: Loopback, secondary: boolean,
+): LoopbackBootResult | null {
+  if (shared && shared.result.port > 0
+      && typeof shared.owner.isRunning === "function"
+      && shared.owner.isRunning()) {
+    if (lb === shared.owner) return shared.result;
+    if (typeof shared.owner.setHandlers === "function"
+        && typeof lb.handlers === "function") {
+      // Primary (production) wiring always takes the routes over — its /ad,
+      // metric and click handlers are the billing authority (and stay
+      // canServeAds-gated, wave 2). A secondary (debug stub) only installs
+      // its handlers while no primary has registered, so a later debug
+      // re-apply can never clobber live production wiring.
+      if (!secondary) {
+        shared.owner.setHandlers(lb.handlers());
+        shared.primary = true;
+      } else if (!shared.primary) {
+        shared.owner.setHandlers(lb.handlers());
+      }
+      return shared.result;
+    }
+  }
+  return null;
+}
+
+export async function bootLoopback(
+  lb: Loopback, ctx: vscode.ExtensionContext, opts: LoopbackBootOpts = {},
+): Promise<LoopbackBootResult> {
+  const secondary = opts.secondary === true;
+  for (;;) {
+    const hit = tryShared(lb, secondary);
+    if (hit) return hit;
+    if (!starting) break;
+    try { await starting; } catch { /* failed bind — re-check, then bind */ }
+    if (!shared) break;  // in-flight bind produced no server: bind ourselves
+  }
+  const bind = (async (): Promise<LoopbackBootResult> => {
+    let stableToken = ctx.globalState.get<string>(STABLE_TOKEN_KEY) || "";
+    if (!/^[0-9a-f]{16,}$/i.test(stableToken)) {
+      stableToken = randomBytes(16).toString("hex");
+      void ctx.globalState.update(STABLE_TOKEN_KEY, stableToken);
+    }
+    const stablePort = Number(ctx.globalState.get<number>(STABLE_PORT_KEY) || 0) || undefined;
+    const { port, token } = await lb.start({ token: stableToken, preferredPort: stablePort, preferredPortRange: 4 });
+    if (port > 0 && port !== stablePort) void ctx.globalState.update(STABLE_PORT_KEY, port);
+    const base = port < 0 ? "" : await resolveLoopbackBase(port, token);
+    const result: LoopbackBootResult = { port, token, base };
+    if (port > 0) shared = { owner: lb, result, primary: !secondary };
+    return result;
+  })();
+  starting = bind;
+  try {
+    return await bind;
+  } finally {
+    if (starting === bind) starting = null;
+  }
+}
diff --git a/src/viewTracking/timer.ts b/src/viewTracking/timer.ts
new file mode 100644
index 0000000..26ef4e9
--- /dev/null
+++ b/src/viewTracking/timer.ts
@@ -0,0 +1,226 @@
+/** W3 ad-viewership tracker.
+ *
+ *  WARNING — NOT SHIPPED (verified 2026-06-10, audit #23 / wave-1 EXT-03):
+ *  no production code imports this class. The esbuild bundle (entry
+ *  src/extension.ts) never reaches it; its only consumer is
+ *  test/viewTimer.test.ts. The LIVE view timers are block.asset.js (`_vt`,
+ *  CC overlay/banner), codex/block.asset.js, and statusBarAd.ts's inline
+ *  interval — all of which now END sessions on hide and CLAMP suspend/wake
+ *  poll gaps (see SUSPEND_GAP_MS in block.asset.js and the contract pinned
+ *  by test/cc-viewtimer.test.ts). This class still has the PRE-FIX
+ *  semantics by design of its 2026-05-22 refactor (no-op hide()/pause(),
+ *  unbounded wall-clock catch-up loop in emitTickIfDue — a laptop suspend
+ *  would replay the whole sleep gap as a billable tick burst on wake).
+ *  Do NOT wire it into a billing surface without first porting the
+ *  suspend-gap clamp + hide-ends-session semantics from block.asset.js.
+ *
+ *  An ad must accumulate `thresholdMs` of cumulative ELAPSED TIME on a
+ *  surface before it counts as "shown" and is eligible for credit. As of
+ *  the absolute-epoch baseline refactor, elapsed time is computed as
+ *  `now() - sessionStartedAt` on every poll — no accumulator, no
+ *  show/hide-driven pause. Rationale: the prior accumulator-with-pause
+ *  model dropped time whenever the 250 ms poll skipped (throttled tab,
+ *  CPU pressure) and produced "stuck session never bills" log signatures.
+ *  An absolute baseline is immune to poll-cadence drift, at the cost of
+ *  no longer pausing while the webview is hidden — bounded in practice
+ *  by mutual exclusion below + the server-side cooldown gate.
+ *
+ *  Two surfaces are tracked independently: the spinner-verb `overlay` and
+ *  the usage-banner `banner`. The same ad can be shown on both surfaces in
+ *  one session and each accumulates separately (it's a different visual
+ *  impression).
+ *
+ *  Hooks:
+ *    onTick(...)       — fired every `tickMs` of elapsed time (default
+ *                        5000 ms). Used to send `view_tick` metrics.
+ *    onThresholdMet()  — fired exactly once per (adId, surface, session)
+ *                        when elapsed crosses `thresholdMs`.
+ *    onErrorImpression() — fired at EVERY `maxSessionMs` boundary of
+ *                        elapsed time. Default 5 s, so a 30 s stuck
+ *                        session fires 6 events (at 5/10/15/20/25/30 s).
+ *                        Backend cooldown gate (cooldown_view_seconds)
+ *                        decides which of those actually credit. Mutually
+ *                        exclusive with onThresholdMet: if any
+ *                        error_impression has fired this session,
+ *                        threshold_met is suppressed — in practice this
+ *                        means threshold_met only fires when the cap is
+ *                        disabled (`maxSessionMs: 0`) or when
+ *                        `thresholdMs < maxSessionMs` (threshold-first
+ *                        config). */
+
+import { type AdSurface } from "../types/surface";
+
+export type { AdSurface };
+
+export interface TickEvent {
+  adId: string;
+  surface: AdSurface;
+  sessionNonce: string;
+  visibleMs: number;          // elapsed since session start (now - sessionStartedAt)
+}
+
+export interface ThresholdEvent extends TickEvent {
+  thresholdMs: number;
+}
+
+export interface ErrorImpressionEvent extends TickEvent {
+  maxSessionMs: number;
+}
+
+export interface ViewTimerOptions {
+  thresholdMs: number;                              // default 15_000
+  tickMs?: number;                                  // default 5_000
+  // MAX_SESSION_MS safety-net cap: if elapsed crosses this without a
+  // natural close, fire onErrorImpression exactly once so a stuck ad
+  // still bills. Default 5_000 ms (matches block.asset.js).
+  maxSessionMs?: number;
+  now?: () => number;                               // injectable for tests
+  onTick?: (e: TickEvent) => void;
+  onThresholdMet?: (e: ThresholdEvent) => void;
+  onErrorImpression?: (e: ErrorImpressionEvent) => void;
+}
+
+interface Session {
+  adId: string;
+  surface: AdSurface;
+  sessionNonce: string;
+  sessionStartedAt: number;     // absolute epoch when session started; never resets within a session
+  lastTickAtMs: number;         // last tick emitted at this elapsed total
+  thresholdMet: boolean;
+  // Count of error_impression events fired so far this session. Used to
+  // gate the *next* fire (at `(count+1) * maxSessionMs`) AND as the mutex
+  // signal for threshold_met (suppressed once any error_impression has
+  // fired this session).
+  errorImpressionCount: number;
+}
+
+export class ViewTimer {
+  private sessions = new Map<string, Session>();
+  private readonly thresholdMs: number;
+  private readonly tickMs: number;
+  private readonly maxSessionMs: number;
+  private readonly now: () => number;
+  private readonly onTick?: (e: TickEvent) => void;
+  private readonly onThresholdMet?: (e: ThresholdEvent) => void;
+  private readonly onErrorImpression?: (e: ErrorImpressionEvent) => void;
+
+  constructor(opts: ViewTimerOptions) {
+    this.thresholdMs = Math.max(0, opts.thresholdMs);
+    this.tickMs = Math.max(100, opts.tickMs ?? 5_000);
+    this.maxSessionMs = Math.max(0, opts.maxSessionMs ?? 5_000);
+    this.now = opts.now ?? (() => Date.now());
+    this.onTick = opts.onTick;
+    this.onThresholdMet = opts.onThresholdMet;
+    this.onErrorImpression = opts.onErrorImpression;
+  }
+
+  private key(adId: string, surface: AdSurface): string {
+    return `${surface}:${adId}`;
+  }
+
+  private elapsedFor(s: Session): number {
+    return Math.max(0, this.now() - s.sessionStartedAt);
+  }
+
+  /** Mark an ad as currently visible on a surface. Idempotent on the same
+   *  sessionNonce — the absolute baseline is sticky and does NOT restart
+   *  on repeated show() calls. A new sessionNonce resets the session. */
+  show(adId: string, surface: AdSurface, sessionNonce: string): void {
+    const k = this.key(adId, surface);
+    const existing = this.sessions.get(k);
+    if (!existing) {
+      this.sessions.set(k, { adId, surface, sessionNonce,
+        sessionStartedAt: this.now(), lastTickAtMs: 0,
+        thresholdMet: false, errorImpressionCount: 0 });
+      return;
+    }
+    if (existing.sessionNonce !== sessionNonce) {
+      // Fresh session — restart the baseline + clear all gate flags.
+      existing.sessionNonce = sessionNonce;
+      existing.sessionStartedAt = this.now();
+      existing.lastTickAtMs = 0;
+      existing.thresholdMet = false;
+      existing.errorImpressionCount = 0;
+    }
+    // Same nonce — sticky baseline; nothing to update.
+  }
+
+  /** No-op under the absolute-epoch baseline. The elapsed counter is
+   *  driven by wall clock from sessionStartedAt; a hidden ad still counts
+   *  toward billing because we cannot reliably re-anchor across poll
+   *  cadence drops (Page Visibility blur, tab throttling). Kept as a
+   *  public method so callers don't need to be rewritten. */
+  hide(_adId: string, _surface: AdSurface): void { /* no-op */ }
+
+  /** No-op under the absolute-epoch baseline. See hide(). */
+  pause(): void { /* no-op */ }
+
+  /** No-op under the absolute-epoch baseline. See hide(). */
+  resume(): void { /* no-op */ }
+
+  /** Drive the periodic emissions. Production calls this every ~250 ms;
+   *  tests advance the clock and call it directly. Safe to call multiple
+   *  times per "real" wall-clock tick: emissions are idempotent (each
+   *  session's threshold-met / error_impression fires at most once). */
+  poll(): void {
+    for (const s of this.sessions.values()) {
+      const elapsed = this.elapsedFor(s);
+      this.emitTickIfDue(s, elapsed);
+      this.emitThresholdIfMet(s, elapsed);
+      this.emitErrorImpressionIfStuck(s, elapsed);
+    }
+  }
+
+  private emitTickIfDue(s: Session, elapsed: number): void {
+    if (!this.onTick) { s.lastTickAtMs = elapsed; return; }
+    while (elapsed - s.lastTickAtMs >= this.tickMs) {
+      s.lastTickAtMs += this.tickMs;
+      this.onTick({ adId: s.adId, surface: s.surface,
+        sessionNonce: s.sessionNonce, visibleMs: s.lastTickAtMs });
+    }
+  }
+
+  private emitThresholdIfMet(s: Session, elapsed: number): void {
+    if (s.thresholdMet) return;
+    // Mutual exclusion: if ANY error_impression has fired this session,
+    // suppress the natural threshold-met event. With default cap=5s and
+    // threshold=15s this means threshold_met effectively never fires;
+    // when the cap is disabled (maxSessionMs=0) threshold_met still works.
+    if (s.errorImpressionCount > 0) return;
+    if (elapsed < this.thresholdMs) return;
+    s.thresholdMet = true;
+    this.onThresholdMet?.({
+      adId: s.adId, surface: s.surface, sessionNonce: s.sessionNonce,
+      visibleMs: elapsed, thresholdMs: this.thresholdMs,
+    });
+  }
+
+  private emitErrorImpressionIfStuck(s: Session, elapsed: number): void {
+    // Mutex: once threshold_met has billed this session, do not also fire
+    // error_impression — preserves the codex-rescue "one bill per session"
+    // contract for the threshold-first config (maxSessionMs > thresholdMs).
+    if (s.thresholdMet) return;
+    if (this.maxSessionMs <= 0) return;        // disabled
+    // Fire at every maxSessionMs boundary of elapsed time. A `nextFireAt`
+    // computed from `errorImpressionCount` keeps the cadence exact even
+    // when poll() skips: if 11 s elapsed since the last fire, the next
+    // single poll fires once and advances the marker; we do NOT fire
+    // twice to "catch up" (that would burst-bill on a throttled tab).
+    const nextFireAt = (s.errorImpressionCount + 1) * this.maxSessionMs;
+    if (elapsed < nextFireAt) return;
+    s.errorImpressionCount += 1;
+    this.onErrorImpression?.({
+      adId: s.adId, surface: s.surface, sessionNonce: s.sessionNonce,
+      visibleMs: elapsed, maxSessionMs: this.maxSessionMs,
+    });
+  }
+
+  /** Inspection helpers (used by tests + diagnostics). */
+  visibleMsFor(adId: string, surface: AdSurface): number {
+    const s = this.sessions.get(this.key(adId, surface));
+    return s ? this.elapsedFor(s) : 0;
+  }
+  hasMetThreshold(adId: string, surface: AdSurface): boolean {
+    return this.sessions.get(this.key(adId, surface))?.thresholdMet === true;
+  }
+}
diff --git a/test/adRotation-token-refresh.test.ts b/test/adRotation-token-refresh.test.ts
new file mode 100644
index 0000000..7f4ce2f
--- /dev/null
+++ b/test/adRotation-token-refresh.test.ts
@@ -0,0 +1,233 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { setupAdRotation, type AdRotationDeps } from "../src/activation/adRotation";
+import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";
+
+// Regression: the server mints a FRESH session token on every /v1/portfolio(/demo)
+// fetch (300s TTL). adRotation used to discard the refreshed response whenever the
+// ad SET (adId signature) was unchanged — so the in-use `activeAd.sessionToken`
+// aged out and every billable view event started returning 403 after ~5 min on
+// stable inventory. The fix adopts the fresh token without re-patching the overlay.
+
+function ad(adId: string, sessionToken: string): PatchAd {
+  return {
+    adId, campaignId: "c-" + adId, adText: "Ad " + adId,
+    iconRef: "i", iconUrl: "", clickUrl: "https://x.test",
+    bannerEnabled: false, sessionToken,
+  };
+}
+
+function resp(ads: PatchAd[]): PortfolioResponse {
+  return {
+    ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
+    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null,
+  };
+}
+
+function makeDeps(initial: PortfolioResponse, fetchImpl: () => Promise<PortfolioResponse>) {
+  const timers: NodeJS.Timeout[] = [];
+  const activeAdRef = { current: initial.ads[0] };
+  const adRef = { current: initial.ads[0] as PatchAd | null };
+  const applyPatch = vi.fn(() => ({ ok: true }));
+  const deps = {
+    adapter: { applyPatch, isPatched: () => true,
+               preflight: () => ({ compatible: true }), restore: () => {} },
+    portfolio: { fetchPortfolio: fetchImpl, fetchDemoPortfolio: fetchImpl },
+    auth: { accessToken: () => "tok", clientId: () => "cid" },
+    debugCtl: { setPortfolioAd: vi.fn() },
+    session: { set: vi.fn() },
+    ccVersion: "2.1.167",
+    port: 12345,
+    patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
+    activeAdRef,
+    corrRef: { current: "corr" },
+    adRef,
+    impDedupe: { reset: vi.fn() },
+    reapplyCodex: null,
+    timers,
+  } as unknown as AdRotationDeps;
+  return { deps, timers, activeAdRef, adRef, applyPatch };
+}
+
+describe("adRotation session-token refresh", () => {
+  const cleanups: Array<() => void> = [];
+  afterEach(() => { cleanups.forEach((c) => c()); cleanups.length = 0; });
+
+  it("adopts the fresh session token when the ad set is unchanged (no re-patch)", async () => {
+    const initial = resp([ad("a1", "tok-OLD")]);
+    const fetchImpl = vi.fn(async () => resp([ad("a1", "tok-NEW")])); // same adId, new token
+    const { deps, timers, activeAdRef, adRef, applyPatch } = makeDeps(initial, fetchImpl);
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    applyPatch.mockClear();              // ignore any setup-time apply
+    await handle.refreshNow(false);      // unchanged ad set → must still refresh token
+
+    expect(fetchImpl).toHaveBeenCalled();
+    expect(activeAdRef.current.sessionToken).toBe("tok-NEW");
+    expect(adRef.current?.sessionToken).toBe("tok-NEW");
+    // Unchanged text/clickUrl ⇒ the overlay must NOT be re-patched on a pure
+    // token refresh (no visible churn, no loopback re-mint).
+    expect(applyPatch).not.toHaveBeenCalled();
+  });
+
+  it("still swaps fully (re-patch) when the ad actually changes", async () => {
+    const initial = resp([ad("a1", "tok-OLD")]);
+    const fetchImpl = vi.fn(async () => resp([ad("a2", "tok-A2")])); // different adId
+    const { deps, timers, activeAdRef, applyPatch } = makeDeps(initial, fetchImpl);
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    applyPatch.mockClear();
+    await handle.refreshNow(false);
+
+    expect(activeAdRef.current.adId).toBe("a2");
+    expect(activeAdRef.current.sessionToken).toBe("tok-A2");
+    expect(applyPatch).toHaveBeenCalled(); // real ad change ⇒ overlay re-patched
+  });
+
+  // BL-187: the demo stamp must travel WITH the adopted token. A mid-session
+  // demotion can return the SAME adId from the demo portfolio; adopting only
+  // the token left the object demo:false, so the status bar (whose signed-out
+  // gate is `!ad.demo` — it has no signedIn() probe) kept showing and billing
+  // ads while signed out.
+  it("adopts the demo stamp when a demotion swaps to demo ads with the same id", async () => {
+    const initial = resp([ad("a1", "tok-REAL")]);          // real: no demo flag
+    const fetchImpl = vi.fn(async () =>
+      resp([{ ...ad("a1", "demo-tok"), demo: true }]));    // demoted, same adId
+    const { deps, timers, activeAdRef, adRef, applyPatch } = makeDeps(initial, fetchImpl);
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    applyPatch.mockClear();
+    await handle.refreshNow(false);
+
+    expect(activeAdRef.current.sessionToken).toBe("demo-tok");
+    expect(activeAdRef.current.demo).toBe(true);   // statusbar gate re-engages
+    expect(adRef.current?.demo).toBe(true);
+    expect(applyPatch).not.toHaveBeenCalled();     // still a churn-free adopt
+  });
+
+  it("clears the demo stamp when a re-auth swaps back to real ads", async () => {
+    const initial = resp([{ ...ad("a1", "demo-tok"), demo: true }]);
+    const fetchImpl = vi.fn(async () => resp([ad("a1", "tok-REAL")])); // real again
+    const { deps, timers, activeAdRef, adRef } = makeDeps(initial, fetchImpl);
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    await handle.refreshNow(false);
+
+    expect(activeAdRef.current.sessionToken).toBe("tok-REAL");
+    expect(activeAdRef.current.demo).toBeFalsy();  // real ads aren't suppressed
+    expect(adRef.current?.demo).toBeFalsy();
+  });
+});
+
+// Dead-token recovery (the "frozen ads" 401 loop, 2026-06-11): a client whose
+// cached access token the server rejects used to 401 on /v1/portfolio every
+// 60s FOREVER — refreshPortfolio swallowed the error, no fresh inventory ever
+// arrived, and every surface kept the last-baked creative. The 60s refresh
+// now routes through fetchPortfolioWithDemoFallback (same ladder as
+// activation): one auth refresh, demote to demo ONLY on authoritative
+// rejection, hold (no demotion) on transient failure.
+describe("adRotation dead-token recovery (frozen-ads 401 loop)", () => {
+  const cleanups: Array<() => void> = [];
+  afterEach(() => { cleanups.forEach((c) => c()); cleanups.length = 0; });
+
+  function makeAuthDeps(initial: PortfolioResponse, opts: {
+    fetchPortfolio: () => Promise<PortfolioResponse | null>;
+    fetchDemoPortfolio: () => Promise<PortfolioResponse | null>;
+    refresh: () => Promise<boolean>;
+    token: () => string | null;
+  }) {
+    const timers: NodeJS.Timeout[] = [];
+    const activeAdRef = { current: initial.ads[0] };
+    const adRef = { current: initial.ads[0] as PatchAd | null };
+    const applyPatch = vi.fn(() => ({ ok: true }));
+    const deps = {
+      adapter: { applyPatch, isPatched: () => true,
+                 preflight: () => ({ compatible: true }), restore: () => {} },
+      portfolio: { fetchPortfolio: opts.fetchPortfolio,
+                   fetchDemoPortfolio: opts.fetchDemoPortfolio },
+      auth: { accessToken: opts.token, clientId: () => "cid",
+              refresh: opts.refresh },
+      debugCtl: { setPortfolioAd: vi.fn() },
+      session: { set: vi.fn() },
+      ccVersion: "2.1.167",
+      port: 12345,
+      patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
+      activeAdRef,
+      corrRef: { current: "corr" },
+      adRef,
+      impDedupe: { reset: vi.fn() },
+      reapplyCodex: null,
+      timers,
+    } as unknown as AdRotationDeps;
+    return { deps, timers, activeAdRef, adRef, applyPatch };
+  }
+
+  it("authoritative rejection: 401 portfolio + failed refresh that CLEARS "
+    + "the token demotes to DEMO ads instead of spinning frozen", async () => {
+    const initial = resp([ad("a1", "tok-DEAD")]);
+    let token: string | null = "tok-DEAD";
+    const fetchPortfolio = vi.fn(async () => null);          // 401, no cache
+    const fetchDemoPortfolio = vi.fn(async () =>
+      resp([{ ...ad("a1", "demo-tok"), demo: true }]));
+    const refresh = vi.fn(async () => { token = null; return false; });
+    const { deps, timers, activeAdRef, adRef } = makeAuthDeps(initial,
+      { fetchPortfolio, fetchDemoPortfolio, refresh, token: () => token });
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    await handle.refreshNow(false);
+
+    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(fetchDemoPortfolio).toHaveBeenCalled();
+    // Same adId → churn-free adopt of the demo token + stamp (BL-187 path).
+    expect(activeAdRef.current.sessionToken).toBe("demo-tok");
+    expect(activeAdRef.current.demo).toBe(true);
+    expect(adRef.current?.demo).toBe(true);
+  });
+
+  it("transient failure: refresh fails but the token survives → HOLD the "
+    + "current ad (no demo demotion, no churn)", async () => {
+    const initial = resp([ad("a1", "tok-MAYBE-FINE")]);
+    const fetchPortfolio = vi.fn(async () => null);          // offline this tick
+    const fetchDemoPortfolio = vi.fn(async () =>
+      resp([{ ...ad("a1", "demo-tok"), demo: true }]));
+    const refresh = vi.fn(async () => false);                // transport failure
+    const { deps, timers, activeAdRef, applyPatch } = makeAuthDeps(initial,
+      { fetchPortfolio, fetchDemoPortfolio, refresh,
+        token: () => "tok-MAYBE-FINE" });                    // token KEPT
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    applyPatch.mockClear();
+    await handle.refreshNow(false);
+
+    expect(fetchDemoPortfolio).not.toHaveBeenCalled();       // no demotion
+    expect(activeAdRef.current.sessionToken).toBe("tok-MAYBE-FINE");
+    expect(activeAdRef.current.demo).toBeFalsy();
+    expect(applyPatch).not.toHaveBeenCalled();
+  });
+
+  it("revived token: 401 portfolio + successful refresh re-fetches REAL ads "
+    + "(no demo detour)", async () => {
+    const initial = resp([ad("a1", "tok-OLD")]);
+    let refreshed = false;
+    const fetchPortfolio = vi.fn(async () =>
+      refreshed ? resp([ad("a1", "tok-REVIVED")]) : null);
+    const fetchDemoPortfolio = vi.fn(async () => null);
+    const refresh = vi.fn(async () => { refreshed = true; return true; });
+    const { deps, timers, activeAdRef } = makeAuthDeps(initial,
+      { fetchPortfolio, fetchDemoPortfolio, refresh, token: () => "tok-OLD" });
+    cleanups.push(() => timers.forEach((t) => clearInterval(t as unknown as NodeJS.Timeout)));
+
+    const handle = setupAdRotation(deps, initial);
+    await handle.refreshNow(false);
+
+    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(fetchDemoPortfolio).not.toHaveBeenCalled();
+    expect(activeAdRef.current.sessionToken).toBe("tok-REVIVED");
+    expect(activeAdRef.current.demo).toBeFalsy();
+  });
+});
diff --git a/test/adapter.test.ts b/test/adapter.test.ts
new file mode 100644
index 0000000..bbf30bf
--- /dev/null
+++ b/test/adapter.test.ts
@@ -0,0 +1,447 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync }
+  from "node:fs";
+import { execFileSync } from "node:child_process";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ClaudeCodeAdapter, resolveBlockAsset }
+  from "../src/adapters/claude-code/adapter";
+
+const FIX = readFileSync(join(__dirname, "fixtures/synthetic-index.js"), "utf8");
+
+function tmpTarget(): string {
+  const d = mkdtempSync(join(tmpdir(), "vibe-ads-"));
+  const p = join(d, "index.js");
+  writeFileSync(p, FIX, "utf8");
+  return p;
+}
+const params = {
+  tier: 3 as const, adText: "Ramp corporate cards & expense mgmt",
+  iconRef: "icon.r", iconUrl: "", clickToken: "ck", clickUrl: "https://ramp.example/lp", corr: "ad1.tst",
+  loopbackPort: 5555, loopbackToken: "lt",
+  loopbackBase: "http://127.0.0.1:5555",
+};
+
+describe("ClaudeCodeAdapter", () => {
+  let target: string;
+  beforeEach(() => { target = tmpTarget(); });
+
+  it("preflight compatible on a fixture with a >=2-elem anchored array", () => {
+    const a = new ClaudeCodeAdapter(target);
+    const pf = a.preflight();
+    expect(pf.compatible).toBe(true);
+    expect(pf.version).not.toBeNull();
+  });
+
+  it("version(): strips CC's platform-specific packaging suffix", () => {
+    // CC moved to platform packages (…-win32-x64); the label must read the
+    // bare semver, not "2.1.161-win32-x64" — across every platform/arch combo.
+    const v = (dir: string) =>
+      new ClaudeCodeAdapter(`/x/${dir}/webview/index.js`).version();
+    expect(v("anthropic.claude-code-2.1.161-win32-x64")).toBe("2.1.161");
+    expect(v("anthropic.claude-code-2.1.161-darwin-arm64")).toBe("2.1.161");
+    expect(v("anthropic.claude-code-2.1.180-linux-x64")).toBe("2.1.180");
+    expect(v("anthropic.claude-code-2.1.143")).toBe("2.1.143"); // legacy, no suffix
+  });
+
+  it("version(): 'unknown' when the path has no claude-code dir segment", () => {
+    // The no-target sentinel path resolves to no semver — never throw, never
+    // mislabel.
+    expect(new ClaudeCodeAdapter("/__vibe_ads_no_target__").version())
+      .toBe("unknown");
+  });
+
+  it("isPatched(): false pristine, true after applyPatch, false after restore",
+    () => {
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.isPatched()).toBe(false);
+    a.applyPatch(params);
+    expect(a.isPatched()).toBe(true);
+    a.restore();
+    expect(a.isPatched()).toBe(false);
+  });
+
+  it("diagnose(): compatible fixture reports live array + bare verb present", () => {
+    const d = new ClaudeCodeAdapter(target).diagnose();
+    expect(d.compatible).toBe(true);
+    expect(d.targetExists).toBe(true);
+    expect(d.live.hasArray).toBe(true);
+    expect(d.live.bareVerbPresent).toBe(true);
+  });
+
+  it("diagnose(): stripped file reports incompatible, no array, no bare verb (→ reinstall CC)", () => {
+    writeFileSync(target, 'var V=["Alpha","Bravo"];', "utf8");
+    const d = new ClaudeCodeAdapter(target).diagnose();
+    expect(d.compatible).toBe(false);
+    expect(d.live.hasArray).toBe(false);
+    expect(d.live.bareVerbPresent).toBe(false);
+  });
+
+  it("diagnose(): verb word present but not in an array (→ bundle-format change)", () => {
+    writeFileSync(target, 'var msg="Discombobulating the widgets";', "utf8");
+    const d = new ClaudeCodeAdapter(target).diagnose();
+    expect(d.compatible).toBe(false);
+    expect(d.live.hasArray).toBe(false);
+    expect(d.live.bareVerbPresent).toBe(true);
+  });
+
+  it("findArray is resilient: locates the array via an alternate verb when Discombobulating is gone", () => {
+    // Simulate a future CC that renamed/removed "Discombobulating" but kept the
+    // verb array (with other distinctive verbs). Detection must survive.
+    writeFileSync(target,
+      'var Gz1=["Working","Flibbertigibbeting","Brewing","Clauding","Cooking"];', "utf8");
+    expect(new ClaudeCodeAdapter(target).preflight().compatible).toBe(true);
+  });
+
+  it("preflight still incompatible when NONE of the anchor verbs are present", () => {
+    writeFileSync(target, 'var V=["Alpha","Bravo","Charlie","Delta"];', "utf8");
+    expect(new ClaudeCodeAdapter(target).preflight().compatible).toBe(false);
+  });
+
+  it("preflight falls back to the live file when the backup is stale (no verb array)", () => {
+    // Field repro (shipuser machine): a tainted/truncated .kickbacks-backup
+    // lacks the verb array, so preflight USED to read it and dead-end as
+    // incompatible even though the live CC build is fine. It must now fall back
+    // to the live file and report compatible.
+    const a = new ClaudeCodeAdapter(target);
+    writeFileSync(target + ".kickbacks-backup", "garbage without the anchor", "utf8");
+    expect(a.preflight().compatible).toBe(true);
+  });
+
+  it("applyPatch recaptures a clean backup when the existing one is stale", () => {
+    const a = new ClaudeCodeAdapter(target);
+    const bak = target + ".kickbacks-backup";
+    writeFileSync(bak, "garbage without the anchor", "utf8");
+    const res = a.applyPatch(params);
+    expect(res.ok).toBe(true);
+    expect(a.isPatched()).toBe(true);
+    // The stale backup was replaced by a fresh capture from the live file, so
+    // the pristine source once again carries the verb array.
+    expect(readFileSync(bak, "utf8")).toContain('"Discombobulating"');
+  });
+
+  it("preflight incompatible only when BOTH backup and live lack the array", () => {
+    const a = new ClaudeCodeAdapter(target);
+    writeFileSync(target, 'var V=["A","B","C"];', "utf8");          // live: no anchor
+    writeFileSync(target + ".kickbacks-backup", "also no anchor", "utf8"); // backup: no anchor
+    const pf = a.preflight();
+    expect(pf.compatible).toBe(false);
+    expect(pf.reason).toContain("verb array not found");
+  });
+
+  it("preflight incompatible (graceful) when anchor missing — no writes", () => {
+    writeFileSync(target, 'var V=["A","B","C"];', "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.preflight().compatible).toBe(false);
+    expect(readFileSync(target, "utf8")).toBe('var V=["A","B","C"];'); // untouched
+  });
+
+  // Self-heal regression: deactivate() is best-effort, and a *different*
+  // extension version can leave the file patched. On next load preflight()
+  // reads the live (already-patched) file whose anchor our own Tier-0 swap
+  // removed — it must NOT dead-end as "incompatible". A co-located pristine
+  // backup proves THIS build was compatible, so preflight evaluates against
+  // it (same source applyPatch() already uses). Without this the only
+  // recovery is a manual debug-menu off→on toggle.
+  it("preflight self-heals: compatible when the live file is already-patched but a pristine backup exists", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params); // live file now patched: block appended after the verb array
+    const patched = readFileSync(target, "utf8");
+    // Sanity: CC's native verb dictionary is INTACT now (we no longer
+    // collapse it to [adText,""]). The visible failure when our block
+    // fails is then CC's normal "Discombobulating…" cycling — a clear
+    // signal that the overlay didn't render, instead of a plain-text
+    // ad that silently masks block.desync.
+    expect(patched).toContain('"Discombobulating"');
+    expect(patched).toContain('/* VIBE-ADS-START */');
+    expect(existsSync(target + ".kickbacks-backup")).toBe(true);
+    const pf = a.preflight();
+    expect(pf.compatible).toBe(true);
+    expect(pf.version).not.toBeNull();
+  });
+
+  it("applyPatch writes a one-time backup, swaps the array, appends one block, idempotent", () => {
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.applyPatch(params).ok).toBe(true);
+    const after1 = readFileSync(target, "utf8");
+    expect(existsSync(target + ".kickbacks-backup")).toBe(true);
+    expect(after1).toContain(params.adText);
+    expect(after1).toContain("/* VIBE-ADS-START */");
+    expect((after1.match(/VIBE-ADS-START/g) || []).length).toBe(1);
+    a.applyPatch(params); // idempotent
+    expect(readFileSync(target, "utf8")).toBe(after1);
+  });
+
+  it("restore is byte-exact and honest", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX); // byte-exact
+    expect(existsSync(target + ".kickbacks-backup")).toBe(false);
+    const r2 = a.restore(); // honest: nothing to restore
+    expect(r2.restored).toBe(false);
+    expect(r2.reason).toMatch(/no backup/i);
+  });
+
+  // Reversibility across the Vibe-Ads rename: an install patched BEFORE the
+  // rename carries a ".vibads-backup" pristine file and a /* VIBADS-START */
+  // block. restore() must still find that backup, and re-apply must strip the
+  // legacy block instead of stacking a second one.
+  it("restores a pre-rename install from the legacy .vibads-backup", () => {
+    const legacyBak = target + ".vibads-backup";
+    writeFileSync(legacyBak, FIX, "utf8"); // pristine captured pre-rename
+    writeFileSync(target,
+      'var V=["x"];/* VIBADS-START */window.__v=1;/* VIBADS-END */', "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX); // byte-exact pristine
+    expect(existsSync(legacyBak)).toBe(false); // legacy backup consumed
+  });
+
+  it("re-apply over a legacy VIBADS block does not stack (single new block)", () => {
+    writeFileSync(target + ".vibads-backup", FIX, "utf8");
+    writeFileSync(target,
+      FIX + '\n/* VIBADS-START */window.__v=1;/* VIBADS-END */\n', "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.applyPatch(params).ok).toBe(true);
+    const out = readFileSync(target, "utf8");
+    expect((out.match(/VIBADS-START/g) || []).length).toBe(0); // legacy stripped
+    expect((out.match(/VIBE-ADS-START/g) || []).length).toBe(1); // exactly one new
+    expect(out).toContain(params.adText);
+  });
+
+  it("never throws on a bad path", () => {
+    const a = new ClaudeCodeAdapter("/no/such/dir/index.js");
+    expect(a.preflight().compatible).toBe(false);
+    expect(a.applyPatch(params).ok).toBe(false);
+    expect(a.restore().restored).toBe(false);
+  });
+
+  it("substitutes __VIBE_ADS_BANNER_ON__ and carries no legacy banner sentinel", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch({ ...params, bannerOn: true });
+    const out = readFileSync(target, "utf8");
+    expect(out).toContain("var BANNER_ON = true");
+    const asset = readFileSync(
+      join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+    expect(asset.includes("__VIBE_ADS_BANNER__")).toBe(false);
+    expect(asset.includes("var BANNER =")).toBe(false);
+  });
+
+  it("bannerOn false ⇒ var BANNER_ON = false", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch({ ...params, bannerOn: false });
+    expect(readFileSync(target, "utf8")).toContain("var BANNER_ON = false");
+  });
+});
+
+// CSP connect-src persistence. The relaxation must outlive a routine
+// deactivate()/reload — Claude Code captures its webview CSP template into
+// memory at extension-host load, BEFORE our re-patch, so a CSP reverted on
+// every deactivate is never effective for the running session (loopback
+// telemetry stays CSP-blocked). Policy: routine restore keeps the (invisible,
+// loopback-only) connect-src; explicit restore/kill/sign-out fully reverts.
+describe("CSP sibling patch + scoped restore", () => {
+  // target = <root>/webview/index.js so extTarget() -> <root>/extension.js.
+  function withSibling(): { target: string; ext: string } {
+    const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-"));
+    mkdirSync(join(root, "webview"), { recursive: true });
+    const target = join(root, "webview", "index.js");
+    writeFileSync(target, FIX, "utf8");
+    const ext = join(root, "extension.js");
+    // contains the exact CSP anchor the adapter rewrites (literal ${q})
+    writeFileSync(ext, "globalThis.x=1;var CSP=\"default-src 'none'; ${q}; img-src data:\";", "utf8");
+    return { target, ext };
+  }
+  const MARK = "connect-src http://127.0.0.1:*";
+
+  it("applyPatch inserts connect-src into the sibling extension.js + backs it up", () => {
+    const { target, ext } = withSibling();
+    expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
+    expect(readFileSync(ext, "utf8")).toContain(MARK);
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);
+  });
+
+  it("restore({keepCsp:true}) reverts index.js but LEAVES the CSP relaxation", () => {
+    const { target, ext } = withSibling();
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);
+    const r = a.restore({ keepCsp: true });
+    expect(r.restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX);            // visible change gone
+    expect(readFileSync(ext, "utf8")).toContain(MARK);         // CSP persists
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);   // still reversible
+  });
+
+  it("restore() (explicit, no opts) fully reverts BOTH index.js and the CSP", () => {
+    const { target, ext } = withSibling();
+    const pristineExt = readFileSync(ext, "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX);
+    expect(readFileSync(ext, "utf8")).toBe(pristineExt);       // byte-exact
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(false);
+  });
+
+  // Regression: CC 2.1.145 renamed the first template variable in the CSP
+  // template from ${q} to ${U}, which silently broke the CSP patch because
+  // the patcher's anchor was a literal string. Every webview→loopback fetch
+  // (clicks, impressions, view-tracking) was CSP-blocked. The fix made
+  // CSP_ANCHOR_RE match any single-identifier template token; this test
+  // pins that 2.1.143's ${q}, 2.1.145's ${U}, and a hypothetical future
+  // ${q2} all patch correctly, while preserving the original variable so the
+  // rest of the CSP template renders identically.
+  it("CSP patch tolerates CC template-variable renames (2.1.143/${q},"
+    + " 2.1.145/${U}, and arbitrary single-identifier tokens)", () => {
+    const cases = [
+      { label: "2.1.143", token: "${q}" },
+      { label: "2.1.145", token: "${U}" },
+      { label: "hypothetical", token: "${q2}" },
+    ];
+    for (const c of cases) {
+      const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-ver-"));
+      mkdirSync(join(root, "webview"), { recursive: true });
+      const target = join(root, "webview", "index.js");
+      writeFileSync(target, FIX, "utf8");
+      const ext = join(root, "extension.js");
+      const cspLine =
+        `var CSP="default-src 'none'; ${c.token}; ${"${M}"}; "`
+        + `+ "script-src 'nonce-${"${D}"}'";`;
+      writeFileSync(ext, cspLine, "utf8");
+      expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
+      const patched = readFileSync(ext, "utf8");
+      expect(patched).toContain(MARK);                             // relaxed
+      expect(patched).toContain(c.token);                          // var preserved
+      expect(patched).toContain(`'none'; connect-src`);            // additive connect-src right after default-src
+      expect(patched).not.toContain("img-src https://");           // H3: never inject our own img-src
+    }
+  });
+
+  // H3 regression: real CC builds ship `${M}` = `img-src ${cspSource} data:`.
+  // The patch must NOT emit a SECOND img-src — per CSP3 only the first is
+  // honored, so a narrow ad-icon img-src would silently override CC's own
+  // image policy and break CC's data:/markdown/webview images (prime
+  // directive). Assert the patched policy has exactly one img-src (CC's).
+  it("CSP patch does not duplicate img-src (preserves CC's image policy)", () => {
+    const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-img-"));
+    mkdirSync(join(root, "webview"), { recursive: true });
+    const target = join(root, "webview", "index.js");
+    writeFileSync(target, FIX, "utf8");
+    const ext = join(root, "extension.js");
+    // CC's real shape: first var, then an img-src directive with data:.
+    writeFileSync(ext,
+      "var CSP=\"default-src 'none'; ${q}; img-src https://cc.example data:\";",
+      "utf8");
+    expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
+    const patched = readFileSync(ext, "utf8");
+    expect(patched).toContain(MARK);                                  // our connect-src added
+    expect((patched.match(/img-src/g) || []).length).toBe(1);         // exactly ONE img-src
+    expect(patched).toContain("img-src https://cc.example data:");    // and it's CC's, intact
+  });
+
+  // Regression: when the CSP anchor literally can't be found in extension.js,
+  // applyPatch used to silently skip the relaxation — and the user only
+  // discovered telemetry was dead by noticing the ledger had no clicks. Pin
+  // that the visible patch (index.js) still succeeds (prime directive: never
+  // break the editor), but the CSP miss is observable via the return shape
+  // so future CC renames are diagnosable without grepping debug.log.
+  it("applyPatch succeeds even when the CSP anchor isn't present (no-op CSP,"
+    + " visible patch still lands — preserves prime directive)", () => {
+    const root = mkdtempSync(join(tmpdir(), "vibe-ads-csp-no-anchor-"));
+    mkdirSync(join(root, "webview"), { recursive: true });
+    const target = join(root, "webview", "index.js");
+    writeFileSync(target, FIX, "utf8");
+    const ext = join(root, "extension.js");
+    // CSP template variable position has been replaced with something the
+    // regex won't match (e.g. a different shape altogether).
+    writeFileSync(ext, "var CSP=\"unrelated cspstuff goes here\";", "utf8");
+    expect(new ClaudeCodeAdapter(target).applyPatch(params).ok).toBe(true);
+    expect(readFileSync(ext, "utf8")).not.toContain(MARK);  // CSP unmodified
+    // But the visible patch DID land — index.js has the block
+    expect(readFileSync(target, "utf8")).toContain("/* VIBE-ADS-START */");
+  });
+
+  // prime(): the boot-time "guaranteed startup reassert". Relaxes the CSP so
+  // the loopback surface is ready BEFORE an ad is in hand, WITHOUT injecting a
+  // visible block. Same connect-src + backup + reversibility as applyPatch's
+  // CSP layer, but index.js is never touched.
+  it("prime() inserts the connect-src + backs up the sibling, WITHOUT injecting a block", () => {
+    const { target, ext } = withSibling();
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.prime().ok).toBe(true);
+    expect(readFileSync(ext, "utf8")).toContain(MARK);          // CSP relaxed
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);    // reversible
+    expect(readFileSync(target, "utf8")).toBe(FIX);             // index.js untouched
+    expect(a.isPatched()).toBe(false);                          // no visible block
+  });
+
+  it("prime() is idempotent (second call is an 'already' no-op, no double insert)", () => {
+    const { target, ext } = withSibling();
+    const a = new ClaudeCodeAdapter(target);
+    a.prime();
+    const after1 = readFileSync(ext, "utf8");
+    const r2 = a.prime();
+    expect(r2.ok).toBe(true);
+    expect(r2.reason).toBe("already");
+    expect(readFileSync(ext, "utf8")).toBe(after1);             // byte-identical
+    expect((after1.match(/connect-src http:\/\/127\.0\.0\.1/g) || []).length).toBe(1);
+  });
+
+  it("prime() then applyPatch is a no-op on the already-primed CSP; restore({keepCsp:true}) keeps it", () => {
+    const { target, ext } = withSibling();
+    const a = new ClaudeCodeAdapter(target);
+    a.prime();
+    const primedExt = readFileSync(ext, "utf8");
+    expect(a.applyPatch(params).ok).toBe(true);                 // ad arrives
+    expect(readFileSync(ext, "utf8")).toBe(primedExt);          // CSP unchanged by applyPatch
+    expect(a.isPatched()).toBe(true);                           // block now present
+    a.restore({ keepCsp: true });
+    expect(readFileSync(ext, "utf8")).toContain(MARK);          // CSP survives routine restore
+  });
+
+  it("prime() then explicit restore() fully reverts the primed CSP (byte-exact)", () => {
+    const { target, ext } = withSibling();
+    const pristineExt = readFileSync(ext, "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    a.prime();
+    expect(readFileSync(ext, "utf8")).toContain(MARK);
+    a.restore();                                                // explicit, no opts
+    expect(readFileSync(ext, "utf8")).toBe(pristineExt);        // CSP reverted byte-exact
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("prime() never throws on a bad path", () => {
+    const a = new ClaudeCodeAdapter("/no/such/dir/webview/index.js");
+    expect(a.prime().ok).toBe(false);                           // graceful: no sibling
+  });
+});
+
+// Regression for S3 Wave 1 review CRIT #1: the block asset must resolve in
+// BOTH the unbundled (co-located) and the esbuild-bundled (dist/) layouts.
+describe("resolveBlockAsset (bundled-vs-unbundled)", () => {
+  it("finds the asset co-located (unbundled / vitest layout)", () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-rb1-"));
+    writeFileSync(join(d, "block.asset.js"), "x", "utf8");
+    expect(resolveBlockAsset(d)).toBe(join(d, "block.asset.js"));
+  });
+  it("finds the asset at adapters/claude-code (bundled dist/ layout)", () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-rb2-"));
+    mkdirSync(join(d, "adapters", "claude-code"), { recursive: true });
+    writeFileSync(join(d, "adapters", "claude-code", "block.asset.js"), "x", "utf8");
+    expect(resolveBlockAsset(d)).toBe(join(d, "adapters", "claude-code", "block.asset.js"));
+  });
+  it("esbuild build actually places the asset where the BUNDLED adapter looks", () => {
+    // Build the real bundle, then assert the asset exists at the path
+    // resolveBlockAsset() will compute from dirname(dist/extension.js).
+    execFileSync("node", ["esbuild.mjs"], { cwd: join(__dirname, ".."), stdio: "pipe" });
+    const distDir = join(__dirname, "..", "dist");
+    expect(existsSync(join(distDir, "extension.js"))).toBe(true);
+    const resolved = resolveBlockAsset(distDir); // dirname(__filename) at runtime
+    expect(existsSync(resolved)).toBe(true);
+    expect(resolved).toBe(join(distDir, "adapters", "claude-code", "block.asset.js"));
+  });
+});
diff --git a/test/attribution.test.ts b/test/attribution.test.ts
new file mode 100644
index 0000000..d7e9eee
--- /dev/null
+++ b/test/attribution.test.ts
@@ -0,0 +1,318 @@
+/** Rotation/poll-lag billing attribution (audit 2026-06-09 finding #17).
+ *
+ *  A rotation flips the host's activeAd instantly, but the webview only
+ *  learns of it from its own 10s /ad poll — so for up to 10s it keeps
+ *  emitting the OLD ad's view events (its metric GETs carry an `ad=` claim)
+ *  and possibly a click on the OLD anchor. Pre-fix the host stamped
+ *  adId/campaignId/sessionToken from the NEW activeAd at arrival time:
+ *  cross-campaign billing misattribution on every rotation boundary.
+ *
+ *  Pins:
+ *    • the loopback lifts the `ad=` claim into the onEvent payload and the
+ *      onClick args;
+ *    • an event/click claiming the OLD ad after a swap bills the OLD
+ *      campaign AND old session token (10s-stale token is fine, TTL 300s);
+ *    • the deployed block sends the ad TEXT as its claim (viewShow(AD, …)),
+ *      so text claims resolve too;
+ *    • unknown/absent claims fall back to activeAd (pre-fix behavior);
+ *    • the impression dedupe keys on the RESOLVED ad, so the old ad's late
+ *      impression can't consume the new ad's dedupe slot (and repeats of the
+ *      old ad's impression are still deduped within its own ad);
+ *    • the wave-2 canServeAds gate still runs FIRST — a gated install bills
+ *      nothing even for a known old-ad claim.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../src/modes", () => ({
+  webviewMode: () => "on",
+  cliMode: () => "on",
+  bannerOverride: () => "server",
+  setWebviewMode: () => {},
+  setCliMode: () => {},
+  setBannerOverride: () => {},
+}));
+
+import { Loopback } from "../src/loopback";
+import { resetServingGate, setKillPosture } from "../src/servingGate";
+import { setupWebviewInjection } from "../src/activation/webviewInjection";
+import { createActivationContext, type ActivationContext }
+  from "../src/activation/context";
+import { resetSharedLoopbackForTest } from "../src/util/loopbackBoot";
+import { SessionState } from "../src/sessionState";
+import { makeContext } from "./mocks/vscode";
+import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";
+
+const AD_A: PatchAd = {
+  adId: "ad-a", campaignId: "c-a", adText: "Linear -- plan, build, ship",
+  iconRef: "icon.a", iconUrl: "", clickUrl: "https://a.test",
+  bannerEnabled: false, sessionToken: "tok-a",
+};
+const AD_B: PatchAd = {
+  adId: "ad-b", campaignId: "c-b", adText: "Railway -- deploy in seconds",
+  iconRef: "icon.b", iconUrl: "", clickUrl: "https://b.test",
+  bannerEnabled: false, sessionToken: "tok-b",
+};
+function mkResp(ads: PatchAd[]): PortfolioResponse {
+  return { ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
+    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null };
+}
+
+beforeEach(() => { resetServingGate(); resetSharedLoopbackForTest(); });
+
+// ── Harness: real loopback + real webview injection wiring ────────────────
+async function mkHarness() {
+  const actx = createActivationContext();
+  const killedRef = { current: false };
+  const adRef = { current: AD_A as PatchAd | null };
+  const metricsSend = vi.fn();
+  let inventory: PortfolioResponse | null = null;
+  // Mutable so the namespace-guard tests can flip the live sign-in state
+  // mid-scenario (the guard reads auth.accessToken() at event time).
+  const authState = { token: "tok" as string | null };
+  const deps = {
+    ctx: makeContext(), actx,
+    adapter: {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      isPatched: () => true,
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    },
+    auth: { accessToken: () => authState.token, clientId: () => "cid" },
+    debugCtl: { setPortfolioAd: () => {} },
+    session: new SessionState(),
+    portfolio: { fetchPortfolio: async () => inventory,
+                 fetchDemoPortfolio: async () => inventory },
+    metrics: { send: metricsSend },
+    logTail: { current: () => ({}), activityAgeMs: () => null },
+    testHooks: { handleTestRoute: async () => ({ status: 404, body: {} }) },
+    statusBar: { set: () => {} },
+    ccVersion: "2.1.143",
+    killed: false, killedRef, adRef,
+    portfolioResp: mkResp([AD_A]),
+    viewThresholdMs: 3_000,
+    statusBarShowActive: async () => {},
+    scheduleEarningsRefresh: () => {},
+    desyncState: { lastApplyAt: 0, lastBlockStartAt: 0 },
+  };
+  const r = await setupWebviewInjection(deps as never);
+  expect(r.lbInfo).not.toBeNull();
+  const base = r.lbInfo!.base;
+  return {
+    r, actx, metricsSend, base, authState,
+    /** Swap the live inventory and force a rotation apply (the real
+     *  refreshPortfolio path — flips activeAd, re-mints corr, resets the
+     *  impression dedupe, exactly what the 120s rotation tick does). */
+    swapTo: async (ads: PatchAd[]) => {
+      inventory = mkResp(ads);
+      await r.refreshPortfolioNow!(true);
+    },
+  };
+}
+
+async function teardown(actx: ActivationContext): Promise<void> {
+  for (const t of actx.timers) clearInterval(t as NodeJS.Timeout);
+  actx.timers.length = 0;
+  if (actx.loopback) { await actx.loopback.stop(); actx.loopback = null; }
+}
+
+type SentArgs = { adId: string; campaignId: string; sessionToken?: string };
+const sent = (m: ReturnType<typeof vi.fn>): [string, SentArgs][] =>
+  m.mock.calls.map((c) => [c[0] as string, c[1] as SentArgs]);
+
+describe("rotation/poll-lag attribution (audit #17)", () => {
+
+  it("a view event claiming the OLD ad id after a swap bills the OLD"
+    + " campaign + session token; the new ad's events bill the new one",
+    async () => {
+    const h = await mkHarness();
+    try {
+      // Prime: one event while AD_A serves — registers it as recently served.
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
+      expect(sent(h.metricsSend)[0][1]).toMatchObject(
+        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
+      await h.swapTo([AD_B]);
+      h.metricsSend.mockClear();
+      // The webview hasn't polled /ad yet: its OLD view session keeps firing.
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=10000`);
+      await fetch(
+        `${h.base}/view_threshold_met?surface=overlay&ad=ad-a&visible_ms=16000`);
+      // …and after its poll, the NEW ad's session fires too.
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-b&visible_ms=2000`);
+      const calls = sent(h.metricsSend);
+      expect(calls).toHaveLength(3);
+      expect(calls[0][0]).toBe("view_tick");
+      expect(calls[0][1]).toMatchObject(
+        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
+      expect(calls[1][0]).toBe("view_threshold_met");
+      expect(calls[1][1]).toMatchObject(
+        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
+      expect(calls[2][1]).toMatchObject(
+        { adId: "ad-b", campaignId: "c-b", sessionToken: "tok-b" });
+    } finally { await teardown(h.actx); }
+  });
+
+  it("claims by ad TEXT resolve too (the deployed block keys its view"
+    + " sessions by AD text and sends that as ad=)", async () => {
+    const h = await mkHarness();
+    try {
+      await fetch(`${h.base}/view_tick?surface=overlay&visible_ms=5000`);
+      await h.swapTo([AD_B]);
+      h.metricsSend.mockClear();
+      await fetch(`${h.base}/view_tick?surface=overlay`
+        + `&ad=${encodeURIComponent(AD_A.adText)}&visible_ms=9000`);
+      expect(sent(h.metricsSend)[0][1]).toMatchObject(
+        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
+    } finally { await teardown(h.actx); }
+  });
+
+  it("a click claiming the OLD ad (above the 15s floor) bills the OLD"
+    + " campaign + token", async () => {
+    const h = await mkHarness();
+    try {
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
+      await h.swapTo([AD_B]);
+      h.metricsSend.mockClear();
+      await fetch(
+        `${h.base}/click?ct=ck&surface=overlay&visible_ms=20000&ad=ad-a`);
+      const calls = sent(h.metricsSend);
+      expect(calls).toHaveLength(1);
+      expect(calls[0][0]).toBe("click");
+      expect(calls[0][1]).toMatchObject(
+        { adId: "ad-a", campaignId: "c-a", sessionToken: "tok-a" });
+    } finally { await teardown(h.actx); }
+  });
+
+  it("unknown claimed ad — and an absent claim — fall back to activeAd"
+    + " (events and clicks)", async () => {
+    const h = await mkHarness();
+    try {
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
+      await h.swapTo([AD_B]);
+      h.metricsSend.mockClear();
+      await fetch(
+        `${h.base}/view_tick?surface=overlay&ad=never-served&visible_ms=1000`);
+      await fetch(`${h.base}/view_tick?surface=overlay&visible_ms=1000`);
+      await fetch(`${h.base}/click?ct=ck&surface=overlay&visible_ms=20000`);
+      const calls = sent(h.metricsSend);
+      expect(calls).toHaveLength(3);
+      for (const [, a] of calls) {
+        expect(a).toMatchObject(
+          { adId: "ad-b", campaignId: "c-b", sessionToken: "tok-b" });
+      }
+    } finally { await teardown(h.actx); }
+  });
+
+  it("impression dedupe keys on the RESOLVED ad: the old ad's late"
+    + " impression doesn't consume the new ad's slot, and repeats are deduped"
+    + " within their own ad", async () => {
+    const h = await mkHarness();
+    try {
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
+      await h.swapTo([AD_B]);
+      h.metricsSend.mockClear();
+      // Old ad's straggler impression (the poll-lag window)…
+      await fetch(`${h.base}/impression_viewable?surface=overlay&ad=ad-a`);
+      // …must NOT have consumed the NEW ad's dedupe slot…
+      await fetch(`${h.base}/impression_viewable?surface=overlay&ad=ad-b`);
+      // …while a REPEAT of the old ad's impression is deduped in its own ad.
+      await fetch(`${h.base}/impression_viewable?surface=overlay&ad=ad-a`);
+      const calls = sent(h.metricsSend);
+      expect(calls).toHaveLength(2);
+      expect(calls[0][1].adId).toBe("ad-a");
+      expect(calls[1][1].adId).toBe("ad-b");
+    } finally { await teardown(h.actx); }
+  });
+
+  it("the wave-2 canServeAds gate runs FIRST: a confirmed kill drops even a"
+    + " known old-ad claim (event and click)", async () => {
+    const h = await mkHarness();
+    try {
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
+      await h.swapTo([AD_B]);
+      h.metricsSend.mockClear();
+      setKillPosture("confirmed");
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=9000`);
+      await fetch(
+        `${h.base}/click?ct=ck&surface=overlay&visible_ms=20000&ad=ad-a`);
+      expect(h.metricsSend).not.toHaveBeenCalled();
+    } finally { await teardown(h.actx); }
+  });
+});
+
+// ── Token-namespace guard (2026-06-11) ─────────────────────────────────────
+// A session token is HMAC-bound to a uid namespace (real g-* vs
+// demo:<client_id>), but MetricsClient picks its route by LIVE auth state.
+// A demo-era token forwarded on the authed route (or a real token after a
+// sign-out, forwarded on the demo route) is a 100%-guaranteed server 403 —
+// in prod this was a chronic all-403 stream of entire view sessions. The
+// host now drops the mismatch at the relay instead of burning the send.
+describe("token-namespace guard", () => {
+  const AD_DEMO: PatchAd = { ...AD_B, sessionToken: "demo-tok", demo: true };
+
+  it("drops view events whose attribution is demo-stamped while signed in", async () => {
+    const h = await mkHarness();
+    try {
+      await h.swapTo([AD_DEMO]);                 // demo object, authed host
+      h.metricsSend.mockClear();
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-b&visible_ms=5000`);
+      await fetch(`${h.base}/click?ct=ck&surface=overlay&visible_ms=20000&ad=ad-b`);
+      expect(h.metricsSend).not.toHaveBeenCalled();
+    } finally { await teardown(h.actx); }
+  });
+
+  it("drops view events carrying a REAL token once signed out", async () => {
+    const h = await mkHarness();
+    try {
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=5000`);
+      h.metricsSend.mockClear();
+      h.authState.token = null;                  // sign-out; registry keeps tok-a
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-a&visible_ms=9000`);
+      expect(h.metricsSend).not.toHaveBeenCalled();
+    } finally { await teardown(h.actx); }
+  });
+
+  it("demo attribution + signed-out still bills (the legitimate demo path)", async () => {
+    const h = await mkHarness();
+    try {
+      await h.swapTo([AD_DEMO]);
+      h.authState.token = null;
+      h.metricsSend.mockClear();
+      await fetch(`${h.base}/view_tick?surface=overlay&ad=ad-b&visible_ms=5000`);
+      const calls = sent(h.metricsSend);
+      expect(calls).toHaveLength(1);
+      expect(calls[0][1]).toMatchObject(
+        { adId: "ad-b", campaignId: "c-b", sessionToken: "demo-tok" });
+    } finally { await teardown(h.actx); }
+  });
+});
+
+// ── The loopback lift itself (route → handler contract) ───────────────────
+describe("loopback lifts the ad= claim (audit #17)", () => {
+  it("metric routes surface claimedAdId in the payload; absent param stays"
+    + " undefined; the click route passes it as the 5th onClick arg",
+    async () => {
+    const events: { kind: string; claimedAdId?: string }[] = [];
+    const clicks: (string | undefined)[] = [];
+    const lb = new Loopback({
+      onEvent: (k, p) => { events.push({ kind: k, claimedAdId: p.claimedAdId }); },
+      onClick: (_ct, _s, _v, _u, claimedAdId) => { clicks.push(claimedAdId); },
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    try {
+      await fetch(`${base}/view_tick?surface=overlay&ad=ad-77&visible_ms=5000`);
+      await fetch(`${base}/view_tick?surface=overlay&visible_ms=5000`);
+      await fetch(`${base}/click?ct=ck&surface=overlay&visible_ms=1&ad=ad-77`);
+      await fetch(`${base}/click?ct=ck&surface=overlay&visible_ms=1`);
+      expect(events).toEqual([
+        { kind: "view_tick", claimedAdId: "ad-77" },
+        { kind: "view_tick", claimedAdId: undefined },
+      ]);
+      expect(clicks).toEqual(["ad-77", undefined]);
+    } finally { await lb.stop(); }
+  });
+});
diff --git a/test/audit-deferred-xfail.test.ts b/test/audit-deferred-xfail.test.ts
new file mode 100644
index 0000000..937db71
--- /dev/null
+++ b/test/audit-deferred-xfail.test.ts
@@ -0,0 +1,84 @@
+/** Strict-xfail tripwire for DEFERRED audit-2026-06-09 finding #16
+ * (mid-show rotation 2x credit). Backend siblings live in
+ * backend/tests/test_audit_deferred_xfail.py (#26 consent default,
+ * #41 multi-surface viewable counts).
+ *
+ * `it.fails` is vitest's strict-xfail: the body asserts the DESIRED
+ * (post-fix) behavior, so today the test "passes" by failing. When the fix
+ * lands the body starts passing, vitest reports the it.fails as a failure,
+ * and whoever ships the fix flips it to a plain `it` — promoting this into
+ * a real regression test. No silent drift in either direction.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { setupStatusBarAd } from "../src/activation/statusBarAd";
+import type { Activity } from "../src/activity/logTail";
+
+function makeDeps() {
+  const statusBar = { set: vi.fn() };
+  const metrics = { send: vi.fn() };
+  const logTail = {
+    current: vi.fn().mockReturnValue(null),
+    activityAgeMs: vi.fn().mockReturnValue(null),
+  };
+  return {
+    logTail: logTail as any,
+    metrics: metrics as any,
+    statusBar,
+    adRef: {
+      current: {
+        adId: "ad1", campaignId: "c1", adText: "Try Acme Widgets",
+        iconRef: "", iconUrl: "", clickUrl: "https://acme.com",
+        bannerEnabled: false, sessionToken: "tok1",
+      },
+    },
+    killedRef: { current: false },
+    ccVersion: "2.1.143",
+    showActive: vi.fn().mockResolvedValue(undefined),
+    timers: [] as NodeJS.Timeout[],
+    barState: { adShowing: false },
+  } as any;
+}
+
+function thinking(): Activity {
+  return { tool: "Edit", elapsedMs: 1000, ts: Date.now(), done: false };
+}
+
+describe("audit #16 (deferred): mid-show ad rotation must not split surfaces onto two adIds", () => {
+  beforeEach(() => { vi.useFakeTimers(); });
+  afterEach(() => { vi.useRealTimers(); });
+
+  it.fails("stops billing the OLD ad once rotation swaps adRef mid-show", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+
+    // t=1s: show starts and snapshots ad1.
+    vi.advanceTimersByTime(1000);
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_rendered",
+      expect.objectContaining({ adId: "ad1", surface: "statusbar" }));
+    const callsBeforeSwap = d.metrics.send.mock.calls.length;
+
+    // Mid-show rotation: every other surface (overlay, CLI, patchParams)
+    // swaps to ad2; the per-(user,ad) cooldown bucket is keyed by ad_id, so
+    // a statusbar still billing ad1 while the overlay bills ad2 credits
+    // twice concurrently.
+    d.adRef.current = {
+      adId: "ad2", campaignId: "c2", adText: "Try Globex",
+      iconRef: "", iconUrl: "", clickUrl: "https://globex.com",
+      bannerEnabled: false, sessionToken: "tok2",
+    };
+
+    // Two view_tick intervals later, still thinking.
+    vi.advanceTimersByTime(10_000);
+
+    // DESIRED: the swap either ends the ad1 show (impression_viewable fires,
+    // ticks stop) or re-snapshots to ad2 — either way, no billable view_tick
+    // may carry the stale adId after the swap. TODAY: the eligibility check
+    // never compares adIds, so the frozen shownAd keeps emitting ad1 ticks.
+    const staleTicks = d.metrics.send.mock.calls
+      .slice(callsBeforeSwap)
+      .filter(([event, payload]: [string, any]) =>
+        event === "view_tick" && payload?.adId === "ad1");
+    expect(staleTicks).toHaveLength(0);
+  });
+});
diff --git a/test/auth.test.ts b/test/auth.test.ts
new file mode 100644
index 0000000..734cc01
--- /dev/null
+++ b/test/auth.test.ts
@@ -0,0 +1,620 @@
+import { describe, it, expect, vi } from "vitest";
+import { mkdtempSync, readFileSync, writeFileSync, statSync, utimesSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// Mock the log module so these tests never touch the real debug log or
+// read dev-machine sentinels / env vars that could flip assertions.
+vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
+  dlogRaw: () => {}, codexEnabled: () => false, codexCliEnabled: () => false,
+  LOG_PATH: "/tmp/test-log" }));
+
+import { AuthClient } from "../src/auth/client";
+import { createVault } from "../src/auth/vault";
+import { makeContext, _opened, _shown } from "./mocks/vscode";
+
+// Hermetic fallback file per test (never touch the real ~/.vibe-ads).
+const mkAuthFile = () => join(mkdtempSync(join(tmpdir(), "vibe-ads-auth-")), "auth.json");
+// Hermetic vault: an unknown platform => "plain" scheme, so seal/open never
+// shell out (fixture-only rule). Per-OS behavior is covered by vault.test.ts.
+const noExec = (async () => { throw new Error("no exec in tests"); }) as never;
+const pv = () => createVault("test", noExec);
+
+describe("AuthClient", () => {
+  it("uses explicit dev-bypass as an in-memory token only for loopback bases", async () => {
+    const oldKickbacks = process.env.KICKBACKS_DEV_BYPASS;
+    const oldVibeAds = process.env.VIBE_ADS_DEV_BYPASS;
+    process.env.VIBE_ADS_DEV_BYPASS = "1";
+    delete process.env.KICKBACKS_DEV_BYPASS;
+    const s = new Map<string, string>(), g = new Map<string, unknown>();
+    const ctx = {
+      secrets: { get: async (k: string) => s.get(k),
+        store: async (k: string, v: string) => { s.set(k, v); },
+        delete: async (k: string) => { s.delete(k); } },
+      globalState: { get: (k: string) => g.get(k),
+        update: async (k: string, v: unknown) => { g.set(k, v); } },
+      subscriptions: [],
+    };
+    const f = vi.fn(async () => ({ ok: false, status: 500 }) as Response);
+    try {
+      const a = new AuthClient("http://127.0.0.1:6080", ctx as never,
+        f as never, 0, mkAuthFile(), pv());
+      await a.loadCached();
+      expect(a.accessToken()).toBe("dev-bypass");
+      expect(a.signedIn()).toBe(true);
+      expect(await a.refresh()).toBe(true);
+      expect(f).not.toHaveBeenCalled();
+      expect(s.size).toBe(0);
+    } finally {
+      if (oldKickbacks === undefined) delete process.env.KICKBACKS_DEV_BYPASS;
+      else process.env.KICKBACKS_DEV_BYPASS = oldKickbacks;
+      if (oldVibeAds === undefined) delete process.env.VIBE_ADS_DEV_BYPASS;
+      else process.env.VIBE_ADS_DEV_BYPASS = oldVibeAds;
+    }
+  });
+
+  it("refuses dev-bypass when the backend base is not loopback", async () => {
+    const old = process.env.VIBE_ADS_DEV_BYPASS;
+    process.env.VIBE_ADS_DEV_BYPASS = "1";
+    const s = new Map<string, string>(), g = new Map<string, unknown>();
+    const ctx = {
+      secrets: { get: async (k: string) => s.get(k),
+        store: async (k: string, v: string) => { s.set(k, v); },
+        delete: async (k: string) => { s.delete(k); } },
+      globalState: { get: (k: string) => g.get(k),
+        update: async (k: string, v: unknown) => { g.set(k, v); } },
+      subscriptions: [],
+    };
+    try {
+      const a = new AuthClient("https://api.kickbacks.ai", ctx as never,
+        (async () => ({ ok: false, status: 500 }) as Response) as never,
+        0, mkAuthFile(), pv());
+      await a.loadCached();
+      expect(a.accessToken()).toBeNull();
+      expect(a.signedIn()).toBe(false);
+    } finally {
+      if (old === undefined) delete process.env.VIBE_ADS_DEV_BYPASS;
+      else process.env.VIBE_ADS_DEV_BYPASS = old;
+    }
+  });
+
+  it("signIn opens the broker URL then polls until tokens, stores in SecretStorage", async () => {
+    const ctx = makeContext();
+    let polls = 0;
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/extension/start"))
+        return { status: 307,
+          headers: { get: (k: string) =>
+            k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null },
+        } as unknown as Response;
+      polls++;
+      if (polls < 2) return { ok: true, status: 200, json: async () => ({ status: "pending" }) } as Response;
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT", refresh_token: "RT", expires_in: 3600 }) } as Response;
+    });
+    const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
+    await a.signIn();
+    expect(_opened.some((u) => u.includes("https://g/auth"))).toBe(true);
+    expect(await ctx.secrets.get("kickbacks.access")).toBe("AT");
+    expect(await ctx.secrets.get("kickbacks.refresh")).toBe("RT");
+    expect(a.accessToken()).toBe("AT");
+    expect(a.signedIn()).toBe(true);
+  });
+
+  it("refresh swaps access AND persists the rotated refresh token", async () => {
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.refresh", "RT");
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/auth/refresh"))
+        return { ok: true, status: 200, json: async () =>
+          ({ access_token: "AT2", refresh_token: "RT2", expires_in: 3600 }) } as Response;
+      return { ok: false, status: 500 } as Response;
+    });
+    const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
+    expect(await a.refresh()).toBe(true);
+    expect(a.accessToken()).toBe("AT2");
+    expect(await ctx.secrets.get("kickbacks.refresh")).toBe("RT2"); // rotation persisted
+  });
+
+  it("single-flights concurrent refresh() calls so the rotating token is consumed once", async () => {
+    // Two callers (status-bar earnings 401 + portfolio 401) racing on the
+    // SAME refresh token used to double-POST /refresh; S1 rotates on first
+    // use, so the second call sent a consumed token, 401'd, and nulled `at`
+    // — clobbering the first call's success. Single-flight must collapse
+    // them to ONE request.
+    const s = new Map<string, string>(), g = new Map<string, unknown>();
+    const ctx = {
+      secrets: { get: async (k: string) => s.get(k),
+        store: async (k: string, v: string) => { s.set(k, v); },
+        delete: async (k: string) => { s.delete(k); } },
+      globalState: { get: (k: string) => g.get(k),
+        update: async (k: string, v: unknown) => { g.set(k, v); } },
+      subscriptions: [],
+    };
+    await ctx.secrets.store("kickbacks.refresh", "RT");
+    let calls = 0;
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/auth/refresh")) {
+        calls++;
+        return { ok: true, status: 200, json: async () =>
+          ({ access_token: "AT2", refresh_token: "RT2" }) } as Response;
+      }
+      return { ok: false, status: 500 } as Response;
+    });
+    const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
+    const [r1, r2] = await Promise.all([a.refresh(), a.refresh()]);
+    expect(r1).toBe(true);
+    expect(r2).toBe(true);
+    expect(calls).toBe(1);                 // one POST, not two
+    expect(a.accessToken()).toBe("AT2");
+  });
+
+  it("refresh() recovers the token from the sealed file when SecretStorage is empty", async () => {
+    // Reinstall / keyring-less: secrets holds nothing, but a prior signIn
+    // sealed the token to the durable file. refresh() (called WITHOUT an
+    // explicit token, e.g. via a 401 retry) must find it there.
+    const isoCtx = () => {
+      const s = new Map<string, string>(), g = new Map<string, unknown>();
+      return {
+        secrets: { get: async (k: string) => s.get(k),
+          store: async (k: string, v: string) => { s.set(k, v); },
+          delete: async (k: string) => { s.delete(k); } },
+        globalState: { get: (k: string) => g.get(k),
+          update: async (k: string, v: unknown) => { g.set(k, v); } },
+        subscriptions: [],
+      };
+    };
+    const file = mkAuthFile();
+    const signInF = vi.fn(async (url: string) =>
+      url.includes("/extension/start")
+        ? { status: 307, headers: { get: (k: string) =>
+            k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
+        : { ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response);
+    const a1 = new AuthClient("http://b", isoCtx() as never, signInF as never, 0, file, pv());
+    await a1.signIn();                      // seals plain:1:RT to `file`
+
+    const refreshF = vi.fn(async (url: string) =>
+      url.includes("/auth/refresh")
+        ? { ok: true, status: 200, json: async () =>
+            ({ access_token: "AT3", refresh_token: "RT3" }) } as Response
+        : { ok: false, status: 500 } as Response);
+    const a2 = new AuthClient("http://b", isoCtx() as never, refreshF as never, 0, file, pv());
+    expect(await a2.refresh()).toBe(true);  // empty secrets -> file fallback
+    expect(a2.accessToken()).toBe("AT3");
+  });
+
+  it("fires the onSignedIn login trigger after a successful interactive sign-in", async () => {
+    const ctx = makeContext();
+    let fired = 0;
+    const f = vi.fn(async (url: string) =>
+      url.includes("/extension/start")
+        ? { status: 307, headers: { get: (k: string) =>
+            k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
+        : { ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response);
+    const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
+    a.setOnSignedIn(() => { fired++; });
+    await a.signIn();
+    expect(fired).toBe(1);                 // login trigger → immediate reassert
+  });
+
+  it("always-writes a sealed envelope to the file (the keyring-less Linux fix)", async () => {
+    const file = mkAuthFile();
+    const ctx = makeContext();
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/extension/start"))
+        return { status: 307, headers: { get: (k: string) =>
+          k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT", refresh_token: "RT" }) } as Response;
+    });
+    const a = new AuthClient("http://b", ctx as never, f as never, 0, file, pv());
+    await a.signIn();
+    const fb = JSON.parse(readFileSync(file, "utf8"));
+    expect(fb.refresh).toBe("plain:1:RT");        // sealed envelope, not bare token
+    expect(fb.refresh).not.toBe("RT");
+  });
+
+  it("survives a reinstall: empty SecretStorage recovers via the sealed file", async () => {
+    const isoCtx = () => {
+      const s = new Map<string, string>(), g = new Map<string, unknown>();
+      return {
+        secrets: { get: async (k: string) => s.get(k),
+          store: async (k: string, v: string) => { s.set(k, v); },
+          delete: async (k: string) => { s.delete(k); } },
+        globalState: { get: (k: string) => g.get(k),
+          update: async (k: string, v: unknown) => { g.set(k, v); } },
+        subscriptions: [],
+      };
+    };
+    const file = mkAuthFile();
+    const ctx1 = isoCtx();
+    const f1 = vi.fn(async (url: string) => {
+      if (url.includes("/extension/start"))
+        return { status: 307, headers: { get: (k: string) =>
+          k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT", refresh_token: "RT" }) } as Response;
+    });
+    const a1 = new AuthClient("http://b", ctx1 as never, f1 as never, 0, file, pv());
+    await a1.signIn();
+
+    const ctx2 = isoCtx(); // brand-new namespace (= reinstall/rename)
+    const f2 = vi.fn(async (url: string) => {
+      if (url.includes("/auth/refresh"))
+        return { ok: true, status: 200, json: async () =>
+          ({ access_token: "AT-NEW", refresh_token: "RT2" }) } as Response;
+      return { ok: false, status: 500 } as Response;
+    });
+    const a2 = new AuthClient("http://b", ctx2 as never, f2 as never, 0, file, pv());
+    expect(a2.accessToken()).toBeNull();
+    await a2.loadCached();
+    expect(a2.accessToken()).toBe("AT-NEW");                 // no re-sign-in
+  });
+
+  it("upgrades a PRE-VAULT plaintext refresh token in place on first read", async () => {
+    // Isolated ctx — makeContext() shares a module-global secrets Map, which
+    // would leak a prior test's tokens and skip the file-recovery path.
+    const isoCtx = () => {
+      const s = new Map<string, string>(), g = new Map<string, unknown>();
+      return {
+        secrets: { get: async (k: string) => s.get(k),
+          store: async (k: string, v: string) => { s.set(k, v); },
+          delete: async (k: string) => { s.delete(k); } },
+        globalState: { get: (k: string) => g.get(k),
+          update: async (k: string, v: unknown) => { g.set(k, v); } },
+        subscriptions: [],
+      };
+    };
+    const file = mkAuthFile();
+    // Simulate an older build's file: bare token, no envelope prefix.
+    const seed = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
+    const cid = seed.clientId();
+    require("node:fs").writeFileSync(file,
+      JSON.stringify({ refresh: "LEGACY-RT", clientId: cid }));
+    const f = vi.fn(async (url: string) =>
+      url.includes("/auth/refresh")
+        ? { ok: true, status: 200, json: async () =>
+            ({ access_token: "AT", refresh_token: "RT2" }) } as Response
+        : { ok: false, status: 500 } as Response);
+    const a = new AuthClient("http://b", isoCtx() as never, f as never, 0, file, pv());
+    await a.loadCached();
+    expect(a.accessToken()).toBe("AT");                       // legacy token still worked
+    const fb = JSON.parse(readFileSync(file, "utf8"));
+    expect(fb.refresh.startsWith("plain:1:")).toBe(true);     // re-sealed (upgraded)
+  });
+
+  it("signOut clears all stores incl. the OS vault entry; no silent re-mint", async () => {
+    const isoCtx = () => {
+      const s = new Map<string, string>(), g = new Map<string, unknown>();
+      return {
+        secrets: { get: async (k: string) => s.get(k),
+          store: async (k: string, v: string) => { s.set(k, v); },
+          delete: async (k: string) => { s.delete(k); } },
+        globalState: { get: (k: string) => g.get(k),
+          update: async (k: string, v: unknown) => { g.set(k, v); } },
+        subscriptions: [],
+      };
+    };
+    const file = mkAuthFile();
+    const vault = pv();
+    const clearSpy = vi.spyOn(vault, "clear");
+    const ctx1 = isoCtx();
+    const f1 = vi.fn(async (url: string) =>
+      url.includes("/extension/start")
+        ? { status: 307, headers: { get: (k: string) =>
+            k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
+        : { ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response);
+    const a1 = new AuthClient("http://b", ctx1 as never, f1 as never, 0, file, vault);
+    await a1.signIn();
+    const id1 = a1.clientId();
+    expect(a1.signedIn()).toBe(true);
+
+    await a1.signOut();
+    expect(a1.signedIn()).toBe(false);
+    expect(a1.accessToken()).toBeNull();
+    expect(clearSpy).toHaveBeenCalled();                      // OS-store entry purged
+    expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBeUndefined();
+    expect(JSON.parse(readFileSync(file, "utf8")).clientId).toBe(id1); // anon id kept
+
+    const ctx2 = isoCtx(); // reinstall after sign-out MUST stay signed out
+    const a2 = new AuthClient("http://b", ctx2 as never,
+      (async () => ({ ok: false, status: 500 })) as never, 0, file, pv());
+    await a2.loadCached();
+    expect(a2.accessToken()).toBeNull();
+    expect(a2.clientId()).toBe(id1);
+  });
+
+  // BL-188: sign-out must also revoke the rotating refresh token SERVER-side
+  // — pre-fix it stayed mintable for its full TTL after a client-local clear.
+  it("signOut posts the refresh token to /v1/auth/signout for revocation", async () => {
+    const file = mkAuthFile();
+    const calls: Array<{ url: string; init?: RequestInit }> = [];
+    const f = vi.fn(async (url: string, init?: RequestInit) => {
+      calls.push({ url, init });
+      return url.includes("/extension/start")
+        ? { status: 307, headers: { get: (k: string) =>
+            k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response
+        : { ok: true, status: 200,
+            json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response;
+    });
+    const a = new AuthClient("http://b", makeContext() as never, f as never, 0, file, pv());
+    await a.signIn();
+    await a.signOut();
+    const revoke = calls.find((c) => c.url.includes("/v1/auth/signout"));
+    expect(revoke).toBeTruthy();
+    expect(revoke!.init?.method).toBe("POST");
+    expect(String(revoke!.init?.body)).toContain("RT");   // the stored token
+    expect(a.signedIn()).toBe(false);                     // local clear intact
+  });
+
+  it("signOut still completes (and clears locally) when revocation fails", async () => {
+    const file = mkAuthFile();
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/extension/start")) {
+        return { status: 307, headers: { get: (k: string) =>
+          k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
+      }
+      if (url.includes("/v1/auth/signout")) {
+        throw new Error("offline");                       // revocation refused
+      }
+      return { ok: true, status: 200,
+        json: async () => ({ access_token: "AT", refresh_token: "RT" }) } as Response;
+    });
+    const a = new AuthClient("http://b", makeContext() as never, f as never, 0, file, pv());
+    await a.signIn();
+    await a.signOut();                                    // must not throw
+    expect(a.signedIn()).toBe(false);
+    expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBeUndefined();
+  });
+
+  it("clientId is a stable persisted anon id (survives a fresh ctx via file)", async () => {
+    const file = mkAuthFile();
+    const a1 = new AuthClient("http://b", makeContext() as never, (async () => ({})) as never, 0, file, pv());
+    const id1 = a1.clientId();
+    expect(id1.length).toBeGreaterThanOrEqual(16);
+    const a2 = new AuthClient("http://b", makeContext() as never, (async () => ({})) as never, 0, file, pv());
+    expect(a2.clientId()).toBe(id1);
+  });
+
+  it("storageInfo reports the active scheme + keyring health", async () => {
+    const ctx = makeContext();
+    const a = new AuthClient("http://b", ctx as never,
+      (async () => ({ ok: true, status: 200, json: async () => ({ access_token: "AT", refresh_token: "RT" }) })) as never,
+      0, mkAuthFile(), pv());
+    expect(a.storageInfo().scheme).toBe("plain");
+    expect(a.storageInfo().keyringDurable).toBeUndefined();    // not probed yet
+  });
+});
+
+// audit BL-015 (wave-2A H1 coverage): a refresh() failure must clear the
+// in-memory access token so signedIn() flips to false at the right moment.
+// Pre-fix a dead token lingered until explicit signOut() and every backend
+// call 401'd while signedIn() still reported true.
+describe("AuthClient refresh failure clears session (H1)", () => {
+  it("H1: refresh() with no stored token clears in-memory `at`", async () => {
+    vi.resetModules();
+    vi.doMock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
+      dlogRaw: () => {}, codexEnabled: () => false,
+      LOG_PATH: "/tmp/t" }));
+    const { AuthClient: AC } = await import("../src/auth/client");
+    const ctx = makeContext();
+    // Seed with a "dead" access token (no refresh token in storage).
+    await ctx.secrets.store("kickbacks.access", "DEAD");
+    const a = new AC("http://localhost:6080", ctx as never,
+      (async () => ({ ok: false, status: 401 })) as never,
+      0, mkAuthFile(), pv());
+    await a.loadCached();                       // pulls DEAD into this.at
+    expect(a.accessToken()).toBe("DEAD");       // pre-fix behavior persists
+    const refreshed = await a.refresh();         // no refresh token -> false
+    expect(refreshed).toBe(false);
+    // Post-fix: at is cleared, so signedIn() flips to false.
+    expect(a.accessToken()).toBeNull();
+    expect(a.signedIn()).toBe(false);
+    vi.doUnmock("../src/log");
+    vi.resetModules();
+  });
+});
+
+// Shared isolated ctx for the audit-fix suites below (makeContext shares a
+// module-global secrets Map; these need a clean per-test namespace).
+const isoCtx = () => {
+  const s = new Map<string, string>(), g = new Map<string, unknown>();
+  return {
+    secrets: { get: async (k: string) => s.get(k),
+      store: async (k: string, v: string) => { s.set(k, v); },
+      delete: async (k: string) => { s.delete(k); } },
+    globalState: { get: (k: string) => g.get(k),
+      update: async (k: string, v: unknown) => { g.set(k, v); } },
+    subscriptions: [],
+  };
+};
+// Locked/absent Secret Service (keyring-less Linux): get works, store THROWS
+// — the env the file fallback exists for. `seed` pre-populates the cache.
+const lockedCtx = (seed?: [string, string][]) => {
+  const s = new Map<string, string>(seed), g = new Map<string, unknown>();
+  return {
+    secrets: { get: async (k: string) => s.get(k),
+      store: async () => {
+        throw new Error("Cannot create an item in a locked collection"); },
+      delete: async (k: string) => { s.delete(k); } },
+    globalState: { get: (k: string) => g.get(k),
+      update: async (k: string, v: unknown) => { g.set(k, v); } },
+    subscriptions: [],
+  };
+};
+
+// audit 2026-06-09 #11: ctx.secrets.store() is best-effort — a throwing
+// keyring must not abort activation (loadCached) nor turn a server-side-
+// successful refresh into a sign-out after the rotating token was consumed.
+describe("AuthClient keyring store failures are best-effort (#11)", () => {
+  it("loadCached survives a throwing keyring store and still re-mints from the file", async () => {
+    const file = mkAuthFile();
+    writeFileSync(file, JSON.stringify({ refresh: "plain:1:RT", clientId: "cid11" }));
+    const f = vi.fn(async (url: string) =>
+      url.includes("/auth/refresh")
+        ? { ok: true, status: 200, json: async () =>
+            ({ access_token: "AT", refresh_token: "RT2" }) } as Response
+        : { ok: false, status: 500 } as Response);
+    const a = new AuthClient("http://b", lockedCtx() as never, f as never, 0, file, pv());
+    await a.loadCached();           // pre-fix: REJECTED at the re-warm store()
+    expect(a.accessToken()).toBe("AT");
+    expect(a.signedIn()).toBe(true);
+    // The rotated token still landed in the durable file (sealToFile).
+    expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBe("plain:1:RT2");
+  });
+
+  it("a successful rotation with a dead keyring still counts as success", async () => {
+    const file = mkAuthFile();
+    const f = vi.fn(async (url: string) =>
+      url.includes("/auth/refresh")
+        ? { ok: true, status: 200, json: async () =>
+            ({ access_token: "AT2", refresh_token: "RT2" }) } as Response
+        : { ok: false, status: 500 } as Response);
+    const a = new AuthClient("http://b",
+      lockedCtx([["kickbacks.refresh", "RT"]]) as never, f as never, 0, file, pv());
+    expect(await a.refresh()).toBe(true);     // pre-fix: false (store threw)
+    expect(a.accessToken()).toBe("AT2");
+    expect(JSON.parse(readFileSync(file, "utf8")).refresh).toBe("plain:1:RT2");
+    expect(a.storageInfo().keyringDurable).toBe(false); // probe sees dead keyring
+  });
+});
+
+// audit 2026-06-09 #37: interactive sign-in is single-flighted, and a still-
+// running poll loop exits silently if sign-in arrived via another path.
+describe("AuthClient interactive sign-in single-flight (#37)", () => {
+  it("concurrent signIn() calls coalesce onto ONE state + browser tab + poll loop", async () => {
+    const openedBefore = _opened.length;
+    let starts = 0, polls = 0;
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/extension/start")) {
+        starts++;
+        return { status: 307, headers: { get: (k: string) =>
+          k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
+      }
+      polls++;
+      if (polls < 2) return { ok: true, status: 200, json: async () => ({ status: "pending" }) } as Response;
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT", refresh_token: "RT" }) } as Response;
+    });
+    const a = new AuthClient("http://b", isoCtx() as never, f as never, 0, mkAuthFile(), pv());
+    const [r1, r2] = await Promise.all([a.signIn(), a.signIn()]);
+    expect(r1).toBe(true);
+    expect(r2).toBe(true);
+    expect(starts).toBe(1);                        // pre-fix: 2 parallel flows
+    expect(_opened.length - openedBefore).toBe(1); // one browser tab, not two
+  });
+
+  it("a still-polling loop exits silently (no error toast) once signed in via another path", async () => {
+    const shownBefore = _shown.length;
+    let polls = 0;
+    let client: AuthClient | undefined;
+    const f = vi.fn(async (url: string) => {
+      if (url.includes("/extension/start"))
+        return { status: 307, headers: { get: (k: string) =>
+          k.toLowerCase() === "location" ? "https://g/auth?state=S1" : null } } as unknown as Response;
+      if (url.includes("/auth/refresh"))
+        return { ok: true, status: 200, json: async () =>
+          ({ access_token: "AT-R", refresh_token: "RT2" }) } as Response;
+      polls++;                       // poll endpoint: this state NEVER completes
+      if (polls === 2) await client?.refresh("RT"); // background path signs us in
+      return { ok: true, status: 200, json: async () => ({ status: "pending" }) } as Response;
+    });
+    client = new AuthClient("http://b", isoCtx() as never, f as never, 0, mkAuthFile(), pv());
+    expect(await client.signIn()).toBe(true); // pre-fix: false after 120 polls
+    expect(polls).toBeLessThan(120);          // pre-fix: full 3-minute loop
+    expect(client.accessToken()).toBe("AT-R");
+    // No "sign-in timed out" toast minutes after the user already signed in.
+    expect(_shown.slice(shownBefore).filter((m) => m.kind === "error")).toEqual([]);
+  });
+});
+
+// audit 2026-06-09 #33: clientId() runs on EVERY metrics send; it must not
+// read-merge-write auth.json unless the id is actually missing there — the
+// unconditional RMW could resurrect a refresh envelope another window had
+// just rotated (single-use token => clobber = sign-out).
+describe("AuthClient clientId() write discipline (#33)", () => {
+  it("does NOT rewrite auth.json when the id is already on disk", async () => {
+    const file = mkAuthFile();
+    const a1 = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
+    const id = a1.clientId();                 // first call creates the file
+    utimesSync(file, 1, 1);                   // sentinel mtime
+    const stamp = statSync(file).mtimeMs;
+    expect(a1.clientId()).toBe(id);           // warm path (globalState hit)
+    const a2 = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
+    expect(a2.clientId()).toBe(id);           // cold path (id read FROM file)
+    // pre-fix: both calls unconditionally rewrote the file (mtime moves).
+    expect(statSync(file).mtimeMs).toBe(stamp);
+  });
+
+  it("a needed write merges with the on-disk token envelope, never clobbers it", async () => {
+    const file = mkAuthFile();
+    // Another window just sealed a rotated token; this window has no id yet.
+    writeFileSync(file, JSON.stringify({ refresh: "plain:1:ROTATED" }));
+    const a = new AuthClient("http://b", isoCtx() as never, (async () => ({})) as never, 0, file, pv());
+    const id = a.clientId();                  // must write (id missing on disk)
+    const fb = JSON.parse(readFileSync(file, "utf8"));
+    expect(fb.clientId).toBe(id);
+    expect(fb.refresh).toBe("plain:1:ROTATED"); // envelope preserved
+  });
+});
+
+// audit 2026-06-09 #10: only an EXPLICIT server rejection may discard tokens.
+// Pre-fix, _refresh's catch (and every !ok status) nulled `at` — a pure
+// network blip during the activation-time forced refresh permanently signed
+// the user out in-memory (demo demotion, user-credit loss) with no retry path.
+describe("AuthClient refresh transient-vs-fatal (#10)", () => {
+  // Signed-in client with AT0 in memory and RT in secrets; `f` is swappable.
+  const mkSignedIn = async (f: (url: string) => Promise<unknown>) => {
+    const ctx = isoCtx();
+    await ctx.secrets.store("kickbacks.access", "AT0");
+    await ctx.secrets.store("kickbacks.refresh", "RT");
+    const a = new AuthClient("http://b", ctx as never, f as never, 0, mkAuthFile(), pv());
+    await a.loadCached();
+    expect(a.accessToken()).toBe("AT0");
+    return a;
+  };
+
+  it("a NETWORK throw keeps the tokens; a later refresh() succeeds", async () => {
+    let online = false;
+    const a = await mkSignedIn(async (url: string) => {
+      if (!url.includes("/auth/refresh")) return { ok: false, status: 500 };
+      if (!online) throw new Error("ENOTFOUND");   // Wi-Fi not up yet
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT2", refresh_token: "RT2" }) };
+    });
+    expect(await a.refresh()).toBe(false);   // transient failure reported...
+    expect(a.accessToken()).toBe("AT0");     // ...but session NOT discarded
+    expect(a.signedIn()).toBe(true);         // pre-fix: false forever
+    online = true;                           // network returns minutes later
+    expect(await a.refresh()).toBe(true);    // retry path works
+    expect(a.accessToken()).toBe("AT2");
+  });
+
+  it("a 5xx is transient: tokens kept", async () => {
+    const a = await mkSignedIn(async () => ({ ok: false, status: 503 }));
+    expect(await a.refresh()).toBe(false);
+    expect(a.accessToken()).toBe("AT0");     // pre-fix: nulled on any !ok
+    expect(a.signedIn()).toBe(true);
+  });
+
+  it("an explicit 401 still clears the session (H1 contract preserved)", async () => {
+    const a = await mkSignedIn(async () => ({ ok: false, status: 401 }));
+    expect(await a.refresh()).toBe(false);
+    expect(a.accessToken()).toBeNull();      // explicit rejection => signed out
+    expect(a.signedIn()).toBe(false);
+  });
+
+  it("a 400 with an invalid_grant-style body is an explicit rejection", async () => {
+    const a = await mkSignedIn(async () => ({ ok: false, status: 400,
+      clone: () => ({ text: async () => '{"detail":"invalid_grant"}' }) }));
+    expect(await a.refresh()).toBe(false);
+    expect(a.accessToken()).toBeNull();
+  });
+
+  it("a 4xx WITHOUT a rejection body (gateway noise) is transient", async () => {
+    const a = await mkSignedIn(async () => ({ ok: false, status: 429,
+      clone: () => ({ text: async () => "rate limited" }) }));
+    expect(await a.refresh()).toBe(false);
+    expect(a.accessToken()).toBe("AT0");
+  });
+});
diff --git a/test/backup-safety.test.ts b/test/backup-safety.test.ts
new file mode 100644
index 0000000..a219a88
--- /dev/null
+++ b/test/backup-safety.test.ts
@@ -0,0 +1,238 @@
+/** Backup/restore safety regressions (audit 2026-06-09 findings #12, #13, #18).
+ *
+ *  Prime-directive territory: these pin the protocol that protects the two
+ *  files Kickbacks does not own — Claude Code's webview/index.js and the
+ *  user's ~/.claude/settings.json — against the cross-window interleave
+ *  (tainted-backup recapture) and the stale-snapshot whole-file rollback. */
+import { describe, it, expect, beforeEach } from "vitest";
+import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync,
+         mkdirSync, statSync, utimesSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { ClaudeCodeAdapter } from "../src/adapters/claude-code/adapter";
+import { ClaudeCliStatuslineAdapter } from "../src/adapters/claude-cli/adapter";
+import { upsertStatusLine, upsertSpinnerVerbs, removeTopLevel, parseable }
+  from "../src/adapters/claude-cli/settingsEdit";
+
+const FIX = readFileSync(join(__dirname, "fixtures/synthetic-index.js"), "utf8");
+
+function tmpTarget(): string {
+  const d = mkdtempSync(join(tmpdir(), "vibe-ads-bsafe-"));
+  const p = join(d, "index.js");
+  writeFileSync(p, FIX, "utf8");
+  return p;
+}
+const params = {
+  tier: 3 as const, adText: "Ramp corporate cards & expense mgmt",
+  iconRef: "icon.r", iconUrl: "", clickToken: "ck",
+  clickUrl: "https://ramp.example/lp", corr: "ad1.tst",
+  loopbackPort: 5555, loopbackToken: "lt",
+  loopbackBase: "http://127.0.0.1:5555",
+};
+
+// ---------------------------------------------------------------------------
+// Finding #12/#18 — tainted-backup recapture (ClaudeCodeAdapter)
+// ---------------------------------------------------------------------------
+describe("ClaudeCodeAdapter taint guard (audit #12/#18)", () => {
+  let target: string;
+  let bak: string;
+  beforeEach(() => { target = tmpTarget(); bak = target + ".kickbacks-backup"; });
+
+  it("ensureBackup REFUSES to capture a patched live file: applyPatch is"
+    + " success-no-write, no tainted backup is minted", () => {
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.applyPatch(params).ok).toBe(true);     // normal first apply
+    // Cross-window interleave: window B's restore() deleted the backup while
+    // the live file stayed patched (window A re-patched after the restore).
+    rmSync(bak);
+    const patched = readFileSync(target, "utf8");
+    const r = a.applyPatch({ ...params, adText: "Different ad text" });
+    expect(r.ok).toBe(true);                        // apply-success-no-write
+    expect(r.reason).toMatch(/already patched/i);
+    expect(existsSync(bak)).toBe(false);            // refused the capture
+    expect(readFileSync(target, "utf8")).toBe(patched); // byte-identical
+    expect(readFileSync(target, "utf8")).not.toContain("Different ad text");
+  });
+
+  it("after a refused capture, restore() is honest (no backup) and never"
+    + " touches the live file", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);
+    rmSync(bak);
+    const patched = readFileSync(target, "utf8");
+    a.applyPatch(params);                           // refused, no capture
+    const r = a.restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(false);
+    expect(r.reason).toMatch(/no backup/i);
+    expect(readFileSync(target, "utf8")).toBe(patched); // untouched
+  });
+
+  it("stale-backup recapture also refuses when the live file is patched"
+    + " (never re-mints a poisoned backup from a poisoned live file)", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);                           // live now patched
+    writeFileSync(bak, "garbage without the anchor", "utf8"); // stale backup
+    const live = readFileSync(target, "utf8");
+    const r = a.applyPatch(params);
+    expect(r.ok).toBe(true);
+    expect(existsSync(bak)).toBe(false);            // stale deleted, NOT replaced
+    expect(readFileSync(target, "utf8")).toBe(live); // no write
+  });
+
+  it("restore() STRIPS our block from a tainted backup instead of reinstating"
+    + " it (pre-guard field backups)", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);
+    const patchedBytes = readFileSync(target);
+    // Simulate a pre-guard tainted capture: backup holds the PATCHED bytes.
+    writeFileSync(bak, patchedBytes);
+    const r = a.restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(true);
+    const after = readFileSync(target, "utf8");
+    expect(after).not.toContain("VIBE-ADS-START");  // ad block NOT reinstated
+    expect(after).toContain('"Discombobulating"');  // verb array intact
+    expect(a.isPatched()).toBe(false);
+    expect(existsSync(bak)).toBe(false);            // consumed after verify
+  });
+
+  it("normal restore stays byte-exact (taint strip never fires on a clean"
+    + " backup)", () => {
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch(params);
+    expect(a.restore().restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Finding #13 — key-scoped settings.json restore (ClaudeCliStatuslineAdapter)
+// ---------------------------------------------------------------------------
+const P = { tier: 0 as const, adText: "Acme", iconRef: "i", iconUrl: "",
+  clickToken: "", clickUrl: "https://acme/x", corr: "cli.abc", loopbackPort: 0,
+  loopbackToken: "", loopbackBase: "" };
+
+function homeWithClaude(): { home: string; settings: string } {
+  const home = mkdtempSync(join(tmpdir(), "vibe-cli-bsafe-"));
+  mkdirSync(join(home, ".claude"), { recursive: true });
+  return { home, settings: join(home, ".claude", "settings.json") };
+}
+
+describe("ClaudeCliStatuslineAdapter key-scoped restore (audit #13)", () => {
+  it("restore preserves user edits made AFTER first apply (no stale-snapshot"
+    + " rollback)", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, '{\n  "model": "opus"\n}\n', "utf8");
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);
+    // Days later: the user adds a hooks config while the patch is applied.
+    const cur = readFileSync(settings, "utf8");
+    writeFileSync(settings, cur.replace("{\n",
+      '{\n  "hooks": { "PostToolUse": [{ "command": "fmt" }] },\n'), "utf8");
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    const out = readFileSync(settings, "utf8");
+    expect(parseable(out)).toBe(true);
+    const parsed = JSON.parse(out);
+    expect(parsed.hooks).toBeDefined();             // user edit SURVIVES
+    expect(parsed.model).toBe("opus");
+    expect(parsed.statusLine).toBeUndefined();      // our keys removed
+    expect(parsed.spinnerVerbs).toBeUndefined();
+  });
+
+  it("restore on the ABSENT sentinel keeps the file when the user added"
+    + " content since (only our keys removed)", () => {
+    const { settings } = homeWithClaude();          // no settings.json yet
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);          // we created the file
+    const cur = readFileSync(settings, "utf8");
+    writeFileSync(settings,
+      cur.replace("{\n", '{\n  "model": "sonnet",\n'), "utf8");
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    expect(existsSync(settings)).toBe(true);        // NOT deleted
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.model).toBe("sonnet");            // user content survives
+    expect(parsed.statusLine).toBeUndefined();
+    expect(parsed.spinnerVerbs).toBeUndefined();
+  });
+
+  it("restore on the ABSENT sentinel deletes the untouched empty shell we"
+    + " created", () => {
+    const { settings } = homeWithClaude();
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    expect(existsSync(settings)).toBe(false);       // shell removed
+    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("restore round-trips a never-edited settings.json byte-exact", () => {
+    const { settings } = homeWithClaude();
+    const pristine = '{\n  "model": "opus",\n  "theme": "dark"\n}\n';
+    writeFileSync(settings, pristine, "utf8");
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    expect(a.restore().restored).toBe(true);
+    expect(readFileSync(settings, "utf8")).toBe(pristine);
+  });
+
+  it("restore refuses (and keeps the backup) when the current settings.json"
+    + " is unparseable — never clobbers a user-broken file", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, '{\n  "model": "opus"\n}\n', "utf8");
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    writeFileSync(settings, "{ broken ", "utf8");   // user breaks the file
+    const r = a.restore();
+    expect(r.ok).toBe(false);
+    expect(r.restored).toBe(false);
+    expect(r.reason).toMatch(/not parseable/);
+    expect(readFileSync(settings, "utf8")).toBe("{ broken "); // untouched
+    expect(existsSync(settings + ".vibe-ads-backup")).toBe(true); // retryable
+  });
+
+  it("applyPatch script write is idempotent (no per-tick rewrite) but heals"
+    + " a corrupted script", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, "{}\n", "utf8");
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    const script = join(home, ".vibe-ads", "vibe-ads-statusline.mjs");
+    const old = (Date.now() - 3_600_000) / 1000;
+    utimesSync(script, old, old);
+    const before = statSync(script).mtimeMs;
+    a.applyPatch(P);                                // 60s cliSync tick
+    expect(statSync(script).mtimeMs).toBe(before);  // byte-identical → no write
+    writeFileSync(script, "corrupted", "utf8");
+    a.applyPatch(P);
+    expect(readFileSync(script, "utf8")).not.toBe("corrupted"); // healed
+  });
+});
+
+// removeTopLevel must round-trip upsertTopLevel byte-exact (whitespace-aware
+// trailing-comma removal) — the property the key-scoped restore stands on.
+describe("settingsEdit.removeTopLevel round-trip (audit #13)", () => {
+  it("upsert x2 then remove x2 returns the original text byte-exact", () => {
+    const src = '{\n  "model": "opus"\n}\n';
+    const VAL = '{ "type": "command", "command": "node x", "padding": 0 }';
+    const SV = '{"mode":"replace","verbs":["Acme"]}';
+    let out = upsertStatusLine(src, VAL);
+    out = upsertSpinnerVerbs(out, SV);
+    out = removeTopLevel(out, "statusLine");
+    out = removeTopLevel(out, "spinnerVerbs");
+    expect(out).toBe(src);
+  });
+
+  it("never consumes a comment between the previous token and the key", () => {
+    const src = '{\n  // user comment\n  "spinnerVerbs": { "mode": "replace",'
+      + ' "verbs": ["Old"] },\n  "model": "opus"\n}\n';
+    const out = removeTopLevel(src, "spinnerVerbs");
+    expect(out).toContain("// user comment");
+    expect(out).toContain('"model": "opus"');
+    expect(out).not.toContain('"verbs"');
+    expect(parseable(out)).toBe(true);
+  });
+});
diff --git a/test/banner.test.ts b/test/banner.test.ts
new file mode 100644
index 0000000..44f6a1c
--- /dev/null
+++ b/test/banner.test.ts
@@ -0,0 +1,19 @@
+import { describe, it, expect } from "vitest";
+import { resolveBannerOn } from "../src/banner";
+
+describe("resolveBannerOn", () => {
+  it("override 'on' forces true even if server is false", () => {
+    expect(resolveBannerOn(false, "on")).toBe(true);
+  });
+  it("override 'off' forces false even if server is true", () => {
+    expect(resolveBannerOn(true, "off")).toBe(false);
+  });
+  it("override 'server' follows the server flag", () => {
+    expect(resolveBannerOn(true, "server")).toBe(true);
+    expect(resolveBannerOn(false, "server")).toBe(false);
+  });
+  it("defaults to following server when override is undefined (old globalState)", () => {
+    expect(resolveBannerOn(true, undefined)).toBe(true);
+    expect(resolveBannerOn(false, undefined)).toBe(false);
+  });
+});
diff --git a/test/block.test.ts b/test/block.test.ts
new file mode 100644
index 0000000..70123bf
--- /dev/null
+++ b/test/block.test.ts
@@ -0,0 +1,206 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import vm from "node:vm";
+
+// Load the asset with substitutions applied, exporting its pure fns via the
+// module.exports branch (mirrors the ad-01 spike's node-test export).
+function load() {
+  let src = readFileSync(join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+  const subs: Record<string,string> = {
+    __VIBE_ADS_TIER__: "3", __VIBE_ADS_AD__: JSON.stringify("Acme deploys faster than your CI"),
+    __VIBE_ADS_ICON__: JSON.stringify("icon.a"), __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"), __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_ICON_URL__: JSON.stringify(""),
+    __VIBE_ADS_CLICKURL__: JSON.stringify("https://acme.example/lp?ck"),
+    __VIBE_ADS_BANNER_ON__: "true",
+    __VIBE_ADS_CORR__: JSON.stringify("ad1.abcd"),
+  };
+  for (const [k,v] of Object.entries(subs)) src = src.split(k).join(v);
+  const mod = { exports: {} as any };
+  vm.runInNewContext(src, { module: mod, exports: mod.exports });
+  return mod.exports;
+}
+
+describe("block.asset", () => {
+  it("esc escapes html", () => {
+    expect(load().esc('<a "&>')).toBe("&lt;a &quot;&amp;&gt;");
+  });
+  it("ellipsis cycles 0..5 dots (6 frames)", () => {
+    const b = load();
+    expect([0, 1, 2, 3, 4, 5, 6].map((i) => b.ellipsis(i)))
+      .toEqual(["", " .", " ..", " ...", " ....", " .....", ""]);
+  });
+  it("buildAdHtml tier<=1 is a bare anchor with theme token color", () => {
+    const h = load().buildAdHtml(1, { ad: "Acme", dots: "", elapsed: "" });
+    expect(h).toContain("var(--vscode-foreground");
+    expect(h).toContain("data-vibe-ads-ad");
+    expect(h).not.toContain("flex");
+  });
+  it("buildAdHtml tier 3: favicon + LEFT-justified underlined ad + dim right",
+    () => {
+    const h = load().buildAdHtml(3, { ad: "Acme", dots: " .", elapsed: "1.2s" });
+    expect(h).toContain("<svg");
+    // Left-justified to the favicon (was space-between, which flung the ad
+    // text to the far right away from the logo).
+    expect(h).toContain("justify-content:flex-start");
+    expect(h).not.toContain("space-between");
+    // Underlined => reads as the clickable hyperlink it is.
+    expect(h).toContain("text-decoration:underline");
+    expect(h).not.toContain("text-decoration:none");
+    expect(h).toContain("var(--vscode-descriptionForeground");
+  });
+  it("Continue feature fully removed: done flag is ignored, always renders the ad", () => {
+    const b = load();
+    // The Continue CTA / done-pill is gone. buildAdHtml never emits a pill;
+    // a `done:true` arg is simply ignored — it still renders the ad markup
+    // (the render loop, not the builder, decides whether to render at all).
+    const h = b.buildAdHtml(3, { ad: "Acme", done: true });
+    expect(h).not.toContain("Continue");
+    expect(h).not.toContain("data-vibe-ads-continue");
+    expect(h).not.toContain("data-vibe-ads-done");
+    expect(h).toContain("Acme");                 // still the ad
+    expect(h).toContain("data-vibe-ads-ad");     // still the ad-URL click
+    // and the asset source itself carries none of the removed plumbing
+    const src = readFileSync(
+      join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+    for (const sym of ["injectContinue", "ensureContinuePill",
+      "removeContinuePill", "data-vibe-ads-continue", "data-vibe-ads-done"])
+      expect(src.includes(sym)).toBe(false);
+  });
+  describe("looksLikeUsageBanner (usage-banner rewrite prototype)", () => {
+    it("matches the real Claude Code weekly-limit banner text", () => {
+      const b = load();
+      expect(b.looksLikeUsageBanner(
+        "You've used 71% of your weekly limit · resets in 4d · View usage")).toBe(true);
+    });
+    it("matches a 5-hour-window variant", () => {
+      const b = load();
+      expect(b.looksLikeUsageBanner(
+        "You've used 92% of your current usage limit · resets in 2h")).toBe(true);
+    });
+    it("does not match spinner / unrelated chrome text", () => {
+      const b = load();
+      expect(b.looksLikeUsageBanner("✶ Discombobulating… Read · 1.2s")).toBe(false);
+      expect(b.looksLikeUsageBanner("Queue another message…")).toBe(false);
+      expect(b.looksLikeUsageBanner("")).toBe(false);
+    });
+    it("does not match a phrase fragment alone (needs both anchors)", () => {
+      const b = load();
+      // "resets in" alone (e.g. a docs paragraph) must not trigger a rewrite
+      expect(b.looksLikeUsageBanner("Your token resets in the next billing cycle")).toBe(false);
+    });
+  });
+
+  describe("buildBannerHtml (usage-banner ad render)", () => {
+    it("is a clickable, escaped ad anchor carrying the shared click hook", () => {
+      const b = load();
+      const h = b.buildBannerHtml("Acme & Co <ad>", "https://acme.example/lp?a=1&b=2");
+      expect(h).toContain('data-vibe-ads-ad="1"');
+      expect(h).toContain('target="_blank"');
+      expect(h).toContain('rel="noopener noreferrer"');
+      expect(h).toContain("href=\"https://acme.example/lp?a=1&amp;b=2\"");
+      expect(h).toContain("Acme &amp; Co &lt;ad&gt;");
+      expect(h).toContain("var(--vscode-foreground");
+      expect(h).not.toContain("<script");
+    });
+    it("degrades to # when no url supplied", () => {
+      expect(load().buildBannerHtml("Acme", "")).toContain('href="#"');
+    });
+    it("rejects non-http(s) clickUrl schemes (no javascript: sink)", () => {
+      const b = load();
+      expect(b.buildBannerHtml("Acme", "javascript:alert(1)")).toContain('href="#"');
+      expect(b.buildBannerHtml("Acme", "data:text/html,x")).toContain('href="#"');
+      // legitimate https still works
+      expect(b.buildBannerHtml("Acme", "https://acme.example/lp")).toContain('href="https://acme.example/lp"');
+    });
+  });
+
+  it("block carries corr in the click ping and relayed dlog", () => {
+    const src = readFileSync(
+      join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+    expect(src).toContain("__VIBE_ADS_CORR__");
+    expect(src).toContain("&corr=");
+    expect(src).toContain("corr: CORR");
+  });
+
+  // Idle DOCK-TO-COMPOSER (replaced freeze-at-pixel). The DOM state machine has
+  // no jsdom harness here (the asset runs in a vm with no document), so these
+  // are source-level guards mirroring the "Continue removed" test above. The
+  // behavioral check is the local VSIX (scroll an idle panel).
+  describe("idle dock-to-composer", () => {
+    const src = () => readFileSync(
+      join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+
+    it("docks above the composer at idle instead of freezing at a pixel", () => {
+      const s = src();
+      expect(s).toContain("function findComposer(");
+      expect(s).toContain("function placeDocked(");
+      expect(s).toContain("function dockOverlay(");
+      // The old freeze-at-pixel entry point is gone (renamed to dockOverlay).
+      expect(s).not.toContain("function freezeOverlay(");
+      expect(s).not.toContain("freezeOverlay()");
+      // Idle branch calls dockOverlay, not the removed freeze.
+      expect(s).toContain("dockOverlay();");
+    });
+
+    it("has a drop-at-idle fallback when the composer can't be located", () => {
+      const s = src();
+      // dockOverlay drops if findComposer returns null; the rAF/evaluate
+      // re-acquire path drops if the cached composer node disconnects.
+      expect(s).toContain("dock_miss_drop");
+      expect(s).toContain("dock_lost_drop");
+    });
+
+    it("composer locator is read-only and stops the dots via textContent (not innerHTML)", () => {
+      const s = src();
+      // Read-only locate: querySelector + getBoundingClientRect only.
+      const fc = s.slice(s.indexOf("function findComposer("),
+        s.indexOf("function placeDocked("));
+      expect(fc).toContain("querySelectorAll");
+      expect(fc).toContain("getBoundingClientRect");
+      // The locator must NEVER mutate CC's composer (prime directive): no
+      // assignment to innerHTML / textContent / style inside findComposer.
+      expect(fc).not.toContain("innerHTML");
+      expect(fc).not.toMatch(/\.textContent\s*=/);
+      expect(fc).not.toMatch(/\.style\b/);
+      // Stopping the "thinking" animation uses the cached child's textContent,
+      // never an innerHTML rewrite (which would detach the click anchor).
+      expect(s).toContain('_dotsEl.textContent = ""');
+    });
+
+    it("logs the matched composer selector so the locator can be hardened", () => {
+      expect(src()).toContain('dlog("composer.found"');
+    });
+  });
+
+  // View-timer billing integrity (audit 2026-06-09 findings #8/#15/#23).
+  // Behavioral coverage lives in cc-viewtimer.test.ts (jsdom); these are
+  // cheap source-level reintroduction guards in the style of the dock tests.
+  describe("view-timer billing guards (audit #8/#15/#23)", () => {
+    const src = () => readFileSync(
+      join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+
+    it("viewHide ENDS the matching _vt session (the no-op left dropped/" +
+       "hidden surfaces emitting error_impression every 5s forever)", () => {
+      const s = src();
+      expect(s).toContain("function viewEnd(");
+      const vh = s.slice(s.indexOf("function viewHide("),
+        s.indexOf("function viewMaybeEmit("));
+      expect(vh).toContain("viewEnd(adId, surface)");
+      expect(vh).not.toContain("No-op");
+    });
+
+    it("viewTick clamps suspend/wake poll gaps (no sleep billed, no " +
+       "view_tick burst replay on wake)", () => {
+      const s = src();
+      expect(s).toContain("SUSPEND_GAP_MS");
+      const vt = s.slice(s.indexOf("function viewTick("),
+        s.indexOf("setInterval(viewTick"));
+      expect(vt).toContain("gap > SUSPEND_GAP_MS");
+      expect(vt).toContain("sessionStartedAt");
+    });
+  });
+});
diff --git a/test/buildinfo.test.ts b/test/buildinfo.test.ts
new file mode 100644
index 0000000..2d4886d
--- /dev/null
+++ b/test/buildinfo.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from "vitest";
+import { humanAge, buildLabel, BUILD_TS } from "../src/buildinfo";
+
+const T0 = Date.parse("2026-05-17T00:00:00Z");
+
+describe("buildinfo.humanAge — truthful compact relative age", () => {
+  it("seconds under 90s", () => {
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 5_000)).toBe("5s ago");
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 89_000)).toBe("89s ago");
+  });
+  it("minutes 90s..90m", () => {
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 90_000)).toBe("1m ago");
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 89 * 60_000)).toBe("89m ago");
+  });
+  it("hours 90m..48h (the headline 'how many hours ago')", () => {
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 3 * 3_600_000)).toBe("3h ago");
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 47 * 3_600_000)).toBe("47h ago");
+  });
+  it("days past 48h", () => {
+    expect(humanAge("2026-05-17T00:00:00Z", T0 + 50 * 3_600_000)).toBe("2d ago");
+  });
+  it("clamps a future stamp to 0s (clock skew never shows negatives)", () => {
+    expect(humanAge("2026-05-17T01:00:00Z", T0)).toBe("0s ago");
+  });
+  it("empty / garbage => 'unknown' (never a fabricated number)", () => {
+    expect(humanAge("", T0)).toBe("unknown");
+    expect(humanAge("not-a-date", T0)).toBe("unknown");
+  });
+});
+
+describe("buildinfo.buildLabel", () => {
+  it("unbundled (no esbuild define) reports 'build dev', not a stale time", () => {
+    expect(BUILD_TS).toBe("");          // vitest never applies the define
+    expect(buildLabel(T0)).toBe("build dev");
+  });
+});
diff --git a/test/bundled-codex-asset.test.ts b/test/bundled-codex-asset.test.ts
new file mode 100644
index 0000000..ddbfc30
--- /dev/null
+++ b/test/bundled-codex-asset.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from "vitest";
+import { execFileSync } from "node:child_process";
+import { existsSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { resolveCodexBlockAsset } from "../src/adapters/codex/adapter";
+
+// S3 Wave-1 CRIT precedent: the injected block is a SHIPPED RAW ASSET, not
+// bundled into extension.js. esbuild must cpSync it to dist/adapters/codex/,
+// and the adapter's resolver must find it there in the bundled layout.
+describe("S9 bundled Codex asset", () => {
+  it("real esbuild build places dist/adapters/codex/block.asset.js", () => {
+    execFileSync("node", ["esbuild.mjs"],
+      { cwd: join(__dirname, ".."), stdio: "pipe" });
+    const p = join(__dirname, "..", "dist", "adapters", "codex", "block.asset.js");
+    expect(existsSync(p)).toBe(true);
+    const src = readFileSync(p, "utf8");
+    // The asset is the bare passthrough IIFE — the VIBE-ADS markers are added
+    // by the adapter AROUND the wrapper at injection (markers-outside, so a
+    // strip removes the whole statement and never leaves an e=()||e; residue).
+    expect(src.trim().startsWith("(function")).toBe(true);
+    expect(src).toContain("__vibeAdsCodexBoot");   // bootstrap guard present
+    expect(src).toContain("data-vibe-ads");        // overlay element marker
+  });
+
+  it("resolveCodexBlockAsset finds the bundled (dist) layout", () => {
+    const dist = join(__dirname, "..", "dist");
+    expect(resolveCodexBlockAsset(dist))
+      .toBe(join(dist, "adapters", "codex", "block.asset.js"));
+  });
+});
diff --git a/test/capWarning.test.ts b/test/capWarning.test.ts
new file mode 100644
index 0000000..fca924a
--- /dev/null
+++ b/test/capWarning.test.ts
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi } from "vitest";
+import * as vscode from "vscode";
+import { CapWarning, formatReset } from "../src/activation/capWarning";
+
+const RED = "#f85149";
+
+/** Construct a CapWarning while capturing the underlying status-bar item, so
+ *  tests can assert on its text/color/tooltip and spy its show/hide. Mirrors
+ *  the statusbar.test.ts capture pattern. */
+function makeCap() {
+  let item: {
+    text: string; tooltip: string; command: string | undefined;
+    color: string | undefined;
+    show: ReturnType<typeof vi.fn>; hide: ReturnType<typeof vi.fn>;
+    dispose: ReturnType<typeof vi.fn>;
+  };
+  const spy = vi.spyOn(vscode.window, "createStatusBarItem").mockImplementation(
+    () => {
+      item = { text: "", tooltip: "", command: undefined, color: undefined,
+               show: vi.fn(), hide: vi.fn(), dispose: vi.fn() };
+      return item as unknown as vscode.StatusBarItem;
+    });
+  const cw = new CapWarning();
+  spy.mockRestore();
+  return { cw, item: item! };
+}
+
+describe("formatReset", () => {
+  it("formats sub-minute, minutes, and hours+minutes", () => {
+    expect(formatReset(0)).toBe("<1m");
+    expect(formatReset(59)).toBe("<1m");
+    expect(formatReset(22 * 60)).toBe("22m");
+    expect(formatReset(59 * 60)).toBe("59m");
+    expect(formatReset(6 * 3600)).toBe("6h");          // exact hour drops "0m"
+    expect(formatReset(6 * 3600 + 12 * 60)).toBe("6h12m");
+  });
+  it("clamps negatives to <1m", () => {
+    expect(formatReset(-5)).toBe("<1m");
+  });
+});
+
+describe("CapWarning", () => {
+  it("hourly: clock icon, red, $-prefixed cap, shown", () => {
+    const { cw, item } = makeCap();
+    cw.show({ scope: "hourly", capUsd: "10.00", resetSeconds: 22 * 60 });
+    expect(item.text).toBe("$(clock) Hourly cap · 22m");
+    expect(item.color).toBe(RED);
+    expect(item.command).toBe("kickbacks.debugMenu");
+    expect(item.tooltip).toContain("$10.00/hr");
+    expect(item.tooltip).toContain("top of the hour");
+    expect(item.show).toHaveBeenCalled();
+  });
+
+  it("daily: warning icon, red, resets-UTC tooltip", () => {
+    const { cw, item } = makeCap();
+    cw.show({ scope: "daily", capUsd: "50.00", resetSeconds: 6 * 3600 + 12 * 60 });
+    expect(item.text).toBe("$(warning) Daily cap · 6h12m");
+    expect(item.color).toBe(RED);
+    expect(item.tooltip).toContain("$50.00");
+    expect(item.tooltip).toContain("00:00 UTC");
+    expect(item.show).toHaveBeenCalled();
+  });
+
+  it("hide() hides the item", () => {
+    const { cw, item } = makeCap();
+    cw.hide();
+    expect(item.hide).toHaveBeenCalled();
+  });
+
+  it("dispose() disposes the item", () => {
+    const { cw, item } = makeCap();
+    cw.dispose();
+    expect(item.dispose).toHaveBeenCalled();
+  });
+});
diff --git a/test/cc-pollad.test.ts b/test/cc-pollad.test.ts
new file mode 100644
index 0000000..4dfb319
--- /dev/null
+++ b/test/cc-pollad.test.ts
@@ -0,0 +1,344 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM, VirtualConsole } from "jsdom";
+
+// Regression coverage for the wave-4 pollAd fixes:
+//
+// Audit #27 — the DOCKED idle overlay never repainted: paint() only runs in
+// the active branch, so a host-side rotation while docked left a STALE
+// creative on screen (clicks unbilled at the host's 15s floor, or
+// misattributed). pollAd now retargets the docked line's EXISTING stable
+// child nodes in place — the ad-text TEXT NODE's nodeValue and the anchor's
+// href ONLY. The anchor is NEVER re-created (rewriting innerHTML of a live
+// clickable element detaches it mid-click — a shipped bug, fixed once), the
+// docked state stays NON-billing (persist-at-idle), and the next thaw's
+// viewShow attributes to the NEW ad.
+//
+// Wave-2 carry-over — the EMPTY /ad payload (the host's serving-gate signal
+// on kill/disable) was a no-op, leaving the last creative painted until
+// idle/reload. pollAd now distinguishes payload shapes: a fetch ERROR keeps
+// keep-last-ad (transient network); TWO consecutive successful-but-empty
+// responses (debounce) drop the overlay/banner via the existing drop paths
+// and END every view session. A served payload re-arms.
+//
+// Harness: cc-viewtimer-style JSDOM + test-controlled Date.now, plus a
+// setInterval capture so the 10s pollAd can be driven deterministically, and
+// a rect-stubbed <textarea> composer so the idle DOCK path (not the
+// dock-miss drop) engages.
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
+  "utf8");
+
+const AD_A = "Acme deploys faster than your CI";
+const URL_A = "https://acme.example/lp";
+const AD_B = "Bolt ships 2x faster";
+const URL_B = "https://bolt.example/lp";
+const PAYLOAD_A = { adText: AD_A, clickUrl: URL_A, iconUrl: "",
+  adId: "adA", campaignId: "c1" };
+const PAYLOAD_B = { adText: AD_B, clickUrl: URL_B, iconUrl: "",
+  adId: "adB", campaignId: "c2" };
+
+function preparedAsset(opts: { bannerOn: boolean }): string {
+  const subs: Record<string, string> = {
+    __VIBE_ADS_TIER__: "3",
+    __VIBE_ADS_AD__: JSON.stringify(AD_A),
+    __VIBE_ADS_ICON__: JSON.stringify("icon.a"),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_ICON_URL__: JSON.stringify(""),
+    __VIBE_ADS_CLICKURL__: JSON.stringify(URL_A),
+    __VIBE_ADS_BANNER_ON__: opts.bannerOn ? "true" : "false",
+    __VIBE_ADS_CORR__: JSON.stringify("adA.abcd"),
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+  };
+  let src = ASSET;
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  return src;
+}
+
+interface Harness {
+  dom: JSDOM;
+  doc: Document;
+  pings: string[];
+  advance: (ms: number) => void;
+  /** Drive the block's 10s /ad poll once, deterministically. */
+  pollAd: () => Promise<void>;
+  setAd: (p: Record<string, unknown> | null) => void;
+  setAdError: (on: boolean) => void;
+}
+
+function makeHarness(opts: { bannerOn: boolean }): Harness {
+  const vc = new VirtualConsole();
+  vc.on("jsdomError", () => { /* irrelevant here */ });
+  const dom = new JSDOM(`<body></body>`,
+    { runScripts: "outside-only", pretendToBeVisual: true,
+      virtualConsole: vc });
+  const win = dom.window as unknown as Window & typeof globalThis & {
+    eval: (s: string) => unknown; fetch: typeof fetch;
+    setInterval: typeof setInterval;
+  };
+  const pings: string[] = [];
+  let adPayload: Record<string, unknown> | null = PAYLOAD_A;
+  let adError = false;
+  win.fetch = ((url: string) => {
+    const u = String(url);
+    if (u.endsWith("/ad")) {
+      if (adError) return Promise.reject(new Error("net down"));
+      return Promise.resolve({ json: async () => (adPayload ?? {}) });
+    }
+    if (u.endsWith("/activity")) {
+      return Promise.resolve({ json: async () => ({}) });
+    }
+    pings.push(u);
+    return Promise.resolve({ json: async () => ({}) });
+  }) as unknown as typeof fetch;
+  // Capture interval callbacks BEFORE boot so the test can invoke pollAd
+  // (the unique 10_000ms interval) directly instead of waiting 10 real
+  // seconds. The real timers keep running underneath, same as production.
+  const intervals: Array<{ fn: () => void; ms: number }> = [];
+  const origSetInterval = win.setInterval.bind(win);
+  (win as unknown as { setInterval: unknown }).setInterval =
+    ((fn: () => void, ms: number) => {
+      intervals.push({ fn, ms });
+      return origSetInterval(fn, ms);
+    }) as unknown as typeof setInterval;
+  let t = 1_700_000_000_000;
+  (win as unknown as { Date: DateConstructor }).Date.now = () => t;
+  win.eval(preparedAsset(opts));
+  const pollFn = intervals.find((i) => i.ms === 10_000)?.fn;
+  if (!pollFn) throw new Error("pollAd interval (10s) not registered");
+  return {
+    dom, doc: dom.window.document, pings,
+    advance: (ms: number) => { t += ms; },
+    pollAd: async () => {
+      pollFn();
+      for (let i = 0; i < 8; i++) await Promise.resolve();
+    },
+    setAd: (p) => { adPayload = p; },
+    setAdError: (on) => { adError = on; },
+  };
+}
+
+const GLYPHS = ["✢", "✶", "✻", "✽"];
+function addSpinner(doc: Document): { el: HTMLElement; spin: () => void } {
+  const el = doc.createElement("div");
+  el.className = "spinnerRow_ab12c";
+  let g = 0;
+  el.textContent = GLYPHS[0] + " Reticulating…";
+  doc.body.appendChild(el);
+  return { el, spin: () => {
+    g = (g + 1) % GLYPHS.length;
+    el.textContent = GLYPHS[g] + " Reticulating…";
+  } };
+}
+
+// A composer the idle DOCK can park above. jsdom rects are all-zero, which
+// findComposer treats as "not visible" (→ dock-miss drop, the path
+// cc-viewtimer.test.ts exercises); stub a real rect so docking succeeds.
+function addComposer(doc: Document): HTMLElement {
+  const el = doc.createElement("textarea");
+  (el as unknown as { getBoundingClientRect: () => unknown })
+    .getBoundingClientRect = () => ({
+      width: 400, height: 60, top: 500, left: 10, right: 410, bottom: 560,
+      x: 10, y: 500, toJSON: () => ({}) });
+  doc.body.appendChild(el);
+  return el;
+}
+
+function addBanner(doc: Document): HTMLElement {
+  const el = doc.createElement("div");
+  el.textContent =
+    "You've used 71% of your weekly limit · resets in 4d · View usage";
+  doc.body.appendChild(el);
+  return el;
+}
+
+const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+const count = (pings: string[], re: RegExp) =>
+  pings.filter((u) => re.test(u)).length;
+const OVERLAY_TICK = /\/view_tick\?surface=overlay&/;
+const BANNER_TICK = /\/view_tick\?surface=banner&/;
+const ERROR = /\/error_impression\?/;
+const overlayOf = (doc: Document) =>
+  doc.querySelector('[data-vibe-ads-overlay="1"]');
+const anchorOf = (doc: Document) =>
+  doc.querySelector('[data-vibe-ads-overlay="1"] a[data-vibe-ads-ad]') as
+    HTMLAnchorElement | null;
+
+describe("CC pollAd — docked-rotation repaint (audit #27)", () => {
+  it("a rotation adopted while DOCKED retargets the SAME anchor in place "
+    + "(text node + href only), stays non-billing, and the thaw attributes "
+    + "to the NEW ad", async () => {
+    const h = makeHarness({ bannerOn: false });
+    addComposer(h.doc);
+    const sp = addSpinner(h.doc);
+    await sleep(300);                       // active paint → overlay + ad A
+    const a1 = anchorOf(h.doc);
+    expect(a1).toBeTruthy();
+    expect(a1!.textContent).toContain(AD_A);
+    expect(a1!.getAttribute("href")).toBe(URL_A);
+    h.advance(5_100); sp.spin();            // accrue one billable tick
+    await sleep(500);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);
+
+    // Turn ends; glyph freezes → idle → DOCK (composer present, no drop).
+    h.advance(2_000);
+    await sleep(400);
+    expect(overlayOf(h.doc)).toBeTruthy(); // persists at idle (docked)
+
+    // Host rotates to ad B while docked; the webview's poll adopts it.
+    h.setAd(PAYLOAD_B);
+    await h.pollAd();
+    const a2 = anchorOf(h.doc);
+    // OLD behavior: the docked line still showed ad A's text + href.
+    expect(a2).toBe(a1);                    // anchor NEVER re-created
+    expect(a2!.textContent).toContain(AD_B);
+    expect(a2!.textContent).not.toContain(AD_A);
+    expect(a2!.getAttribute("href")).toBe(URL_B);
+    // Stable children survive the retarget (no innerHTML rewrite).
+    expect(a2!.querySelector("[data-va-dots]")).toBeTruthy();
+
+    // Docked stays NON-billing after the swap (persist-at-idle): the old
+    // session ended, no live session started for B.
+    h.advance(20_000);
+    await sleep(400);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);
+    expect(count(h.pings, ERROR)).toBe(0);
+
+    // Next turn thaws → the NEW ad's session opens fresh and bills as B.
+    sp.spin();
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.advance(5_100); sp.spin();
+    await sleep(500);
+    const ticks = h.pings.filter((u) => OVERLAY_TICK.test(u));
+    expect(ticks.length).toBe(2);
+    expect(ticks[1]).toContain("&ad=" + encodeURIComponent(AD_B));
+    expect(ticks[1]).toContain("&visible_ms=5000");
+    h.dom.window.close();
+  }, 20000);
+});
+
+describe("CC pollAd — empty payload is the no-serve signal (wave-2 "
+  + "carry-over)", () => {
+  it("TWO consecutive empty polls drop the overlay and END every session — "
+    + "nothing repaints even with a live spinner; a served payload re-arms",
+    async () => {
+    const h = makeHarness({ bannerOn: false });
+    const sp = addSpinner(h.doc);
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.advance(5_100); sp.spin();
+    await sleep(500);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);
+
+    h.setAd(null);                          // host gate: /ad returns {}
+    await h.pollAd();
+    // Debounce: ONE empty read never tears down (could race a rotation).
+    expect(overlayOf(h.doc)).toBeTruthy();
+    await h.pollAd();                       // second consecutive empty
+    await sleep(300);
+    // OLD behavior: the empty payload was a no-op — the last creative
+    // stayed painted (and at idle would persist) until reload.
+    expect(overlayOf(h.doc)).toBeNull();
+
+    // Sessions are ENDED and the evaluator stays down even though the
+    // spinner row is still live: no ticks, no error_impressions, no repaint.
+    h.advance(20_000); sp.spin();
+    await sleep(500);
+    expect(overlayOf(h.doc)).toBeNull();
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);
+    expect(count(h.pings, ERROR)).toBe(0);
+
+    // Host serves again → re-arm: the overlay re-mounts on the next active
+    // paint with the new creative.
+    h.setAd(PAYLOAD_B);
+    await h.pollAd();
+    sp.spin();
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    expect(anchorOf(h.doc)!.textContent).toContain(AD_B);
+    h.dom.window.close();
+  }, 20000);
+
+  it("ONE empty poll followed by a real ad does NOT drop (single-race "
+    + "debounce; the counter resets on any served payload)", async () => {
+    const h = makeHarness({ bannerOn: false });
+    const sp = addSpinner(h.doc);
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+
+    h.setAd(null);
+    await h.pollAd();                       // empty #1
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.setAd(PAYLOAD_A);
+    await h.pollAd();                       // served → counter resets
+    h.setAd(null);
+    await h.pollAd();                       // empty #1 again (not #2)
+    await sleep(200);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    sp.spin();                              // still serving normally
+    await sleep(200);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.dom.window.close();
+  }, 20000);
+
+  it("fetch ERRORS never drop: transient network keeps the last ad and its "
+    + "session keeps billing", async () => {
+    const h = makeHarness({ bannerOn: false });
+    const sp = addSpinner(h.doc);
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+
+    h.setAdError(true);
+    await h.pollAd();
+    await h.pollAd();
+    await h.pollAd();                       // 3 consecutive FAILURES
+    expect(overlayOf(h.doc)).toBeTruthy(); // keep-last-ad preserved
+    h.advance(5_100); sp.spin();
+    await sleep(500);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);  // session alive
+    h.dom.window.close();
+  }, 20000);
+
+  it("the BANNER surface is hidden and its session ENDED by no-serve at "
+    + "idle, and repaints when the host serves again", async () => {
+    const h = makeHarness({ bannerOn: true });
+    addBanner(h.doc);
+    await sleep(1_300);                     // banner loop is 1s
+    const bEl = h.doc.querySelector(
+      '[data-vibe-ads-banner="1"]') as HTMLElement;
+    expect(bEl).toBeTruthy();
+    h.advance(5_100);
+    await sleep(500);
+    expect(count(h.pings, BANNER_TICK)).toBe(1);  // visible-at-idle billing
+
+    h.setAd(null);
+    await h.pollAd();
+    await h.pollAd();
+    await sleep(1_200);                     // ≥1 banner loop tick
+    // OLD behavior: the banner kept the stale creative visible and ticking.
+    expect(bEl.style.display).toBe("none");
+    h.advance(10_000);
+    await sleep(600);
+    expect(count(h.pings, BANNER_TICK)).toBe(1);
+
+    // Host resumes (same creative): banner un-hides, repaints, and a FRESH
+    // session starts at 0.
+    h.setAd(PAYLOAD_A);
+    await h.pollAd();
+    await sleep(1_300);
+    expect(bEl.style.display).not.toBe("none");
+    h.advance(5_100);
+    await sleep(500);
+    const ticks = h.pings.filter((u) => BANNER_TICK.test(u));
+    expect(ticks.length).toBe(2);
+    expect(/[?&]visible_ms=5000(&|$)/.test(ticks[1])).toBe(true);
+    h.dom.window.close();
+  }, 20000);
+});
diff --git a/test/cc-spinner-detect.test.ts b/test/cc-spinner-detect.test.ts
new file mode 100644
index 0000000..f3aa52e
--- /dev/null
+++ b/test/cc-spinner-detect.test.ts
@@ -0,0 +1,169 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM, VirtualConsole } from "jsdom";
+
+// Regression coverage for audit 2026-06-09 finding #28: findSpinner returned
+// the FIRST non-empty `.spinnerRow_` element in document order. CC can leave
+// a STALE prior row frozen mid-glyph at turn end (the asset's own GRACE_MS
+// comment documents this) while keeping the node mounted — so in turn N+1
+// the live animating row mounts BELOW the stale one, the stale row shadows
+// it, the global freshness signature never changes, and the ad is suppressed
+// for the entire turn. The fix prefers the LAST non-empty row among the rows
+// the EXISTING selector already matches (the transcript appends, so the live
+// row is the latest). PRIME DIRECTIVE: the selector and the observation
+// scope must NOT grow — these tests also pin the single-row / zero-row /
+// emptied-row behaviors unchanged.
+//
+// Harness: same shape as cc-viewtimer.test.ts — the block runs in JSDOM on
+// its real 80ms/250ms intervals, the realm's Date.now is test-controlled, so
+// freshness (GRACE_MS) advances only when the test says so.
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
+  "utf8");
+
+function preparedAsset(): string {
+  const subs: Record<string, string> = {
+    __VIBE_ADS_TIER__: "3",
+    __VIBE_ADS_AD__: JSON.stringify("Acme deploys faster than your CI"),
+    __VIBE_ADS_ICON__: JSON.stringify("icon.a"),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_ICON_URL__: JSON.stringify(""),
+    __VIBE_ADS_CLICKURL__: JSON.stringify("https://acme.example/lp"),
+    __VIBE_ADS_BANNER_ON__: "false",
+    __VIBE_ADS_CORR__: JSON.stringify("adA.abcd"),
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+  };
+  let src = ASSET;
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  return src;
+}
+
+interface Harness {
+  dom: JSDOM;
+  doc: Document;
+  pings: string[];
+  advance: (ms: number) => void;
+}
+
+function makeHarness(): Harness {
+  const vc = new VirtualConsole();
+  vc.on("jsdomError", () => { /* irrelevant here */ });
+  const dom = new JSDOM(`<body></body>`,
+    { runScripts: "outside-only", pretendToBeVisual: true,
+      virtualConsole: vc });
+  const win = dom.window as unknown as Window & typeof globalThis & {
+    eval: (s: string) => unknown; fetch: typeof fetch;
+  };
+  const pings: string[] = [];
+  win.fetch = ((url: string) => {
+    const u = String(url);
+    // /ad must keep serving a REAL payload: the no-serve teardown (wave-4
+    // fix 3) must never engage in these detection-only tests.
+    if (u.endsWith("/ad")) {
+      return Promise.resolve({ json: async () => ({
+        adText: "Acme deploys faster than your CI",
+        clickUrl: "https://acme.example/lp", iconUrl: "",
+        adId: "adA", campaignId: "c1" }) });
+    }
+    if (u.endsWith("/activity")) {
+      return Promise.resolve({ json: async () => ({}) });
+    }
+    pings.push(u);
+    return Promise.resolve({ json: async () => ({}) });
+  }) as unknown as typeof fetch;
+  let t = 1_700_000_000_000;
+  (win as unknown as { Date: DateConstructor }).Date.now = () => t;
+  win.eval(preparedAsset());
+  return { dom, doc: dom.window.document, pings,
+    advance: (ms: number) => { t += ms; } };
+}
+
+const GLYPHS = ["✢", "✶", "✻", "✽"];
+function addSpinner(doc: Document): { el: HTMLElement; spin: () => void } {
+  const el = doc.createElement("div");
+  el.className = "spinnerRow_ab12c";
+  let g = 0;
+  el.textContent = GLYPHS[0] + " Reticulating…";
+  doc.body.appendChild(el);
+  return { el, spin: () => {
+    g = (g + 1) % GLYPHS.length;
+    el.textContent = GLYPHS[g] + " Reticulating…";
+  } };
+}
+
+const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+const overlayOf = (doc: Document) =>
+  doc.querySelector('[data-vibe-ads-overlay="1"]');
+const count = (pings: string[], re: RegExp) =>
+  pings.filter((u) => re.test(u)).length;
+const OVERLAY_TICK = /\/view_tick\?surface=overlay&/;
+
+describe("CC spinner detection — last non-empty row wins (audit #28)", () => {
+  it("a stale frozen row ABOVE must not shadow the live row BELOW: the "
+    + "live (latest) row is chosen, the ad mounts and bills the turn",
+    async () => {
+    const h = makeHarness();
+    // Turn N's leftover: a non-empty row frozen mid-glyph (✻ never changes),
+    // still mounted at the TOP of the transcript.
+    const stale = h.doc.createElement("div");
+    stale.className = "spinnerRow_ab12c";
+    stale.textContent = "✻ Baking… (12s)";
+    h.doc.body.appendChild(stale);
+    await sleep(300);
+    // The frozen row alone looks fresh on first sighting, then goes stale
+    // past GRACE_MS → idle → dock → no composer in jsdom → drop.
+    h.advance(2_000);
+    await sleep(400);
+    expect(overlayOf(h.doc)).toBeNull();
+
+    // Turn N+1: the LIVE row mounts BELOW the stale one and animates
+    // (initial glyph ✢ ≠ the frozen ✻).
+    const live = addSpinner(h.doc);
+    await sleep(400);
+    // OLD behavior: findSpinner returned the FIRST non-empty row (the
+    // frozen one), its signature never changed, paint() never ran — the ad
+    // stayed suppressed for the whole turn. NEW: the LAST non-empty row is
+    // the live one; the overlay mounts.
+    expect(overlayOf(h.doc)).toBeTruthy();
+    // …and the turn bills normally (the live row's session is counting).
+    h.advance(5_100); live.spin();
+    await sleep(500);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);
+    h.dom.window.close();
+  }, 20000);
+
+  it("single-row case unchanged: one live row shows the ad; frozen past "
+    + "GRACE it goes idle (dock-miss → drop in jsdom)", async () => {
+    const h = makeHarness();
+    addSpinner(h.doc);
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.advance(2_000);                      // glyph never rotated → stale
+    await sleep(400);
+    expect(overlayOf(h.doc)).toBeNull();
+    h.dom.window.close();
+  }, 20000);
+
+  it("zero-row and emptied-row cases unchanged: no spinnerRow (or only "
+    + "CC's emptied `<div></div>` turn-end row) never paints", async () => {
+    const h = makeHarness();
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeNull();
+    // CC's turn-end state: the row stays mounted but emptied — NOT a
+    // candidate (whitespace-only is "no spinner").
+    const emptied = h.doc.createElement("div");
+    emptied.className = "spinnerRow_ab12c";
+    emptied.textContent = "";
+    h.doc.body.appendChild(emptied);
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeNull();
+    expect(count(h.pings, /impression_rendered/)).toBe(0);
+    h.dom.window.close();
+  }, 20000);
+});
diff --git a/test/cc-viewtimer.test.ts b/test/cc-viewtimer.test.ts
new file mode 100644
index 0000000..73b5159
--- /dev/null
+++ b/test/cc-viewtimer.test.ts
@@ -0,0 +1,235 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM, VirtualConsole } from "jsdom";
+
+// Regression coverage for the CC-webview view-timer billing fixes
+// (audit 2026-06-09 findings #8, #15, #23) — the same phantom-billing class
+// already fixed in the codex block (see codex-viewtimer.test.ts):
+//   #8  viewHide was a documented no-op: the dropOverlay / banner-hidden
+//       paths never ended their _vt session, so a failed dock kept emitting
+//       view_tick + error_impression every 5s forever. viewHide now ENDS
+//       the session (codex viewEnd semantics); a later viewShow re-arms a
+//       FRESH session.
+//   #15 the banner session kept billing while display:none during turns.
+//       Hide ends the session; un-hide opens a fresh one. (Banner billing
+//       while VISIBLE at idle is by design and unchanged.)
+//   #23 laptop suspend was billed as visible time and replayed as a
+//       synchronous view_tick burst on wake (absolute-epoch elapsed +
+//       unbounded catch-up loop). viewTick now clamps poll gaps > 2 tick
+//       intervals: the excess shifts the baseline (never billed) and at
+//       most ONE catch-up tick fires per wake.
+//
+// Harness: the block runs in JSDOM on its real 250ms/1s intervals, but the
+// webview realm's Date.now is test-controlled, so "elapsed" view-time (and
+// an 8h suspend) advance only when the test says so — deterministic billing
+// boundaries without 5s real-time sleeps.
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
+  "utf8");
+
+function preparedAsset(opts: { bannerOn: boolean }): string {
+  const subs: Record<string, string> = {
+    __VIBE_ADS_TIER__: "3",
+    __VIBE_ADS_AD__: JSON.stringify("Acme deploys faster than your CI"),
+    __VIBE_ADS_ICON__: JSON.stringify("icon.a"),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_ICON_URL__: JSON.stringify(""),
+    __VIBE_ADS_CLICKURL__: JSON.stringify("https://acme.example/lp"),
+    __VIBE_ADS_BANNER_ON__: opts.bannerOn ? "true" : "false",
+    __VIBE_ADS_CORR__: JSON.stringify("ad1.abcd"),
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+  };
+  let src = ASSET;
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  return src;
+}
+
+interface Harness {
+  dom: JSDOM;
+  doc: Document;
+  pings: string[];
+  advance: (ms: number) => void;
+}
+
+function makeHarness(opts: { bannerOn: boolean }): Harness {
+  const vc = new VirtualConsole();
+  vc.on("jsdomError", () => { /* anchor navigation etc. — irrelevant here */ });
+  const dom = new JSDOM(`<body></body>`,
+    { runScripts: "outside-only", pretendToBeVisual: true,
+      virtualConsole: vc });
+  const win = dom.window as unknown as Window & typeof globalThis & {
+    eval: (s: string) => unknown; fetch: typeof fetch;
+  };
+  const pings: string[] = [];
+  win.fetch = ((url: string) => {
+    pings.push(String(url));
+    return Promise.resolve({ json: async () => ({}) });
+  }) as unknown as typeof fetch;
+  // Take over the webview realm's wall clock BEFORE booting the block: every
+  // billing decision in the asset reads Date.now(). The real setInterval
+  // polls keep firing underneath; only "elapsed" is virtual.
+  let t = 1_700_000_000_000;
+  (win as unknown as { Date: DateConstructor }).Date.now = () => t;
+  win.eval(preparedAsset(opts));
+  return { dom, doc: dom.window.document, pings,
+    advance: (ms: number) => { t += ms; } };
+}
+
+// A live CC spinner row. The block treats the row as ACTIVE only while its
+// leading sparkle glyph keeps CHANGING within GRACE_MS (fake-clock ms), so:
+// with the clock frozen it stays active for free; every advance() > GRACE_MS
+// must be paired with spin() (same synchronous step) to stay "thinking", or
+// left un-spun to go idle.
+const GLYPHS = ["✢", "✶", "✻", "✽"];
+function addSpinner(doc: Document): { el: HTMLElement; spin: () => void } {
+  const el = doc.createElement("div");
+  el.className = "spinnerRow_ab12c";
+  let g = 0;
+  el.textContent = GLYPHS[0] + " Reticulating…";
+  doc.body.appendChild(el);
+  return { el, spin: () => {
+    g = (g + 1) % GLYPHS.length;
+    el.textContent = GLYPHS[g] + " Reticulating…";
+  } };
+}
+
+function addBanner(doc: Document): HTMLElement {
+  const el = doc.createElement("div");
+  el.textContent =
+    "You've used 71% of your weekly limit · resets in 4d · View usage";
+  doc.body.appendChild(el);
+  return el;
+}
+
+const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+const count = (pings: string[], re: RegExp) =>
+  pings.filter((u) => re.test(u)).length;
+const OVERLAY_TICK = /\/view_tick\?surface=overlay&/;
+const BANNER_TICK = /\/view_tick\?surface=banner&/;
+const ERROR = /\/error_impression\?/;
+const THRESHOLD = /\/view_threshold_met\?/;
+function visibleMs(u: string): number {
+  const m = /[?&]visible_ms=(\d+)/.exec(u);
+  return m ? Number(m[1]) : -1;
+}
+
+describe("CC view-timer billing fixes (audit #8/#15/#23)", () => {
+  it("#8: a failed dock (composer miss) ENDS the overlay session — no "
+    + "view_tick/error_impression keeps firing off-screen; the next turn "
+    + "re-arms a FRESH session", async () => {
+    const h = makeHarness({ bannerOn: false });
+    const sp = addSpinner(h.doc);
+    await sleep(400);                       // evaluate() paints + viewShow
+    expect(h.doc.querySelector('[data-vibe-ads-overlay="1"]')).toBeTruthy();
+
+    h.advance(5_100); sp.spin();            // cross one tick boundary, active
+    await sleep(500);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(1);
+
+    // Turn ends; jsdom has no visible composer → dock_miss → dropOverlay
+    // (exactly the finding-#8 failure path). The glyph is NOT rotated so the
+    // row goes stale past GRACE_MS.
+    h.advance(2_000);
+    await sleep(500);
+    expect(h.doc.querySelector('[data-vibe-ads-overlay="1"]')).toBeNull();
+    const ticksAtDrop = count(h.pings, OVERLAY_TICK);
+    const errsAtDrop = count(h.pings, ERROR);
+
+    // OLD behavior: the no-op viewHide left the session immortal — this
+    // jump crossed the 10s and 15s boundaries and kept emitting. An ENDED
+    // session emits NOTHING.
+    h.advance(9_900);
+    await sleep(500);
+    expect(count(h.pings, OVERLAY_TICK)).toBe(ticksAtDrop);
+    expect(count(h.pings, ERROR)).toBe(errsAtDrop);
+
+    // A new turn re-glues and opens a FRESH session: its first tick reports
+    // visible_ms=5000, not a carried-over total.
+    sp.spin();
+    await sleep(400);
+    expect(h.doc.querySelector('[data-vibe-ads-overlay="1"]')).toBeTruthy();
+    h.advance(5_100); sp.spin();
+    await sleep(500);
+    const ticks = h.pings.filter((u) => OVERLAY_TICK.test(u));
+    expect(ticks.length).toBe(ticksAtDrop + 1);
+    expect(visibleMs(ticks[ticks.length - 1])).toBe(5_000);
+    h.dom.window.close();
+  }, 20000);
+
+  it("#15: the banner session ENDS while hidden during a turn "
+    + "(display:none must not bill); un-hide at idle opens a FRESH session",
+    async () => {
+    const h = makeHarness({ bannerOn: true });
+    addBanner(h.doc);
+    await sleep(1_300);                     // banner loop is 1s
+    expect(h.doc.querySelector('[data-vibe-ads-banner="1"]')).toBeTruthy();
+
+    // Visible-at-idle billing is BY DESIGN and must keep working.
+    h.advance(5_100);
+    await sleep(500);
+    expect(count(h.pings, BANNER_TICK)).toBe(1);
+
+    // A turn starts → banner hides → its session must END.
+    const sp = addSpinner(h.doc);
+    await sleep(1_300);                     // ≥1 banner tick: hide + end
+    const bEl = h.doc.querySelector(
+      '[data-vibe-ads-banner="1"]') as HTMLElement;
+    expect(bEl.style.display).toBe("none");
+    const hiddenAt = count(h.pings, BANNER_TICK);
+
+    // OLD behavior: the hidden banner's session kept passing the paused
+    // gate and ticked at the 10s/15s boundaries of this jump.
+    h.advance(9_900); sp.spin();            // stay in-turn across the jump
+    await sleep(700);
+    expect(count(h.pings, BANNER_TICK)).toBe(hiddenAt);
+
+    // Turn ends (no composer → overlay drops, _spinnerActive clears) →
+    // banner un-hides and a FRESH session starts at 0.
+    h.advance(2_000);                       // glyph frozen → idle → drop
+    await sleep(1_500);
+    expect(bEl.style.display).not.toBe("none");
+    h.advance(5_100);
+    await sleep(500);
+    const ticks = h.pings.filter((u) => BANNER_TICK.test(u));
+    expect(ticks.length).toBe(hiddenAt + 1);
+    expect(visibleMs(ticks[ticks.length - 1])).toBe(5_000);  // fresh baseline
+    h.dom.window.close();
+  }, 20000);
+
+  it("#23: an 8h suspend is NOT billed as visible time and NOT replayed as "
+    + "a tick burst on wake (at most one catch-up tick; session survives)",
+    async () => {
+    const h = makeHarness({ bannerOn: true });
+    addBanner(h.doc);
+    await sleep(1_300);                     // banner session live at idle
+    h.advance(5_100);
+    await sleep(500);
+    expect(count(h.pings, BANNER_TICK)).toBe(1);
+
+    // Laptop suspends for 8 hours with the session live. OLD behavior:
+    // the wake poll replayed one view_tick per 5s of sleep (~5760 events)
+    // each reporting machine-off time as cumulative visible_ms.
+    h.advance(8 * 3_600_000);
+    await sleep(700);
+    const ticks = h.pings.filter((u) => BANNER_TICK.test(u));
+    expect(ticks.length).toBe(2);           // exactly ONE catch-up tick
+    expect(visibleMs(ticks[1])).toBe(10_000);  // sleep excluded, not ~8h
+    expect(count(h.pings, ERROR)).toBe(0);
+    expect(count(h.pings, THRESHOLD)).toBe(0);
+
+    // Billing continues normally after wake — the baseline was shifted
+    // past the sleep, not the session torn down.
+    h.advance(5_000);
+    await sleep(500);
+    const after = h.pings.filter((u) => BANNER_TICK.test(u));
+    expect(after.length).toBe(3);
+    expect(visibleMs(after[2])).toBe(15_000);
+    h.dom.window.close();
+  }, 20000);
+});
diff --git a/test/claudeCodeVersion.test.ts b/test/claudeCodeVersion.test.ts
new file mode 100644
index 0000000..2ef682e
--- /dev/null
+++ b/test/claudeCodeVersion.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { compareClaudeCodeInstall } from "../src/util/claudeCodeVersion";
+
+describe("compareClaudeCodeInstall", () => {
+  it("orders Claude Code installs by semver, not lexicographic text", () => {
+    const paths = [
+      "C:/x/anthropic.claude-code-2.1.99-win32-x64/webview/index.js",
+      "C:/x/anthropic.claude-code-2.1.162-win32-x64/webview/index.js",
+      "C:/x/anthropic.claude-code-2.1.9-win32-x64/webview/index.js",
+    ].sort(compareClaudeCodeInstall);
+
+    expect(paths.map((p) => /claude-code-([0-9.]+)/.exec(p)?.[1]))
+      .toEqual(["2.1.9", "2.1.99", "2.1.162"]);
+  });
+
+  it("keeps versioned installs after unexpected names", () => {
+    const paths = [
+      "anthropic.claude-code-preview/webview/index.js",
+      "anthropic.claude-code-2.1.162-win32-x64/webview/index.js",
+    ].sort(compareClaudeCodeInstall);
+
+    expect(paths[1]).toContain("2.1.162");
+  });
+});
diff --git a/test/cli-statusline.test.ts b/test/cli-statusline.test.ts
new file mode 100644
index 0000000..ddb2d18
--- /dev/null
+++ b/test/cli-statusline.test.ts
@@ -0,0 +1,796 @@
+import { describe, it, expect } from "vitest";
+import { ClaudeCliStatuslineAdapter }
+  from "../src/adapters/claude-cli/adapter";
+import { upsertStatusLine, upsertSpinnerVerbs, parseable, readTopLevel }
+  from "../src/adapters/claude-cli/settingsEdit";
+import { parseClaudeCliVersion, supportsSpinnerVerbs, gte, SPINNER_VERBS_FLOOR }
+  from "../src/adapters/claude-cli/cliVersion";
+import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, utimesSync,
+         existsSync, rmSync }
+  from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { writeCliAdCache, readCliAdCache, cliSessionActive, FRESH_MS,
+         shouldCountCliImpression, shouldCountSpinnerImpression }
+  from "../src/adapters/claude-cli/cliAd";
+import { execFileSync } from "node:child_process";
+
+const VAL = '{ "type": "command", "command": "node x", "padding": 0 }';
+
+describe("settingsEdit.parseable", () => {
+  it("true for plain JSON", () => {
+    expect(parseable('{"a":1}')).toBe(true);
+  });
+  it("true for JSONC (line+block comments, trailing comma)", () => {
+    expect(parseable('{\n // c\n "a":1, /* b */ "z":2,\n}')).toBe(true);
+  });
+  it("false for genuinely broken text", () => {
+    expect(parseable('{ this is not json ')).toBe(false);
+  });
+});
+
+describe("settingsEdit.upsertStatusLine", () => {
+  it("inserts statusLine when absent, preserving other keys/format", () => {
+    const src = '{\n  "model": "opus",\n  "theme": "dark"\n}\n';
+    const out = upsertStatusLine(src, VAL);
+    expect(out).toContain('"statusLine": ' + VAL);
+    expect(out).toContain('"model": "opus"');
+    expect(out).toContain('"theme": "dark"');
+    expect(parseable(out)).toBe(true);
+  });
+
+  it("replaces ONLY the existing statusLine value, keeping comments intact", () => {
+    const src =
+      '{\n  // keep me\n  "statusLine": { "type":"command", "command":"old" },\n'
+      + '  "model": "opus" /* and me */\n}\n';
+    const out = upsertStatusLine(src, VAL);
+    expect(out).toContain('"statusLine": ' + VAL);
+    expect(out).not.toContain('"command":"old"');
+    expect(out).toContain("// keep me");
+    expect(out).toContain("/* and me */");
+    expect(out).toContain('"model": "opus"');
+    expect(parseable(out)).toBe(true);
+  });
+
+  it("idempotent: re-upsert with same value is a no-op", () => {
+    const src = '{ "model": "x" }\n';
+    const a = upsertStatusLine(src, VAL);
+    const b = upsertStatusLine(a, VAL);
+    expect(b).toBe(a);
+  });
+
+  it("throws on unparseable input (caller treats as ok:false)", () => {
+    expect(() => upsertStatusLine("{ broken ", VAL)).toThrow();
+  });
+});
+
+const SV = '{"mode":"replace","verbs":["Acme - Try Acme.com"]}';
+
+describe("settingsEdit.upsertSpinnerVerbs", () => {
+  it("inserts spinnerVerbs when absent, preserving other keys/format", () => {
+    const src = '{\n  "model": "opus",\n  "theme": "dark"\n}\n';
+    const out = upsertSpinnerVerbs(src, SV);
+    expect(out).toContain('"spinnerVerbs": ' + SV);
+    expect(out).toContain('"model": "opus"');
+    expect(out).toContain('"theme": "dark"');
+    expect(parseable(out)).toBe(true);
+  });
+
+  it("replaces ONLY the existing spinnerVerbs value, keeping comments intact", () => {
+    const src =
+      '{\n  // keep me\n  "spinnerVerbs": { "mode":"append", "verbs":["Old"] },\n'
+      + '  "model": "opus" /* and me */\n}\n';
+    const out = upsertSpinnerVerbs(src, SV);
+    expect(out).toContain('"spinnerVerbs": ' + SV);
+    expect(out).not.toContain('"Old"');
+    expect(out).toContain("// keep me");
+    expect(out).toContain("/* and me */");
+    expect(out).toContain('"model": "opus"');
+    expect(parseable(out)).toBe(true);
+  });
+
+  it("idempotent: re-upsert with same value is a no-op", () => {
+    const src = '{ "model": "x" }\n';
+    const a = upsertSpinnerVerbs(src, SV);
+    const b = upsertSpinnerVerbs(a, SV);
+    expect(b).toBe(a);
+  });
+
+  it("composes cleanly with upsertStatusLine (both keys land, both update)", () => {
+    const src = '{\n  "model": "opus"\n}\n';
+    let out = upsertStatusLine(src, VAL);
+    out = upsertSpinnerVerbs(out, SV);
+    expect(out).toContain('"statusLine": ' + VAL);
+    expect(out).toContain('"spinnerVerbs": ' + SV);
+    expect(out).toContain('"model": "opus"');
+    expect(parseable(out)).toBe(true);
+    // Replacing only spinnerVerbs leaves the statusLine untouched (no
+    // accidental clobber across the two top-level upserts).
+    const SV2 = '{"mode":"replace","verbs":["NewAd"]}';
+    const out2 = upsertSpinnerVerbs(out, SV2);
+    expect(out2).toContain('"statusLine": ' + VAL);
+    expect(out2).toContain('"spinnerVerbs": ' + SV2);
+    expect(out2).not.toContain('"Acme - Try Acme.com"');
+  });
+});
+
+describe("settingsEdit.readTopLevel", () => {
+  it("returns the parsed value of a top-level key (JSONC tolerated)", () => {
+    const src = '{\n  // hud\n  "statusLine": { "type": "command",'
+      + ' "command": "node hud.js", "padding": 0 },\n}\n';
+    expect(readTopLevel(src, "statusLine"))
+      .toEqual({ type: "command", command: "node hud.js", padding: 0 });
+  });
+  it("returns undefined when the key is absent", () => {
+    expect(readTopLevel('{ "model": "x" }', "statusLine")).toBeUndefined();
+  });
+  it("returns undefined for unparseable text (never throws)", () => {
+    expect(readTopLevel("{ broken ", "statusLine")).toBeUndefined();
+  });
+});
+
+function tmp(): string { return mkdtempSync(join(tmpdir(), "vibe-cli-")); }
+
+describe("cliAd cache", () => {
+  it("writeCliAdCache then readCliAdCache round-trips with a ts", () => {
+    const home = tmp();
+    writeCliAdCache(home, { adText: "Acme", iconRef: "i", iconUrl: "", clickUrl: "https://a/x" });
+    const c = readCliAdCache(home);
+    expect(c?.adText).toBe("Acme");
+    expect(c?.clickUrl).toBe("https://a/x");
+    expect(typeof c?.ts).toBe("number");
+  });
+  it("readCliAdCache returns null when absent", () => {
+    expect(readCliAdCache(tmp())).toBeNull();
+  });
+  it("writeCliAdCache strips control chars (ESC/OSC/BEL/CSI) from every persisted field", () => {
+    const home = tmp();
+    const ESC = "\u001b", BEL = "\u0007", CSI = "\u009b";
+    writeCliAdCache(home, {
+      adText: "Acme" + ESC + "]8;;https://evil" + BEL + " deploys" + CSI + "31m",
+      iconRef: "i" + ESC, iconUrl: "u" + BEL,
+      clickUrl: "https://a/x" + ESC + "]8;;" });
+    const c = readCliAdCache(home);
+    expect(c?.adText).toBe("Acme]8;;https://evil deploys31m");
+    expect(c?.iconRef).toBe("i");
+    expect(c?.iconUrl).toBe("u");
+    expect(c?.clickUrl).toBe("https://a/x]8;;");
+  });
+  it("writeCliAdCache is PERMISSIVE: emoji/pipes/unicode/URLs round-trip byte-identical", () => {
+    const home = tmp();
+    const line = "Déployez 🚀 | ai.dev — détails: https://a/?q=1&r=2";
+    writeCliAdCache(home, { adText: line, iconRef: "i", iconUrl: "",
+      clickUrl: "https://a/x" });
+    expect(readCliAdCache(home)?.adText).toBe(line);
+  });
+});
+
+// Transcript line tagged with a CC `entrypoint` ("cli" = terminal session,
+// "claude-vscode" = the VS Code panel's own; untagged = older CC builds).
+const taggedRec = (entrypoint: string): string =>
+  JSON.stringify({ type: "user",
+    message: { role: "user", content: "hi" }, entrypoint }) + "\n";
+
+describe("cliSessionActive", () => {
+  it("true when a recent transcript exists in <projectsRoot>", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    const f = join(proj, "s.jsonl"); writeFileSync(f, "{}");
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(true);
+  });
+  it("false when newest transcript is older than the window", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    const f = join(proj, "s.jsonl"); writeFileSync(f, "{}");
+    const old = (Date.now() - FRESH_MS - 60_000) / 1000;
+    utimesSync(f, old, old);
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
+  });
+  it("false when projects root is absent", () => {
+    expect(cliSessionActive(Date.now(), FRESH_MS, join(tmp(), "nope"))).toBe(false);
+  });
+  // Audit #30: the VS Code panel writes the SAME tree (entrypoint
+  // "claude-vscode") — editor turns must not count as a live CLI session,
+  // else statusline/spinner impressions are recorded for an unrendered surface.
+  it("false when the only recent transcript is the VS Code panel's own", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    writeFileSync(join(proj, "s.jsonl"), taggedRec("claude-vscode"));
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
+  });
+  it("true for a recent genuine terminal-CLI transcript (entrypoint cli)", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    writeFileSync(join(proj, "s.jsonl"), taggedRec("cli"));
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(true);
+  });
+  it("true when a recent CLI transcript coexists with a recent vscode one", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    writeFileSync(join(proj, "vscode.jsonl"), taggedRec("claude-vscode"));
+    writeFileSync(join(proj, "cli.jsonl"), taggedRec("cli"));
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(true);
+  });
+  it("false when the CLI transcript is stale and only the vscode one is recent", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    writeFileSync(join(proj, "vscode.jsonl"), taggedRec("claude-vscode"));
+    const cli = join(proj, "cli.jsonl"); writeFileSync(cli, taggedRec("cli"));
+    const old = (Date.now() - FRESH_MS - 60_000) / 1000;
+    utimesSync(cli, old, old);
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
+  });
+  // 2026-06-11: headless SDK / desktop-agent sessions write the same tree
+  // with their own entrypoint tags. They render no statusline — counting
+  // them emitted impressions for an unseen surface while the tick loop
+  // (strictly cli-or-untagged) stayed silent. Both signals now accept the
+  // same set: "cli" | untagged.
+  it("false when the only recent transcripts carry other positive tags (sdk/desktop)", () => {
+    const root = tmp();
+    const proj = join(root, "p1"); mkdirSync(proj, { recursive: true });
+    writeFileSync(join(proj, "sdk.jsonl"), taggedRec("sdk-ts"));
+    writeFileSync(join(proj, "desktop.jsonl"), taggedRec("desktop"));
+    expect(cliSessionActive(Date.now(), FRESH_MS, root)).toBe(false);
+  });
+});
+
+function renderScript(cachePath: string, freshMs: number,
+                      prevPath = join(tmpdir(), "vibe-prev-absent.json"),
+                      chainTimeoutMs = 5000): string {
+  const tpl = readFileSync(join(__dirname,
+    "../src/adapters/claude-cli/statusline.asset.mjs"), "utf8");
+  return tpl
+    .split("__VIBE_ADS_CLI_AD_PATH__").join(JSON.stringify(cachePath))
+    .split("__VIBE_ADS_CLI_PREV_PATH__").join(JSON.stringify(prevPath))
+    .split("__VIBE_ADS_FRESH_MS__").join(String(freshMs))
+    .split("__VIBE_ADS_SCRIPT_NAME__")
+    .join(JSON.stringify("vibe-ads-statusline.mjs"))
+    .split("__VIBE_ADS_CHAIN_TIMEOUT_MS__").join(String(chainTimeoutMs));
+}
+function runScript(body: string, input?: string): string {
+  const d = tmp();
+  const p = join(d, "s.mjs");
+  writeFileSync(p, body, "utf8");
+  return execFileSync(process.execPath, [p],
+    input === undefined ? { encoding: "utf8" } : { encoding: "utf8", input });
+}
+
+describe("statusline.asset script", () => {
+  it("prints a real OSC 8 hyperlink (ESC bytes) + marker + adText for a fresh cache", () => {
+    const d = tmp();
+    const cache = join(d, "cli-ad.json");
+    writeFileSync(cache, JSON.stringify({ adText: "Acme deploys",
+      iconRef: "i", iconUrl: "", clickUrl: "https://acme/x", ts: Date.now() }));
+    const out = runScript(renderScript(cache, FRESH_MS));
+    const ESC = "";
+    expect(out).toContain("ad· Acme deploys");                       // visible text + marker
+    expect(out).toContain(ESC + "]8;;https://acme/x" + ESC + "\\");  // hyperlink open + ST
+    expect(out).toContain(ESC + "]8;;" + ESC + "\\");                // hyperlink close + ST
+    expect(out.startsWith(ESC + "]8;;")).toBe(true);                 // begins with the escape
+  })
+  it("prints nothing for a stale cache", () => {
+    const d = tmp();
+    const cache = join(d, "cli-ad.json");
+    writeFileSync(cache, JSON.stringify({ adText: "Old", iconRef: "i",
+      clickUrl: "https://a/x", ts: Date.now() - FRESH_MS - 1000 }));
+    expect(runScript(renderScript(cache, FRESH_MS)).trim()).toBe("");
+  });
+  it("prints nothing when cache missing", () => {
+    expect(runScript(renderScript(join(tmp(), "nope.json"),
+      FRESH_MS)).trim()).toBe("");
+  });
+  it("prints nothing (exit 0) for malformed cache", () => {
+    const d = tmp();
+    const cache = join(d, "cli-ad.json");
+    writeFileSync(cache, "{ not json");
+    expect(runScript(renderScript(cache, FRESH_MS)).trim()).toBe("");
+  });
+  it("strips control bytes from adText/clickUrl before the OSC 8 wrap (defense-in-depth vs tampered cache)", () => {
+    const d = tmp();
+    const cache = join(d, "cli-ad.json");
+    const ESC = "\u001b", BEL = "\u0007";
+    // Bypass writeCliAdCache: simulate a tampered/legacy cache carrying raw
+    // escape bytes in BOTH fields (write boundary defeated).
+    writeFileSync(cache, JSON.stringify({
+      adText: "Acme" + ESC + "]8;;https://evil" + ESC + "\\spoof" + BEL,
+      iconRef: "i", iconUrl: "",
+      clickUrl: "https://acme/x" + ESC + "]8;;", ts: Date.now() }));
+    const out = runScript(renderScript(cache, FRESH_MS));
+    // The ONLY escapes emitted are the script's own 4 OSC 8 framing bytes…
+    expect(out.split(ESC).length - 1).toBe(4);
+    // …the injected sequences survive only as inert de-escaped text.
+    expect(out).toContain("ad· Acme]8;;https://evil\\spoof");
+    expect(out).toContain(ESC + "]8;;https://acme/x]8;;" + ESC + "\\");
+  });
+  it("passes emoji / pipes / unicode through byte-identical (strip is control-chars ONLY)", () => {
+    const d = tmp();
+    const cache = join(d, "cli-ad.json");
+    const line = "Déployez 🚀 | ai.dev — vite";
+    writeFileSync(cache, JSON.stringify({ adText: line, iconRef: "i",
+      iconUrl: "", clickUrl: "https://acme/x", ts: Date.now() }));
+    const out = runScript(renderScript(cache, FRESH_MS));
+    expect(out).toContain("ad· " + line);
+  });
+});
+
+function prevFileWith(cmd: string): string {
+  const p = join(tmp(), "cli-prev-statusline.json");
+  writeFileSync(p, JSON.stringify(
+    { statusLine: { type: "command", command: cmd, padding: 0 } }));
+  return p;
+}
+function freshCache(): string {
+  const cache = join(tmp(), "cli-ad.json");
+  writeFileSync(cache, JSON.stringify({ adText: "Acme deploys", iconRef: "i",
+    iconUrl: "", clickUrl: "https://acme/x", ts: Date.now() }));
+  return cache;
+}
+
+describe("statusline.asset script — chained previous statusLine", () => {
+  it("prints the ad line ABOVE the chained command's output", () => {
+    const prev = prevFileWith("echo HUD-LINE");
+    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), "");
+    const nl = out.indexOf("\n");
+    expect(nl).toBeGreaterThan(0);
+    expect(out.slice(0, nl)).toContain("ad· Acme deploys");
+    expect(out.slice(nl + 1)).toBe("HUD-LINE");
+  });
+  it("prints ONLY the chained output when the ad cache is stale or missing", () => {
+    const prev = prevFileWith("echo HUD-LINE");
+    const out = runScript(
+      renderScript(join(tmp(), "nope.json"), FRESH_MS, prev), "");
+    expect(out).toBe("HUD-LINE");
+  });
+  it("forwards stdin to the chained command (CC pipes the session JSON)", () => {
+    const prev = prevFileWith(
+      `node -e "process.stdout.write(require('fs').readFileSync(0,'utf8'))"`);
+    const json = JSON.stringify({ model: { display_name: "Opus" } });
+    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), json);
+    expect(out.slice(out.indexOf("\n") + 1)).toBe(json);
+  });
+  it("ad-only when the prev file is malformed (never breaks the CLI)", () => {
+    const p = join(tmp(), "cli-prev-statusline.json");
+    writeFileSync(p, "{ not json");
+    const out = runScript(renderScript(freshCache(), FRESH_MS, p), "");
+    expect(out).toContain("ad· Acme deploys");
+    expect(out).not.toContain("\n");
+  });
+  it("ad-only when the captured command points at ourselves (self-spawn guard)", () => {
+    const prev = prevFileWith("node /x/vibe-ads-statusline.mjs");
+    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), "");
+    expect(out).toContain("ad· Acme deploys");
+    expect(out).not.toContain("\n");
+  });
+  it("ad-only when the chained command exits nonzero with no output", () => {
+    const prev = prevFileWith(`node -e "process.exit(3)"`);
+    const out = runScript(renderScript(freshCache(), FRESH_MS, prev), "");
+    expect(out).toContain("ad· Acme deploys");
+    expect(out).not.toContain("\n");
+  });
+  it("exits with ad-only when the chained command outlives the deadline " +
+     "(wedged HUD must not hang CC)", () => {
+    // Chained command sleeps far past the 400ms deadline; the script must
+    // exit at the deadline with the ad line, not wait for the child.
+    const prev = prevFileWith(`node -e "setTimeout(()=>{}, 30000)"`);
+    const t0 = Date.now();
+    const out = runScript(
+      renderScript(freshCache(), FRESH_MS, prev, 400), "");
+    expect(Date.now() - t0).toBeLessThan(10_000);
+    expect(out).toContain("ad· Acme deploys");
+    expect(out).not.toContain("\n");
+  });
+
+  it("exits promptly when a GRANDCHILD squats on the stdout pipe after the " +
+     "shell exits (spawnSync-hang regression guard)", () => {
+    // Shell spawns a detached long-lived grandchild inheriting stdio, prints
+    // one line, and exits. 'close' never fires while the grandchild holds
+    // the pipe; the exit-drain grace must still flush the HUD line and exit.
+    const grand = "const cp=require('node:child_process');"
+      + "cp.spawn(process.execPath,['-e','setTimeout(()=>{},30000)'],"
+      + "{detached:true,stdio:'inherit'}).unref();"
+      + "console.log('HUD-LINE');";
+    const prev = prevFileWith(
+      `node -e ${JSON.stringify(grand)}`);
+    const t0 = Date.now();
+    const out = runScript(
+      renderScript(freshCache(), FRESH_MS, prev, 5000), "");
+    expect(Date.now() - t0).toBeLessThan(4_000);
+    expect(out.split("\n")[1]).toBe("HUD-LINE");
+  });
+
+  it("prints nothing when there is no ad AND no captured command", () => {
+    const out = runScript(
+      renderScript(join(tmp(), "no-cache.json"), FRESH_MS), "");
+    expect(out).toBe("");
+  });
+});
+
+const P = { tier: 0 as const, adText: "Acme", iconRef: "i", iconUrl: "",
+  clickToken: "", clickUrl: "https://acme/x", corr: "cli.abc", loopbackPort: 0,
+  loopbackToken: "", loopbackBase: "" };
+
+function homeWithClaude(): { home: string; settings: string } {
+  const home = tmp();
+  mkdirSync(join(home, ".claude"), { recursive: true });
+  const settings = join(home, ".claude", "settings.json");
+  return { home, settings };
+}
+function parseableFile(p: string): boolean {
+  try { JSON.parse(readFileSync(p, "utf8")); return true; } catch { return false; }
+}
+
+describe("ClaudeCliStatuslineAdapter", () => {
+  it("preflight: compatible for parseable, absent, JSONC; incompatible for broken", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, '{ "model":"x" }');
+    expect(new ClaudeCliStatuslineAdapter(settings).preflight().compatible).toBe(true);
+    rmSync(settings);
+    expect(new ClaudeCliStatuslineAdapter(settings).preflight().compatible).toBe(true);
+    writeFileSync(settings, '{ /* c */ "a":1, }');
+    expect(new ClaudeCliStatuslineAdapter(settings).preflight().compatible).toBe(true);
+    writeFileSync(settings, '{ broken ');
+    const pf = new ClaudeCliStatuslineAdapter(settings).preflight();
+    expect(pf.compatible).toBe(false);
+    expect(pf.reason).toMatch(/not parseable/);
+  });
+
+  it("applyPatch installs statusLine + script, backs up pristine, idempotent", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);
+    const s = readFileSync(settings, "utf8");
+    expect(s).toContain('"statusLine"');
+    expect(s).toContain('"model": "opus"');
+    expect(existsSync(settings + ".vibe-ads-backup")).toBe(true);
+    expect(readFileSync(settings + ".vibe-ads-backup", "utf8"))
+      .toBe('{\n  "model": "opus"\n}\n');
+    expect(existsSync(join(home, ".vibe-ads",
+      "vibe-ads-statusline.mjs"))).toBe(true);
+    const after1 = readFileSync(settings, "utf8");
+    a.applyPatch(P);
+    expect(readFileSync(settings, "utf8")).toBe(after1);
+  });
+
+  it("applyPatch installs BOTH statusLine AND spinnerVerbs when supported", () => {
+    // The CLI adapter writes two surfaces: statusLine (OSC 8 clickable
+    // hyperlink) and spinnerVerbs (the ad text in the thinking-shimmer verb
+    // slot). spinnerVerbs is gated on `spinnerVerbsSupported`, which defaults
+    // to true (fail-open) until async version detection resolves.
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.spinnerVerbsSupported).toBe(true);
+    expect(a.applyPatch(P).ok).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.spinnerVerbs).toBeDefined();
+    expect(parsed.spinnerVerbs.mode).toBe("replace");
+    expect(parsed.spinnerVerbs.verbs).toEqual(["Acme"]);
+    expect(parsed.statusLine?.type).toBe("command");
+    expect(parsed.model).toBe("opus");
+  });
+
+  it("applyPatch REPLACES any pre-existing spinnerVerbs with current ad", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings,
+      '{\n  "spinnerVerbs": { "mode": "replace", "verbs": ["StaleAd"] }\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.spinnerVerbs.mode).toBe("replace");
+    expect(parsed.spinnerVerbs.verbs).toEqual(["Acme"]);
+    expect(parsed.spinnerVerbs.verbs).not.toContain("StaleAd");
+    expect(parsed.statusLine?.type).toBe("command");
+  });
+
+  it("applyPatch EVICTS spinnerVerbs when the CLI does not support it", () => {
+    // On a pre-2.1.143 CLI (spinnerVerbsSupported=false) the key is a dead
+    // no-op, so we remove it (and heal any stale entry) — statusLine only.
+    const { settings } = homeWithClaude();
+    writeFileSync(settings,
+      '{\n  "spinnerVerbs": { "mode": "replace", "verbs": ["StaleAd"] }\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.spinnerVerbsSupported = false;
+    expect(a.applyPatch(P).ok).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.spinnerVerbs).toBeUndefined();
+    expect(parsed.statusLine?.type).toBe("command");
+  });
+
+  it("applyPatch refuses to touch an unparseable settings.json", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, "{ broken ");
+    const r = new ClaudeCliStatuslineAdapter(settings).applyPatch(P);
+    expect(r.ok).toBe(false);
+    expect(r.reason).toMatch(/not parseable/);
+    expect(readFileSync(settings, "utf8")).toBe("{ broken ");
+    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("creates settings.json + ~/.claude when absent; restore deletes it", () => {
+    const home = tmp();
+    const settings = join(home, ".claude", "settings.json");
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);
+    expect(existsSync(settings)).toBe(true);
+    expect(parseableFile(settings)).toBe(true);
+    const r = a.restore();
+    expect(r.restored).toBe(true);
+    expect(existsSync(settings)).toBe(false);
+    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("restore reverts an existing file byte-exact and removes script", () => {
+    const { home, settings } = homeWithClaude();
+    const pristine = '{\n  "model": "opus"\n}\n';
+    writeFileSync(settings, pristine);
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    const patched = readFileSync(settings, "utf8");
+    expect(patched).toContain('"statusLine"');
+    expect(patched).toContain('"spinnerVerbs"');
+    expect(a.restore().restored).toBe(true);
+    expect(readFileSync(settings, "utf8")).toBe(pristine);
+    expect(existsSync(join(home, ".vibe-ads",
+      "vibe-ads-statusline.mjs"))).toBe(false);
+    expect(existsSync(settings + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("restore is a no-op when no backup present", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, "{}");
+    const r = new ClaudeCliStatuslineAdapter(settings).restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(false);
+    expect(r.reason).toMatch(/no backup/);
+  });
+
+  it("spinnerVerbs value reflects the PatchParams adText", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    const customP = { ...P, adText: "Try Ramp.com — Free for 30 days" };
+    expect(a.applyPatch(customP).ok).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.spinnerVerbs.mode).toBe("replace");
+    expect(parsed.spinnerVerbs.verbs).toEqual(["Try Ramp.com — Free for 30 days"]);
+  });
+
+  it("applyPatch is idempotent: second call with same ad does not re-write", () => {
+    const { settings } = homeWithClaude();
+    writeFileSync(settings, '{\n  "model": "opus"\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    const after1 = readFileSync(settings, "utf8");
+    expect(after1).toContain('"spinnerVerbs"');
+    a.applyPatch(P);
+    expect(readFileSync(settings, "utf8")).toBe(after1);
+  });
+});
+
+const HUD = '{ "type": "command", "command": "node /x/hud.js", "padding": 1 }';
+
+describe("ClaudeCliStatuslineAdapter chain-capture", () => {
+  const prevFile = (home: string): string =>
+    join(home, ".vibe-ads", "cli-prev-statusline.json");
+
+  it("applyPatch captures a pre-existing foreign statusLine", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings,
+      '{\n  "model": "opus",\n  "statusLine": ' + HUD + '\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    expect(a.applyPatch(P).ok).toBe(true);
+    const prev = JSON.parse(readFileSync(prevFile(home), "utf8"));
+    expect(prev.statusLine)
+      .toEqual({ type: "command", command: "node /x/hud.js", padding: 1 });
+    // The live slot now holds OUR command…
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.statusLine.command).toContain("vibe-ads-statusline.mjs");
+    // …and the installed script points at the capture file.
+    const script = readFileSync(join(home, ".vibe-ads",
+      "vibe-ads-statusline.mjs"), "utf8");
+    expect(script).toContain("cli-prev-statusline.json");
+  });
+
+  it("re-apply over our own slot leaves the capture untouched (60s tick)", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    a.applyPatch(P);
+    const prev = JSON.parse(readFileSync(prevFile(home), "utf8"));
+    expect(prev.statusLine.command).toBe("node /x/hud.js");
+  });
+
+  it("writes no capture file when there was no pre-existing statusLine", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, '{ "model": "opus" }');
+    new ClaudeCliStatuslineAdapter(settings).applyPatch(P);
+    expect(existsSync(prevFile(home))).toBe(false);
+  });
+
+  it("restore puts the captured statusLine BACK instead of dropping the key", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings,
+      '{\n  "model": "opus",\n  "statusLine": ' + HUD + '\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    expect(a.restore().restored).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.statusLine)
+      .toEqual({ type: "command", command: "node /x/hud.js", padding: 1 });
+    expect(parsed.model).toBe("opus");
+    expect(parsed.spinnerVerbs).toBeUndefined();
+    expect(existsSync(prevFile(home))).toBe(false);
+  });
+
+  it("KEEPS the capture when the statusLine key vanishes (deleting the ad " +
+     "entry must not forget the HUD)", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    expect(existsSync(prevFile(home))).toBe(true);
+    // While installed the slot holds OUR command — a deleted key means the
+    // user removed the AD entry, not their own HUD.
+    writeFileSync(settings, '{ "model": "opus" }');
+    a.applyPatch(P);
+    expect(existsSync(prevFile(home))).toBe(true);
+    // …so restore still puts their HUD back.
+    expect(a.restore().restored).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.statusLine.command).toBe("node /x/hud.js");
+  });
+
+  it("keeps the capture when settings.json is transiently ABSENT at a tick", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    rmSync(settings);                      // mid-rewrite / sync-tool window
+    a.applyPatch(P);
+    expect(existsSync(prevFile(home))).toBe(true);
+    const prev = JSON.parse(readFileSync(prevFile(home), "utf8"));
+    expect(prev.statusLine.command).toBe("node /x/hud.js");
+  });
+
+  it("restore falls back to the first-apply snapshot when the capture file " +
+     "is missing", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings,
+      '{\n  "model": "opus",\n  "statusLine": ' + HUD + '\n}\n');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    rmSync(prevFile(home));                // cleared ~/.vibe-ads, AV tooling…
+    expect(a.restore().restored).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.statusLine)
+      .toEqual({ type: "command", command: "node /x/hud.js", padding: 1 });
+  });
+
+  it("restore leaves a FOREIGN statusLine untouched (user hand-installed a " +
+     "newer one after capture)", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings, '{ "statusLine": ' + HUD + ' }');
+    const a = new ClaudeCliStatuslineAdapter(settings);
+    a.applyPatch(P);
+    // User replaces our entry with HUD-B before any re-capture tick runs.
+    const hudB = '{ "type": "command", "command": "node /x/hud-b.js" }';
+    writeFileSync(settings, '{ "statusLine": ' + hudB + ' }');
+    expect(a.restore().restored).toBe(true);
+    const parsed = JSON.parse(readFileSync(settings, "utf8"));
+    expect(parsed.statusLine.command).toBe("node /x/hud-b.js");
+    expect(existsSync(prevFile(home))).toBe(false);   // capture still cleaned
+  });
+
+  it("END-TO-END: the installed script stacks the ad above the captured HUD", () => {
+    const { home, settings } = homeWithClaude();
+    writeFileSync(settings,
+      '{ "statusLine": { "type": "command", "command": "echo HUD-LINE" } }');
+    new ClaudeCliStatuslineAdapter(settings).applyPatch(P);
+    writeCliAdCache(home, { adText: "Acme deploys", iconRef: "i",
+      iconUrl: "", clickUrl: "https://acme/x" });
+    const out = execFileSync(process.execPath,
+      [join(home, ".vibe-ads", "vibe-ads-statusline.mjs")],
+      { encoding: "utf8", input: "{}" });
+    const nl = out.indexOf("\n");
+    expect(out.slice(0, nl)).toContain("ad· Acme deploys");
+    expect(out.slice(nl + 1)).toBe("HUD-LINE");
+  });
+});
+
+describe("shouldCountCliImpression", () => {
+  const base = { signedIn: true, haveAd: true, sessionActive: true,
+                 adId: "ad1", lastCountedAdId: null as string | null };
+  it("true when signed-in + ad + active session + new adId", () => {
+    expect(shouldCountCliImpression(base)).toBe(true);
+  });
+  it("false when not signed in", () => {
+    expect(shouldCountCliImpression({ ...base, signedIn: false })).toBe(false);
+  });
+  it("false when no active CLI session", () => {
+    expect(shouldCountCliImpression({ ...base, sessionActive: false })).toBe(false);
+  });
+  it("false when this adId was already counted", () => {
+    expect(shouldCountCliImpression({ ...base, lastCountedAdId: "ad1" }))
+      .toBe(false);
+  });
+  it("true again for a different adId", () => {
+    expect(shouldCountCliImpression({ ...base, lastCountedAdId: "ad0" }))
+      .toBe(true);
+  });
+});
+
+describe("shouldCountSpinnerImpression", () => {
+  const base = { supportConfirmed: true, signedIn: true, haveAd: true,
+                 sessionActive: true, adId: "ad1",
+                 lastCountedAdId: null as string | null };
+  it("true when support confirmed + signed-in + ad + active + new adId", () => {
+    expect(shouldCountSpinnerImpression(base)).toBe(true);
+  });
+  it("false when support is NOT confirmed, even if all else is satisfied", () => {
+    // The over-count guard: the first synchronous activation sync runs before
+    // `claude --version` resolves, so spinner billing must stay off until
+    // support is positively confirmed — never bill for an unrendered verb.
+    expect(shouldCountSpinnerImpression({ ...base, supportConfirmed: false }))
+      .toBe(false);
+  });
+  it("false when not signed in", () => {
+    expect(shouldCountSpinnerImpression({ ...base, signedIn: false }))
+      .toBe(false);
+  });
+  it("false when no active CLI session", () => {
+    expect(shouldCountSpinnerImpression({ ...base, sessionActive: false }))
+      .toBe(false);
+  });
+  it("false when this adId was already counted (dedup)", () => {
+    expect(shouldCountSpinnerImpression({ ...base, lastCountedAdId: "ad1" }))
+      .toBe(false);
+  });
+  it("true again for a different adId", () => {
+    expect(shouldCountSpinnerImpression({ ...base, lastCountedAdId: "ad0" }))
+      .toBe(true);
+  });
+  it("is independent of the statusline counter (separate dedup keys)", () => {
+    // statusline already counted ad1; spinner counter still null → spinner
+    // should still fire for ad1 (two distinct surfaces, two counters).
+    expect(shouldCountCliImpression({ signedIn: true, haveAd: true,
+      sessionActive: true, adId: "ad1", lastCountedAdId: "ad1" })).toBe(false);
+    expect(shouldCountSpinnerImpression({ ...base, lastCountedAdId: null }))
+      .toBe(true);
+  });
+});
+
+describe("cliVersion.parseClaudeCliVersion", () => {
+  it("parses a real `claude --version` line", () => {
+    expect(parseClaudeCliVersion("2.1.158 (Claude Code)")).toEqual([2, 1, 158]);
+  });
+  it("parses a bare semver", () => {
+    expect(parseClaudeCliVersion("2.1.143")).toEqual([2, 1, 143]);
+  });
+  it("returns null when no semver is present", () => {
+    expect(parseClaudeCliVersion("Claude Code")).toBeNull();
+    expect(parseClaudeCliVersion("")).toBeNull();
+  });
+});
+
+describe("cliVersion.gte / supportsSpinnerVerbs", () => {
+  it("floor is 2.1.143", () => {
+    expect(SPINNER_VERBS_FLOOR).toEqual([2, 1, 143]);
+  });
+  it("gte orders by major, minor, patch", () => {
+    expect(gte([2, 1, 143], [2, 1, 143])).toBe(true);   // equal
+    expect(gte([2, 1, 158], [2, 1, 143])).toBe(true);   // patch higher
+    expect(gte([2, 2, 0], [2, 1, 143])).toBe(true);     // minor higher
+    expect(gte([3, 0, 0], [2, 1, 143])).toBe(true);     // major higher
+    expect(gte([2, 1, 142], [2, 1, 143])).toBe(false);  // patch lower
+    expect(gte([2, 0, 999], [2, 1, 143])).toBe(false);  // minor lower
+  });
+  it("supportsSpinnerVerbs gates on the floor", () => {
+    expect(supportsSpinnerVerbs([2, 1, 158])).toBe(true);   // Andrew's CLI
+    expect(supportsSpinnerVerbs([2, 1, 143])).toBe(true);   // exact floor
+    expect(supportsSpinnerVerbs([2, 1, 142])).toBe(false);  // just below
+    expect(supportsSpinnerVerbs([2, 0, 44])).toBe(false);   // 2.0.x
+    expect(supportsSpinnerVerbs(null)).toBe(false);         // unparseable
+  });
+});
diff --git a/test/cliTick.test.ts b/test/cliTick.test.ts
new file mode 100644
index 0000000..fb54ae8
--- /dev/null
+++ b/test/cliTick.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { setupCliTick, type CliTickDeps } from "../src/activation/cliTick";
+import type { Activity } from "../src/activity/logTail";
+
+// The statusline view-tick loop (the Steven fix): TUI surfaces used to emit
+// impression_viewable only — no view_tick, no credit, no advertiser debit —
+// so a terminal-only user saw ads all day and earned nothing. These tests pin
+// the dwell loop's gates and cadence.
+
+function makeDeps(overrides: Partial<CliTickDeps> = {}): CliTickDeps & {
+  metrics: { send: ReturnType<typeof vi.fn> };
+  cliTail: {
+    current: ReturnType<typeof vi.fn>;
+    activityAgeMs: ReturnType<typeof vi.fn>;
+  };
+} {
+  const metrics = { send: vi.fn() };
+  const cliTail = {
+    current: vi.fn().mockReturnValue(null),
+    activityAgeMs: vi.fn().mockReturnValue(null),
+  };
+  return {
+    cliTail: cliTail as any,
+    metrics: metrics as any,
+    adRef: { current: { adId: "ad1", campaignId: "c1",
+      adText: "Try Acme Widgets", iconRef: "", iconUrl: "",
+      clickUrl: "https://acme.com", bannerEnabled: false,
+      sessionToken: "tok1" } },
+    killedRef: { current: false },
+    signedIn: () => true,
+    surfaceApplied: () => true,
+    ccVersion: "2.1.143",
+    timers: [],
+    cliModeFn: () => "on",
+    canPatchFn: () => true,
+    ...overrides,
+  } as any;
+}
+
+function turn(): Activity {
+  return { tool: "Edit", elapsedMs: 1000, ts: Date.now(), done: false };
+}
+function turnDone(): Activity {
+  return { tool: "Edit", elapsedMs: 5000, ts: Date.now(), done: true };
+}
+
+describe("setupCliTick", () => {
+  beforeEach(() => { vi.useFakeTimers(); });
+  afterEach(() => { vi.useRealTimers(); });
+
+  it("emits view_tick with surface statusline every 5s during a terminal turn", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(11_000);
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks.length).toBeGreaterThanOrEqual(2);
+    expect(ticks[0][1]).toMatchObject({
+      adId: "ad1", surface: "statusline", sessionToken: "tok1" });
+    expect((ticks[0][1] as { visibleMs: number }).visibleMs).toBeGreaterThan(0);
+  });
+
+  it("emits nothing when there is no terminal activity", () => {
+    const d = makeDeps();
+    setupCliTick(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("starts on a fresh-but-unparseable transcript (activityAgeMs fallback)", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(null);
+    d.cliTail.activityAgeMs.mockReturnValue(500);
+    setupCliTick(d);
+    vi.advanceTimersByTime(6000);
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks.length).toBeGreaterThanOrEqual(1);
+  });
+
+  it("stops ticking when the turn completes", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(6000);
+    const before = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    expect(before).toBeGreaterThanOrEqual(1);
+    d.cliTail.current.mockReturnValue(turnDone());
+    vi.advanceTimersByTime(15_000);
+    const after = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    expect(after).toBe(before);
+  });
+
+  it("never ticks while signed out", () => {
+    const d = makeDeps({ signedIn: () => false });
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("never ticks a demo ad", () => {
+    const d = makeDeps();
+    d.adRef.current = { ...d.adRef.current!, demo: true } as any;
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("never ticks when killed, and stops mid-show when the kill trips", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(6000);
+    const before = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    d.killedRef.current = true;
+    vi.advanceTimersByTime(15_000);
+    const after = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    expect(after).toBe(before);
+  });
+
+  it("never ticks when the statusline surface is not actually applied", () => {
+    const d = makeDeps({ surfaceApplied: () => false });
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("never ticks when cliMode is off", () => {
+    const d = makeDeps({ cliModeFn: () => "off" });
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("adopts a fresh session token mid-show (audit #1 contract)", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(1000);             // show opens
+    d.adRef.current = { ...d.adRef.current!, sessionToken: "tok2" };
+    vi.advanceTimersByTime(5000);             // next tick adopts
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks[ticks.length - 1][1]).toMatchObject(
+      { adId: "ad1", sessionToken: "tok2" });
+  });
+
+  it("restarts the session when the rotation swaps the ad (statusline repaints)", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(6000);             // ticking ad1
+    d.adRef.current = { ...d.adRef.current!, adId: "ad2",
+      sessionToken: "tok9", adText: "Other Ad" } as any;
+    vi.advanceTimersByTime(6000);             // session restarts on ad2
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks[ticks.length - 1][1]).toMatchObject(
+      { adId: "ad2", sessionToken: "tok9" });
+  });
+
+  it("suspend clamp: a sleep gap is not billed as visible time", () => {
+    const d = makeDeps();
+    d.cliTail.current.mockReturnValue(turn());
+    setupCliTick(d);
+    vi.advanceTimersByTime(5000);             // ~5s genuinely active
+    vi.setSystemTime(Date.now() + 8 * 3600_000);  // suspend, no timer ticks
+    vi.advanceTimersByTime(5000);
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    const last = ticks[ticks.length - 1][1] as { visibleMs: number };
+    expect(last.visibleMs).toBeLessThan(30_000);  // never the 8h gap
+  });
+});
diff --git a/test/click-telemetry.test.ts b/test/click-telemetry.test.ts
new file mode 100644
index 0000000..31ffcde
--- /dev/null
+++ b/test/click-telemetry.test.ts
@@ -0,0 +1,284 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM, VirtualConsole } from "jsdom";
+import { Loopback } from "../src/loopback";
+
+// Click-through telemetry is the revenue-bearing signal in the Kickbacks
+// auction. The CC block tries sendBeacon FIRST (Electron lifecycle-safe
+// during the host's external-navigation tear-down), then falls back to
+// fetch+keepalive. Until tonight only the impression beacon path had
+// coverage; the click path's sendBeacon-vs-fetch dual-fallback and the
+// surface-routing (overlay vs banner) walk-up were untested. A silent drop
+// in either codepath would burn live click revenue without any signal —
+// the worst-class regression for a billing pipeline.
+//
+// These tests load the CC block.asset.js in JSDOM, mock navigator.sendBeacon
+// + fetch on the JSDOM window, dispatch synthetic click events, and assert
+// the EXACT loopback URL the ping plumbing emits.
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "claude-code", "block.asset.js"),
+  "utf8");
+
+const SUBS: Record<string, string> = {
+  __VIBE_ADS_TIER__: "3",
+  __VIBE_ADS_AD__: JSON.stringify("Ramp - Save time & money"),
+  __VIBE_ADS_ICON__: JSON.stringify("icon.r"),
+  __VIBE_ADS_PORT__: "5555",
+  __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+  __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ctok-abc123"),
+  __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+  __VIBE_ADS_DEBUG__: "false",
+  __VIBE_ADS_CLICKURL__: JSON.stringify("https://ramp.example/lp?utm=ck"),
+  __VIBE_ADS_BANNER_ON__: "true",
+  __VIBE_ADS_CORR__: JSON.stringify("corr.xyz789"),
+  __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+  __VIBE_ADS_ICON_URL__: JSON.stringify(""),
+};
+
+function preparedAsset(): string {
+  let src = ASSET;
+  for (const [k, v] of Object.entries(SUBS)) src = src.split(k).join(v);
+  return src;
+}
+
+type BeaconCall = { url: string; data: Blob | string | null };
+type FetchCall = { url: string; init: RequestInit | undefined };
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function makeHarness(opts: { beacon: boolean | "reject" } = { beacon: true }) {
+  // Anchor click triggers a real-href navigation attempt jsdom can't carry
+  // out; without filtering this floods stderr with "Not implemented:
+  // navigation" warnings on every test. We only care about beacon/fetch
+  // outcomes here, so swallow that one warning class.
+  const vc = new VirtualConsole();
+  vc.on("jsdomError", (e: Error) => {
+    if (!/navigation/i.test(String(e?.message || ""))) throw e;
+  });
+  const dom = new JSDOM(`<body></body>`,
+    { runScripts: "outside-only", virtualConsole: vc });
+  const win = dom.window as unknown as Window & typeof globalThis & {
+    eval: (s: string) => unknown;
+    navigator: Navigator & { sendBeacon?: unknown };
+    fetch: typeof fetch;
+  };
+  const beacons: BeaconCall[] = [];
+  const fetches: FetchCall[] = [];
+  // sendBeacon: configurable (present + true, present + returns false, or
+  // absent). The block's ping() falls through to fetch on either of the
+  // latter two — the cases we MUST not silently drop.
+  if (opts.beacon !== false) {
+    (win.navigator as unknown as { sendBeacon: (u: string, d?: Blob | string) => boolean })
+      .sendBeacon = (u: string, d?: Blob | string): boolean => {
+        beacons.push({ url: u, data: d ?? null });
+        return opts.beacon === true;
+      };
+  } else {
+    try { delete (win.navigator as unknown as { sendBeacon?: unknown }).sendBeacon; }
+    catch { /* jsdom navigator getter; the absence is what matters */ }
+    (win.navigator as unknown as { sendBeacon: undefined }).sendBeacon = undefined;
+  }
+  win.fetch = ((url: string, init?: RequestInit) => {
+    fetches.push({ url: String(url), init });
+    return Promise.resolve(new (win as unknown as { Response: typeof Response })
+      .Response("", { status: 204 }));
+  }) as typeof fetch;
+  return { dom, win, beacons, fetches };
+}
+
+function bootBlock(dom: JSDOM): void {
+  // Strip the module.exports early-return branch so the IIFE runs the
+  // DOM-attached path (event listeners + render loop). jsdom defines no
+  // `module` global in the browser ctx, so the existing gate already
+  // skips the early-return there — but be explicit for forward safety.
+  (dom.window as unknown as { eval: (s: string) => unknown })
+    .eval(preparedAsset());
+}
+
+function makeAd(doc: Document, opts: { surface?: "overlay" | "banner" } = {}):
+  HTMLAnchorElement {
+  const wrap = doc.createElement("div");
+  if (opts.surface === "banner") wrap.setAttribute("data-vibe-ads-banner", "1");
+  else if (opts.surface === "overlay") wrap.setAttribute("data-vibe-ads-overlay", "1");
+  const a = doc.createElement("a");
+  a.setAttribute("data-vibe-ads-ad", "1");
+  a.setAttribute("href", "https://ramp.example/lp?utm=ck");
+  a.textContent = "Ramp - Save time & money";
+  wrap.appendChild(a);
+  doc.body.appendChild(wrap);
+  return a;
+}
+
+async function flushMicrotasks(): Promise<void> {
+  for (let i = 0; i < 5; i++) await Promise.resolve();
+}
+
+describe("CC click-through telemetry — sendBeacon-first, fetch fallback",
+  () => {
+  beforeEach(() => { vi.useRealTimers(); });
+
+  it("sendBeacon path: clicks emit a single beacon to the loopback /click "
+    + "endpoint with ct, corr, surface=overlay", async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    const a = makeAd(h.dom.window.document, { surface: "overlay" });
+    a.click();
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(1);
+    expect(h.fetches).toHaveLength(0);     // fallback NOT used
+    const u = new URL(h.beacons[0].url);
+    expect(u.pathname).toBe("/vibe-ads/lt/click");
+    expect(u.searchParams.get("ct")).toBe("ctok-abc123");
+    expect(u.searchParams.get("corr")).toBe("corr.xyz789");
+    expect(u.searchParams.get("surface")).toBe("overlay");
+    expect(u.searchParams.get("event_uuid")).toMatch(UUID_RE);
+  });
+
+  it("sendBeacon UNAVAILABLE: falls back to fetch+keepalive POST with the "
+    + "SAME url shape (silent-drop guard)", async () => {
+    const h = makeHarness({ beacon: false });
+    bootBlock(h.dom);
+    makeAd(h.dom.window.document, { surface: "overlay" }).click();
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(0);
+    expect(h.fetches).toHaveLength(1);
+    expect(h.fetches[0].init?.method).toBe("POST");
+    expect((h.fetches[0].init as unknown as { keepalive: boolean }).keepalive)
+      .toBe(true);
+    const u = new URL(h.fetches[0].url);
+    expect(u.pathname).toBe("/vibe-ads/lt/click");
+    expect(u.searchParams.get("ct")).toBe("ctok-abc123");
+    expect(u.searchParams.get("surface")).toBe("overlay");
+    expect(u.searchParams.get("event_uuid")).toMatch(UUID_RE);
+  });
+
+  it("sendBeacon REJECTS (returns false): falls back to fetch — the "
+    + "Electron quirk where beacon present but the browser refuses the "
+    + "specific request", async () => {
+    const h = makeHarness({ beacon: "reject" });
+    bootBlock(h.dom);
+    makeAd(h.dom.window.document, { surface: "overlay" }).click();
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(1);     // attempted
+    expect(h.fetches).toHaveLength(1);     // and fell through
+    expect(new URL(h.fetches[0].url).pathname).toBe("/vibe-ads/lt/click");
+  });
+
+  it("surface=banner when the click target is INSIDE a data-vibe-ads-banner "
+    + "wrapper (ledger attribution must distinguish spinner vs banner)",
+    async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    makeAd(h.dom.window.document, { surface: "banner" }).click();
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(1);
+    expect(new URL(h.beacons[0].url).searchParams.get("surface"))
+      .toBe("banner");
+  });
+
+  it("surface defaults to overlay when no wrapper found (defensive)",
+    async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    // Ad anchor with no banner/overlay wrapper at all.
+    const a = h.dom.window.document.createElement("a");
+    a.setAttribute("data-vibe-ads-ad", "1");
+    a.setAttribute("href", "https://x.example");
+    h.dom.window.document.body.appendChild(a);
+    a.click();
+    await flushMicrotasks();
+    expect(new URL(h.beacons[0].url).searchParams.get("surface"))
+      .toBe("overlay");
+  });
+
+  it("clicks on a NON-ad element emit zero pings (no listener leak)",
+    async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    const div = h.dom.window.document.createElement("div");
+    div.textContent = "unrelated chrome";
+    h.dom.window.document.body.appendChild(div);
+    div.click();
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(0);
+    expect(h.fetches).toHaveLength(0);
+  });
+
+  it("does NOT preventDefault — the anchor's real href must reach the host "
+    + "for the external-open (CSP-exempt navigation is the actual click-out)",
+    async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    const a = makeAd(h.dom.window.document, { surface: "overlay" });
+    let defaultPrevented = false;
+    a.addEventListener("click", (ev) => {
+      defaultPrevented = ev.defaultPrevented;
+    }, false);
+    a.click();
+    await flushMicrotasks();
+    expect(defaultPrevented).toBe(false);
+  });
+
+  it("click ping carries the ad= attribution CLAIM (wave-4 fix 4): the same "
+    + "param the view-event pings send — the pollAd-adopted identifier the "
+    + "block keys its _vt sessions on (the ad TEXT) — and a REAL loopback "
+    + "lifts it into onClick's claimedAdId", async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    makeAd(h.dom.window.document, { surface: "overlay" }).click();
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(1);
+    const u = new URL(h.beacons[0].url);
+    // Without this claim the host's recent-ads registry (audit #17) cannot
+    // resolve a click landing in the ≤10s /ad poll-lag window after a
+    // rotation — it would bill the freshly-rotated campaign instead.
+    expect(u.searchParams.get("ad")).toBe("Ramp - Save time & money");
+
+    // End-to-end: replay the EXACT query the block emitted against a live
+    // loopback server and assert the host parses the same param name into
+    // onClick's claimedAdId (loopback.ts click route → `ad=` →
+    // webviewInjection.resolveAttribution upstream).
+    const clicks: Array<{ ct: string; claimedAdId?: string }> = [];
+    const lb = new Loopback({
+      onEvent: () => { /* not under test */ },
+      onClick: (ct, _surface, _visibleMs, _eventUuid, claimedAdId) => {
+        clicks.push({ ct, claimedAdId });
+      },
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    try {
+      const { port, token } = await lb.start();
+      expect(port).toBeGreaterThan(0);
+      const res = await fetch(
+        `http://127.0.0.1:${port}/vibe-ads/${token}/click${u.search}`,
+        { method: "POST" });
+      expect(res.status).toBe(204);
+    } finally {
+      await lb.stop();
+    }
+    expect(clicks).toHaveLength(1);
+    expect(clicks[0].ct).toBe("ctok-abc123");
+    expect(clicks[0].claimedAdId).toBe("Ramp - Save time & money");
+  });
+
+  it("clicks on a CHILD element of the ad anchor still attribute correctly "
+    + "(the favicon SVG, animated dots span — every child must walk up to "
+    + "the [data-vibe-ads-ad] ancestor)", async () => {
+    const h = makeHarness({ beacon: true });
+    bootBlock(h.dom);
+    const a = makeAd(h.dom.window.document, { surface: "overlay" });
+    // Simulate the favicon SVG or dots-span child the real ad renders.
+    const dot = h.dom.window.document.createElement("span");
+    dot.setAttribute("data-va-dots", "1");
+    dot.textContent = " ..";
+    a.appendChild(dot);
+    dot.dispatchEvent(new h.dom.window.MouseEvent("click",
+      { bubbles: true, cancelable: true }));
+    await flushMicrotasks();
+    expect(h.beacons).toHaveLength(1);
+    expect(new URL(h.beacons[0].url).searchParams.get("ct"))
+      .toBe("ctok-abc123");
+  });
+});
diff --git a/test/codex-adapter.test.ts b/test/codex-adapter.test.ts
new file mode 100644
index 0000000..302551a
--- /dev/null
+++ b/test/codex-adapter.test.ts
@@ -0,0 +1,261 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync }
+  from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { CodexAdapter } from "../src/adapters/codex/adapter";
+import type { PatchParams } from "../src/adapters/types";
+
+const FIX = readFileSync(
+  join(__dirname, "fixtures/synthetic-thinking-shimmer.js"), "utf8");
+
+// Real Codex path shape:
+// .../openai.chatgpt-<ver>/webview/assets/thinking-shimmer-<hash>.js
+function tmpTarget(ver = "26.513.21555"): string {
+  const root = mkdtempSync(join(tmpdir(), "vibe-ads-codex-"));
+  const dir = join(root, `openai.chatgpt-${ver}`, "webview", "assets");
+  mkdirSync(dir, { recursive: true });
+  const p = join(dir, "thinking-shimmer-BcRunliI.js");
+  writeFileSync(p, FIX, "utf8");
+  return p;
+}
+
+function tmpTargetWithCsp(ver = "26.513.21555"):
+  { target: string; ext: string; pristineExt: string } {
+  const root = mkdtempSync(join(tmpdir(), "vibe-ads-codex-csp-"));
+  const extRoot = join(root, `openai.chatgpt-${ver}`);
+  const dir = join(extRoot, "webview", "assets");
+  mkdirSync(dir, { recursive: true });
+  mkdirSync(join(extRoot, "out"), { recursive: true });
+  const target = join(dir, "thinking-shimmer-BcRunliI.js");
+  const ext = join(extRoot, "out", "extension.js");
+  const pristineExt =
+    "const c=[\"default-src 'none'\","
+    + "`connect-src ${e.cspSource} ${rfe}`].join('; ');";
+  writeFileSync(target, FIX, "utf8");
+  writeFileSync(ext, pristineExt, "utf8");
+  return { target, ext, pristineExt };
+}
+
+const params: PatchParams = {
+  tier: 3, adText: "Ramp corporate cards & expense mgmt",
+  iconRef: "icon.r", iconUrl: "", clickToken: "ck", clickUrl: "https://ramp.example/lp",
+  corr: "ad1.tst", loopbackPort: 5555, loopbackToken: "lt",
+  loopbackBase: "http://127.0.0.1:5555/vibe-ads/lt", debug: false,
+};
+
+describe("CodexAdapter", () => {
+  let target: string;
+  beforeEach(() => { target = tmpTarget(); });
+
+  it("fixture carries the four S9 anchors", () => {
+    expect(FIX.replace(/\s+/g, "")).toContain("export{vasn,gast}");
+    expect(FIX).toContain("defaultMessage:`Thinking`");
+    expect(FIX).toContain("id:`thinkingShimmer.default`");
+    expect(FIX).toMatch(/\(0,\s*d\.jsxs?\)/);
+  });
+
+  it("preflight compatible on the fixture; version from the dir segment", () => {
+    const a = new CodexAdapter(target);
+    const pf = a.preflight();
+    expect(pf.ok).toBe(true);
+    expect(pf.compatible).toBe(true);
+    expect(pf.version).toBe("26.513.21555");
+  });
+
+  it("preflight incompatible (graceful, no writes) when anchors missing", () => {
+    writeFileSync(target, "export{};var x=1;", "utf8");
+    const a = new CodexAdapter(target);
+    expect(a.preflight().compatible).toBe(false);
+    expect(readFileSync(target, "utf8")).toBe("export{};var x=1;");
+  });
+
+  it("preflight not-found is graceful", () => {
+    const a = new CodexAdapter(target + ".nope");
+    const pf = a.preflight();
+    expect(pf.ok).toBe(true);
+    expect(pf.compatible).toBe(false);
+  });
+
+  it("version reads the openai.chatgpt-<ver> path segment", () => {
+    expect(new CodexAdapter(target).version()).toBe("26.513.21555");
+  });
+
+  it("applyPatch adds loopback hosts to Codex out/extension.js connect-src", () => {
+    const { target: cspTarget, ext } = tmpTargetWithCsp();
+    const a = new CodexAdapter(cspTarget);
+    expect(a.applyPatch(params).ok).toBe(true);
+    const patched = readFileSync(ext, "utf8");
+    expect(patched).toContain(
+      "connect-src http://127.0.0.1:* http://localhost:* "
+      + "${e.cspSource} ${rfe}");
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);
+
+    a.applyPatch(params);
+    expect((readFileSync(ext, "utf8").match(/http:\/\/127\.0\.0\.1:\*/g) || [])
+      .length).toBe(1);
+  });
+
+  it("applyPatch: one-time backup, single block at function v(e){, idempotent", () => {
+    const a = new CodexAdapter(target);
+    expect(a.applyPatch(params).ok).toBe(true);
+    const out1 = readFileSync(target, "utf8");
+    expect(existsSync(target + ".vibe-ads-backup")).toBe(true);
+    // Markers wrap the WHOLE arg= statement (OUTSIDE the wrapper) so a future
+    // strip removes it entirely — no empty `e=()||e;` residue can remain.
+    expect(out1).toMatch(/function v\(e\)\{\/\* VIBE-ADS-START \*\/e=\(/);
+    expect(out1).toMatch(/\)\|\|e;\/\* VIBE-ADS-END \*\/\s*let /);
+    expect(out1).toContain(params.adText);
+    expect((out1.match(/VIBE-ADS-START/g) || []).length).toBe(1);
+    expect(out1).toContain('"http://127.0.0.1:5555/vibe-ads/lt"'); // loopbackBase substituted
+    a.applyPatch(params);                        // idempotent (re-derived from pristine)
+    expect(readFileSync(target, "utf8")).toBe(out1);
+  });
+
+  it("applyPatch wraps the entry arg and resolves every sentinel", () => {
+    const a = new CodexAdapter(target);
+    a.applyPatch(params);
+    const out = readFileSync(target, "utf8");
+    // Body-level overlay block: a pure passthrough IIFE (returns undefined →
+    // `e = <iife> || e` leaves Codex's component untouched). The arg is still
+    // wrapped by the adapter; no JSX runtime is injected (DOM-only overlay).
+    expect(out).toMatch(
+      /function v\(e\)\{\/\* VIBE-ADS-START \*\/e=\(.*\)\|\|e;\/\* VIBE-ADS-END \*\//s);
+    expect(out).toContain("data-vibe-ads"); // overlay element marker present
+    expect(out).not.toContain("__VIBE_ADS_");
+  });
+
+  it("self-heals every historical injection form (no e=()||e; residue)", () => {
+    // Regression for the 'finnicky/stopped working' root cause: a re-derive
+    // from an already-patched file with NO backup must reproduce the TRUE
+    // pristine, never leave an empty-wrapper residue, and stay ESM-valid +
+    // idempotent. Covers current/legacy/residue forms.
+    const seeds = [
+      FIX,                                                        // clean
+      FIX.replace(/function v\(e\)\{/,
+        "function v(e){/* VIBE-ADS-START */e=((function(){return})())||e;/* VIBE-ADS-END */"), // new form
+      FIX.replace(/function v\(e\)\{/,
+        "function v(e){e=(/* VIBE-ADS-START */0/* VIBE-ADS-END */)||e;"), // legacy markers-inside
+      FIX.replace(/function v\(e\)\{/, "function v(e){e=()||e;e=()||e;"), // accumulated residue
+    ];
+    for (const seed of seeds) {
+      writeFileSync(target, seed, "utf8");                  // NO backup written
+      const a = new CodexAdapter(target);
+      expect(a.applyPatch(params).ok).toBe(true);
+      const o1 = readFileSync(target, "utf8");
+      expect((o1.match(/VIBE-ADS-START/g) || []).length).toBe(1);
+      expect(o1).not.toMatch(/=\(\)\|\|/);                  // zero empty residue
+      a.applyPatch(params);
+      expect(readFileSync(target, "utf8")).toBe(o1);        // idempotent
+      const r = a.restore();
+      expect(r.restored).toBe(true);
+      expect(readFileSync(target, "utf8")).toBe(FIX);       // byte-true pristine
+    }
+  });
+
+  it("applyPatch isPatched() reflects state", () => {
+    const a = new CodexAdapter(target);
+    expect(a.isPatched()).toBe(false);
+    a.applyPatch(params);
+    expect(a.isPatched()).toBe(true);
+  });
+
+  it("applyPatch graceful when target missing", () => {
+    const a = new CodexAdapter(target + ".nope");
+    expect(a.applyPatch(params).ok).toBe(false);
+  });
+
+  it("applyPatch incompatible build → ok:false, no write", () => {
+    writeFileSync(target, "export{};var x=1;", "utf8");
+    const a = new CodexAdapter(target);
+    expect(a.applyPatch(params).ok).toBe(false);
+    expect(readFileSync(target, "utf8")).toBe("export{};var x=1;");
+    expect(existsSync(target + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("restore is byte-exact + honest, removes backup, isPatched()→false", () => {
+    const a = new CodexAdapter(target);
+    a.applyPatch(params);
+    const r = a.restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX);          // byte-exact
+    expect(existsSync(target + ".vibe-ads-backup")).toBe(false);
+    expect(a.isPatched()).toBe(false);
+  });
+
+  it("restore with no backup is an honest no-op", () => {
+    const a = new CodexAdapter(target);
+    const r = a.restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(false);
+    expect(r.reason).toMatch(/no backup/i);
+  });
+
+  it("restore({ keepCsp:true }) reverts the chunk but leaves CSP relaxation", () => {
+    const { target: cspTarget, ext } = tmpTargetWithCsp();
+    const a = new CodexAdapter(cspTarget);
+    a.applyPatch(params);
+    expect(a.restore({ keepCsp: true }).restored).toBe(true);
+    expect(readFileSync(cspTarget, "utf8")).toBe(FIX);
+    expect(readFileSync(ext, "utf8")).toContain("connect-src http://127.0.0.1:*");
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);
+  });
+
+  it("restore() fully reverts the Codex CSP relaxation", () => {
+    const { target: cspTarget, ext, pristineExt } = tmpTargetWithCsp();
+    const a = new CodexAdapter(cspTarget);
+    a.applyPatch(params);
+    expect(a.restore().restored).toBe(true);
+    expect(readFileSync(cspTarget, "utf8")).toBe(FIX);
+    expect(readFileSync(ext, "utf8")).toBe(pristineExt);
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(false);
+  });
+
+  // prime(): the boot-time structural reassert — relax the loopback CSP with
+  // NO block injected, so the surface is primed before an ad arrives.
+  it("prime() relaxes the Codex CSP + backs it up, WITHOUT injecting a block", () => {
+    const { target: cspTarget, ext } = tmpTargetWithCsp();
+    const a = new CodexAdapter(cspTarget);
+    expect(a.prime().ok).toBe(true);
+    expect(readFileSync(ext, "utf8")).toContain("connect-src http://127.0.0.1:*");
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(true);
+    expect(readFileSync(cspTarget, "utf8")).toBe(FIX);       // chunk untouched
+    expect(a.isPatched()).toBe(false);                       // no block
+  });
+
+  it("prime() is idempotent (no double connect-src insert)", () => {
+    const { target: cspTarget, ext } = tmpTargetWithCsp();
+    const a = new CodexAdapter(cspTarget);
+    a.prime();
+    const after1 = readFileSync(ext, "utf8");
+    a.prime();
+    expect(readFileSync(ext, "utf8")).toBe(after1);
+    expect((after1.match(/http:\/\/127\.0\.0\.1:\*/g) || []).length).toBe(1);
+  });
+
+  it("prime() then explicit restore() fully reverts the primed CSP (no block backup)", () => {
+    const { target: cspTarget, ext, pristineExt } = tmpTargetWithCsp();
+    const a = new CodexAdapter(cspTarget);
+    a.prime();
+    expect(readFileSync(ext, "utf8")).toContain("connect-src http://127.0.0.1:*");
+    a.restore();                                             // explicit opt-out
+    expect(readFileSync(ext, "utf8")).toBe(pristineExt);     // CSP byte-exact reverted
+    expect(existsSync(ext + ".vibe-ads-backup")).toBe(false);
+  });
+
+  it("prime() graceful when the sibling extension.js is absent", () => {
+    const a = new CodexAdapter(target);                      // tmpTarget(): no out/extension.js
+    expect(a.prime().ok).toBe(false);
+    expect(readFileSync(target, "utf8")).toBe(FIX);          // chunk untouched
+  });
+
+  it("never throws on a garbled target", () => {
+    writeFileSync(target, "  not js ", "utf8");
+    const a = new CodexAdapter(target);
+    expect(() => a.preflight()).not.toThrow();
+    expect(a.applyPatch(params).ok).toBe(false);
+    expect(() => a.restore()).not.toThrow();
+    expect(() => a.isPatched()).not.toThrow();
+  });
+});
diff --git a/test/codex-cli-adapter.test.ts b/test/codex-cli-adapter.test.ts
new file mode 100644
index 0000000..6787314
--- /dev/null
+++ b/test/codex-cli-adapter.test.ts
@@ -0,0 +1,223 @@
+import { describe, it, expect } from "vitest";
+import { CodexCliWrapperAdapter, resolveWrapperAsset }
+  from "../src/adapters/codex-cli/adapter";
+import { mkdtempSync, writeFileSync, mkdirSync, readFileSync,
+         existsSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { PatchParams } from "../src/adapters/types";
+
+const PRISTINE_CMD =
+  "@ECHO off\nGOTO start\n:find_dp0\nSET dp0=%~dp0\nEXIT /b\n" +
+  ":start\nSETLOCAL\nCALL :find_dp0\n\n" +
+  'IF EXIST "%dp0%\\node.exe" (\n' +
+  '  SET "_prog=%dp0%\\node.exe"\n' +
+  ') ELSE (\n' +
+  '  SET "_prog=node"\n' +
+  '  SET PATHEXT=%PATHEXT:;.JS;=;%\n' +
+  ")\n\n" +
+  'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%"  ' +
+  '"%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*\n';
+
+const PRISTINE_SH =
+  "#!/usr/bin/env node\n" +
+  "// @openai/codex shim — boots node + codex.js\n" +
+  'import { spawn } from "node:child_process";\n';
+
+function patchParams(overrides: Partial<PatchParams> = {}): PatchParams {
+  return {
+    tier: 0,
+    adText: "Test Ad XYZ",
+    iconRef: "i.svg",
+    iconUrl: "",
+    clickToken: "",
+    clickUrl: "https://example.com",
+    corr: "c.test",
+    loopbackPort: 0,
+    loopbackToken: "",
+    loopbackBase: "",
+    ...overrides,
+  };
+}
+
+describe("CodexCliWrapperAdapter — Windows .cmd shim", () => {
+  function setup(content = PRISTINE_CMD) {
+    const home = mkdtempSync(join(tmpdir(), "kb-cxcli-"));
+    const shimDir = join(home, "AppData", "Roaming", "npm");
+    mkdirSync(shimDir, { recursive: true });
+    const shim = join(shimDir, "codex.cmd");
+    writeFileSync(shim, content);
+    return { home, shim, backup: join(shimDir, "codex.vibe-ads-orig.cmd"),
+             adFile: join(home, ".vibe-ads", "codex-cli-ad.txt") };
+  }
+
+  it("preflight: compatible on a plausibly-npm-shim", () => {
+    const { home, shim } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const pf = a.preflight();
+    expect(pf.ok).toBe(true);
+    expect(pf.compatible).toBe(true);
+  });
+
+  it("preflight: incompatible when shim doesn't reference @openai/codex", () => {
+    const { home, shim } = setup("@echo off\necho not-codex\n");
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const pf = a.preflight();
+    expect(pf.compatible).toBe(false);
+    expect(pf.reason).toMatch(/openai\/codex/);
+  });
+
+  it("preflight: incompatible when shim file is missing", () => {
+    const { home, shim } = setup();
+    rmSync(shim);
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const pf = a.preflight();
+    expect(pf.compatible).toBe(false);
+    expect(pf.reason).toBe("shim not found");
+  });
+
+  it("applyPatch: writes wrapper with MARKER, creates backup + ad-text file", () => {
+    const { home, shim, backup, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const r = a.applyPatch(patchParams());
+    expect(r.ok).toBe(true);
+    const wrapper = readFileSync(shim, "utf8");
+    expect(wrapper).toContain("VIBE-ADS-CODEX-CLI");
+    // The wrapper should delegate to the backup we just saved
+    expect(wrapper).toContain(backup);
+    expect(existsSync(backup)).toBe(true);
+    expect(readFileSync(backup, "utf8")).toBe(PRISTINE_CMD);
+    expect(readFileSync(adFile, "utf8").trim()).toBe("Test Ad XYZ");
+    expect(a.isPatched()).toBe(true);
+  });
+
+  it("applyPatch is idempotent: second call doesn't rewrap or overwrite backup", () => {
+    const { home, shim, backup } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    a.applyPatch(patchParams());
+    const wrapper1 = readFileSync(shim, "utf8");
+    const backupBefore = readFileSync(backup, "utf8");
+    a.applyPatch(patchParams({ adText: "Different Ad" }));
+    const wrapper2 = readFileSync(shim, "utf8");
+    // Wrapper itself unchanged (no rewrite when MARKER already present)
+    expect(wrapper2).toBe(wrapper1);
+    // Backup still pristine — never overwritten by the wrapper bytes
+    expect(readFileSync(backup, "utf8")).toBe(backupBefore);
+  });
+
+  it("applyPatch: ad-text file is updated on every call (decoupled from wrapper)", () => {
+    const { home, shim, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    a.applyPatch(patchParams({ adText: "First Ad" }));
+    expect(readFileSync(adFile, "utf8").trim()).toBe("First Ad");
+    a.applyPatch(patchParams({ adText: "Second Ad" }));
+    expect(readFileSync(adFile, "utf8").trim()).toBe("Second Ad");
+  });
+
+  it("applyPatch strips control chars from the ad file (wrappers print it raw to the terminal)", () => {
+    const { home, shim, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const ESC = "\u001b", BEL = "\u0007";
+    a.applyPatch(patchParams({
+      adText: "Evil" + ESC + "]0;pwned" + BEL + ESC + "[31mAd" }));
+    expect(readFileSync(adFile, "utf8")).toBe("Evil]0;pwned[31mAd\n");
+  });
+
+  it("applyPatch is PERMISSIVE: emoji/pipes/unicode written byte-identical", () => {
+    const { home, shim, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const line = "Déployez 🚀 | ai.dev — vite";
+    a.applyPatch(patchParams({ adText: line }));
+    expect(readFileSync(adFile, "utf8")).toBe(line + "\n");
+  });
+
+  it("applyPatch falls back to the default line when adText strips to empty", () => {
+    const { home, shim, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    a.applyPatch(patchParams({ adText: "\u001b\u0007" }));
+    expect(readFileSync(adFile, "utf8")).toBe("Earning Kickback\n");
+  });
+
+  it("restore: byte-exact restoration of the npm shim", () => {
+    const { home, shim, backup, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    a.applyPatch(patchParams());
+    expect(readFileSync(shim, "utf8")).not.toBe(PRISTINE_CMD);
+    const r = a.restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(true);
+    expect(readFileSync(shim, "utf8")).toBe(PRISTINE_CMD);
+    expect(existsSync(backup)).toBe(false);
+    expect(existsSync(adFile)).toBe(false);
+  });
+
+  it("restore: returns restored:false when no backup is present", () => {
+    const { home, shim } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const r = a.restore();
+    expect(r.ok).toBe(true);
+    expect(r.restored).toBe(false);
+    expect(r.reason).toMatch(/no backup/);
+  });
+
+  it("wrapper substitutes the ad-file and backup paths verbatim", () => {
+    const { home, shim, backup, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    a.applyPatch(patchParams());
+    const wrapper = readFileSync(shim, "utf8");
+    expect(wrapper).toContain(adFile);
+    expect(wrapper).toContain(backup);
+    // No raw template placeholders should leak through
+    expect(wrapper).not.toContain("__VIBE_ADS_AD_PATH__");
+    expect(wrapper).not.toContain("__VIBE_ADS_BACKUP__");
+  });
+});
+
+describe("CodexCliWrapperAdapter — POSIX shim", () => {
+  function setup(content = PRISTINE_SH) {
+    const home = mkdtempSync(join(tmpdir(), "kb-cxcli-posix-"));
+    const shimDir = join(home, "usr", "local", "bin");
+    mkdirSync(shimDir, { recursive: true });
+    const shim = join(shimDir, "codex");
+    writeFileSync(shim, content);
+    return { home, shim, backup: join(shimDir, "codex.vibe-ads-orig"),
+             adFile: join(home, ".vibe-ads", "codex-cli-ad.txt") };
+  }
+
+  it("preflight: compatible on a JS-shebang npm shim", () => {
+    const { home, shim } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    expect(a.preflight().compatible).toBe(true);
+  });
+
+  it("applyPatch: writes shell wrapper, backup uses no-extension naming", () => {
+    const { home, shim, backup, adFile } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    const r = a.applyPatch(patchParams());
+    expect(r.ok).toBe(true);
+    const wrapper = readFileSync(shim, "utf8");
+    expect(wrapper).toContain("VIBE-ADS-CODEX-CLI");
+    expect(wrapper).toMatch(/^#!\/bin\/sh/);
+    expect(wrapper).toContain('exec "' + backup + '"');
+    expect(wrapper).toContain(adFile);
+    expect(readFileSync(backup, "utf8")).toBe(PRISTINE_SH);
+  });
+
+  it("restore: byte-exact round-trip of the POSIX shim", () => {
+    const { home, shim } = setup();
+    const a = new CodexCliWrapperAdapter(shim, home);
+    a.applyPatch(patchParams());
+    a.restore();
+    expect(readFileSync(shim, "utf8")).toBe(PRISTINE_SH);
+  });
+});
+
+describe("resolveWrapperAsset", () => {
+  it("picks the .cmd asset on Windows, .sh on POSIX", () => {
+    const dir = join(__dirname, "..", "src", "adapters", "codex-cli");
+    const winAsset = resolveWrapperAsset(dir, true);
+    const posixAsset = resolveWrapperAsset(dir, false);
+    expect(winAsset.endsWith("wrapper.cmd.asset")).toBe(true);
+    expect(posixAsset.endsWith("wrapper.sh.asset")).toBe(true);
+  });
+});
diff --git a/test/codex-findrow.test.ts b/test/codex-findrow.test.ts
new file mode 100644
index 0000000..21f014f
--- /dev/null
+++ b/test/codex-findrow.test.ts
@@ -0,0 +1,189 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM } from "jsdom";
+
+// Regression coverage for the codex-e2e row 03 + row 08 failures: Codex 26.x
+// leaves a hidden "Thinking 1.2s" summary chip in chat history after a turn
+// completes, with the same `text-size-chat truncate select-none` class trio
+// the live shimmer uses. The old findRow() matched the stale chip and
+// isThinkingRow() agreed → overlay never released at idle (row 03) and stuck
+// to turn 1 on multi-turn prompts (row 08, "glued to turn 1").
+//
+// Fix is in extension/src/adapters/codex/block.asset.js: findRow() now also
+// requires `loading-shimmer` in the className AND a non-zero bounding rect.
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "codex", "block.asset.js"), "utf8");
+
+function preparedAsset(): string {
+  // Mirror CodexAdapter.renderBlock placeholder substitution (we only need
+  // the IIFE body to run inside JSDOM; markers stripped since the bare asset
+  // is itself a valid IIFE expression).
+  const subs: Record<string, string> = {
+    __VIBE_ADS_AD__: JSON.stringify("Ramp - Save time & money"),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ct"),
+    __VIBE_ADS_CLICKURL__: JSON.stringify("https://ramp.example/lp"),
+    __VIBE_ADS_CORR__: JSON.stringify("test.codex"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+    __VIBE_ADS_ARG__: "e",        // unused by overlay path
+    __VIBE_ADS_JSX__: "d",        // unused by overlay path
+  };
+  let src = ASSET;
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  return src;
+}
+
+function makeDom(): { dom: JSDOM; doc: Document; mc: HTMLElement } {
+  const dom = new JSDOM(
+    `<body><div id="mc" class="chatpanel"></div></body>`,
+    { runScripts: "outside-only", pretendToBeVisual: true });
+  // Stub fetch — the asset best-effort-pings the loopback; we just no-op.
+  (dom.window as unknown as { fetch: typeof fetch }).fetch =
+    (() => Promise.resolve(new (dom.window as unknown as { Response: typeof Response }).Response("", { status: 204 }))) as typeof fetch;
+  // Force rects: jsdom returns 0×0 for layout. We patch getBoundingClientRect
+  // on a per-element basis below to model live vs hidden states.
+  const doc = dom.window.document;
+  return { dom, doc, mc: doc.getElementById("mc") as HTMLElement };
+}
+
+function bootAsset(dom: JSDOM): void {
+  // eval inside the JSDOM window ctx — same pattern as test/e2e.test.ts.
+  // A <script>-tag inject would require runScripts:"dangerously" and is
+  // strictly slower because jsdom rebuilds the parser path.
+  (dom.window as unknown as { eval: (src: string) => unknown })
+    .eval(preparedAsset());
+}
+
+// Helper: stamp a fixed rect on an element so the asset's
+// getBoundingClientRect() visibility filter sees the shape we want.
+function setRect(el: HTMLElement,
+  r: { x: number; y: number; w: number; h: number }): void {
+  (el as unknown as { getBoundingClientRect: () => DOMRect })
+    .getBoundingClientRect = () => ({
+      x: r.x, y: r.y, left: r.x, top: r.y, right: r.x + r.w,
+      bottom: r.y + r.h, width: r.w, height: r.h, toJSON() { return {}; },
+    } as DOMRect);
+}
+
+function sleep(ms: number): Promise<void> {
+  return new Promise((res) => setTimeout(res, ms));
+}
+
+// Real codex 26.x shimmer markup, copied from a captured codex.show debug
+// line: class="loading-shimmer-pure-text _cadencedShimmer_1bpr9_1 text-
+// size-chat leading-[1.5] select-none truncate". textContent renders as
+// "ThinkingThinking" because the shimmer stacks two spans (base + sweep).
+function makeLiveShimmer(doc: Document): HTMLElement {
+  const el = doc.createElement("span");
+  el.className = "loading-shimmer-pure-text _cadencedShimmer_1bpr9_1 "
+    + "text-size-chat leading-[1.5] select-none truncate";
+  // Two stacked spans → textContent = "ThinkingThinking"
+  const a = doc.createElement("span"); a.textContent = "Thinking";
+  const b = doc.createElement("span"); b.textContent = "Thinking";
+  el.appendChild(a); el.appendChild(b);
+  setRect(el, { x: 100, y: 200, w: 240, h: 20 });
+  return el;
+}
+
+// The post-turn "Thinking 1.2s" summary chip Codex leaves in chat history
+// after streaming ends. Same class trio (text-size-chat / truncate /
+// select-none) BUT no loading-shimmer-* class and (typically) display:none
+// → zero rect.
+function makeStaleSummaryChip(doc: Document): HTMLElement {
+  const el = doc.createElement("span");
+  el.className = "text-size-chat leading-[1.5] select-none truncate";
+  el.textContent = "Thinking 1.2s";
+  setRect(el, { x: 0, y: 0, w: 0, h: 0 });    // display:none-equivalent
+  return el;
+}
+
+describe("S9 codex findRow — live shimmer only, never stale chip", () => {
+  it("paints overlay on a live shimmer row", async () => {
+    const { dom, doc, mc } = makeDom();
+    mc.appendChild(makeLiveShimmer(doc));
+    bootAsset(dom);
+    await sleep(200);                          // > 80ms interval tick
+    const overlay = doc.querySelector('[data-vibe-ads="codex"]');
+    expect(overlay).toBeTruthy();
+    expect(overlay!.querySelector('[data-vibe-ads-ad]')).toBeTruthy();
+    dom.window.close();
+  });
+
+  it("never paints on a post-turn 'Thinking 1.2s' summary chip alone",
+    async () => {
+    const { dom, doc, mc } = makeDom();
+    mc.appendChild(makeStaleSummaryChip(doc));   // only the stale chip
+    bootAsset(dom);
+    await sleep(200);
+    const overlay = doc.querySelector('[data-vibe-ads="codex"]');
+    expect(overlay).toBeNull();
+    dom.window.close();
+  });
+
+  it("picks the LIVE shimmer when both a stale chip AND a live shimmer "
+    + "exist (row 08: 'glued to turn 1' regression guard)", async () => {
+    const { dom, doc, mc } = makeDom();
+    // Stale chip comes FIRST in DOM order — the bug had findRow returning
+    // it because it matched the class trio. With the fix, the live
+    // shimmer (which carries loading-shimmer + non-zero rect) wins.
+    const stale = makeStaleSummaryChip(doc);
+    const live = makeLiveShimmer(doc);
+    mc.appendChild(stale);
+    mc.appendChild(live);
+    bootAsset(dom);
+    await sleep(200);
+    const overlay = doc.querySelector('[data-vibe-ads="codex"]') as
+      HTMLElement | null;
+    expect(overlay).toBeTruthy();
+    // Overlay rect must align with the LIVE shimmer, not the stale chip.
+    // The asset reads getBoundingClientRect().left/top off lastRow and
+    // pins overlay.style.left/top to it; live rect is x=100,y=200.
+    expect(overlay!.style.left).toBe("100px");
+    expect(overlay!.style.top).toBe("200px");
+    dom.window.close();
+  });
+
+  it("drops the overlay when the live shimmer becomes hidden "
+    + "(row 03: idle-release regression guard)", async () => {
+    const { dom, doc, mc } = makeDom();
+    const live = makeLiveShimmer(doc);
+    mc.appendChild(live);
+    bootAsset(dom);
+    await sleep(200);
+    expect(doc.querySelector('[data-vibe-ads="codex"]')).toBeTruthy();
+    // Simulate Codex finishing the turn: rect collapses to 0×0
+    // (Codex applies display:none on the shimmer). The current row
+    // class trio (text-size-chat/truncate/select-none) survives but
+    // loading-shimmer-* drops or the rect drops to zero. Either way,
+    // findRow() must release.
+    setRect(live, { x: 0, y: 0, w: 0, h: 0 });
+    // Walk past GRACE_MS=1500 so the idle-drop branch fires.
+    await sleep(1800);
+    expect(doc.querySelector('[data-vibe-ads="codex"]')).toBeNull();
+    dom.window.close();
+  });
+
+  it("does NOT paint on a live-shimmer-class row that's visibility:hidden "
+    + "(Codex 26 post-stream invisible-but-still-mounted state — row 03 "
+    + "second-line regression guard)", async () => {
+    const { dom, doc, mc } = makeDom();
+    const live = makeLiveShimmer(doc);
+    // Codex 26 transitional state: live shimmer remains mounted with the
+    // sweep class on, has a non-zero rect (box reserved), but is invisible
+    // because the chat panel marked it visibility:hidden. The old rect-
+    // only filter would happily paint on this and the dot-cycle would
+    // keep going past the harness's "idle confirmed" gate, false-FAILing
+    // row 03 even after the class+rect filters were added.
+    live.style.visibility = "hidden";
+    mc.appendChild(live);
+    bootAsset(dom);
+    await sleep(200);
+    expect(doc.querySelector('[data-vibe-ads="codex"]')).toBeNull();
+    dom.window.close();
+  });
+});
diff --git a/test/codex-gating.test.ts b/test/codex-gating.test.ts
new file mode 100644
index 0000000..eed46b7
--- /dev/null
+++ b/test/codex-gating.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect } from "vitest";
+import { isThinkingMessage } from "../src/adapters/codex/adapter";
+
+// `m` is the live `e.message` prop the ThinkingShimmer entry receives. A bare
+// React element shape is `{ props: {...} }`; null/undefined => Codex falls back
+// to its own `thinkingShimmer.default` "Thinking" placeholder (spec §4.4 — the
+// ONLY surface we override; every real tool/approval/reviewer status passes
+// through untouched).
+const el = (props: Record<string, unknown>) => ({ props });
+
+describe("Codex display gate (spec §4.4)", () => {
+  it("ad SHOWS: null / undefined / reasoningItem.thinking / thinkingShimmer.default / defaultMessage Thinking", () => {
+    expect(isThinkingMessage(null)).toBe(true);
+    expect(isThinkingMessage(undefined)).toBe(true);
+    expect(isThinkingMessage(el({ id: "reasoningItem.thinking" }))).toBe(true);
+    expect(isThinkingMessage(el({ id: "thinkingShimmer.default" }))).toBe(true);
+    expect(isThinkingMessage(el({ defaultMessage: "Thinking" }))).toBe(true);
+  });
+  it("ad HIDDEN: tool / approval / reviewer / arbitrary / non-element", () => {
+    expect(isThinkingMessage(el({ id: "localConversation.approvalRequest.inProgress" }))).toBe(false);
+    expect(isThinkingMessage(el({ id: "localConversation.userInputRequest.inProgress" }))).toBe(false);
+    expect(isThinkingMessage(el({ defaultMessage: "Reading {target}" }))).toBe(false);
+    expect(isThinkingMessage(el({ id: "something.else" }))).toBe(false);
+    expect(isThinkingMessage("a string")).toBe(false);
+    expect(isThinkingMessage(42)).toBe(false);
+  });
+});
diff --git a/test/codex-pollad.test.ts b/test/codex-pollad.test.ts
new file mode 100644
index 0000000..4f36c7f
--- /dev/null
+++ b/test/codex-pollad.test.ts
@@ -0,0 +1,276 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM, VirtualConsole } from "jsdom";
+
+// Regression coverage for the codex "frozen ads" fix (2026-06-11): the baked
+// __VIBE_ADS_AD__ was frozen for the life of the webview — rotation re-patched
+// the bundle on disk but a running Codex panel never re-read it, so users sat
+// on one creative (or the pre-inventory "Your ad here" placeholder) until a
+// full VS Code reload. The block now polls the loopback /ad every 10s with
+// the SAME semantics as claude-code/block.asset.js pollAd:
+//   • changed payload → adopt + RESET all view sessions/impression flags
+//     (old creative's accumulated time never bills against the new one)
+//   • successful-but-empty ×2 (debounced) → no-serve: drop overlay, end
+//     every session, suppress repaint until served again
+//   • fetch error → keep last ad (transient network / CSP-blocked loopback)
+// Plus: the click ping now carries the `ad=` attribution claim (CC parity).
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "codex", "block.asset.js"), "utf8");
+
+const AD_A = "Acme deploys faster than your CI";
+const URL_A = "https://acme.example/lp";
+const AD_B = "Bolt ships 2x faster";
+const URL_B = "https://bolt.example/lp";
+const PAYLOAD_B = { adText: AD_B, clickUrl: URL_B, iconUrl: "",
+  adId: "adB", campaignId: "c2" };
+
+function preparedAsset(): string {
+  const subs: Record<string, string> = {
+    __VIBE_ADS_AD__: JSON.stringify(AD_A),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
+    __VIBE_ADS_CLICKURL__: JSON.stringify(URL_A),
+    __VIBE_ADS_CORR__: JSON.stringify("test.codex.poll"),
+    __VIBE_ADS_DEBUG__: "false",
+    // Large threshold so only the 5s view_tick cadence drives billing
+    // assertions (threshold_met never fires inside these windows).
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+    __VIBE_ADS_ARG__: "e",
+    __VIBE_ADS_JSX__: "d",
+  };
+  let src = ASSET;
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  return src;
+}
+
+interface Harness {
+  dom: JSDOM;
+  doc: Document;
+  pings: string[];
+  advance: (ms: number) => void;
+  /** Drive the block's 10s /ad poll once, deterministically. */
+  pollAd: () => Promise<void>;
+  setAd: (p: Record<string, unknown> | null) => void;
+  setAdError: (on: boolean) => void;
+}
+
+function makeHarness(): Harness {
+  const vc = new VirtualConsole();
+  vc.on("jsdomError", () => { /* anchor-click navigation noise */ });
+  const dom = new JSDOM(`<body><div id="mc" class="chatpanel"></div></body>`,
+    { runScripts: "outside-only", pretendToBeVisual: true,
+      virtualConsole: vc });
+  const win = dom.window as unknown as Window & typeof globalThis & {
+    eval: (s: string) => unknown; fetch: typeof fetch;
+    setInterval: typeof setInterval;
+  };
+  const pings: string[] = [];
+  let adPayload: Record<string, unknown> | null =
+    { adText: AD_A, clickUrl: URL_A, iconUrl: "", adId: "adA", campaignId: "c1" };
+  let adError = false;
+  win.fetch = ((url: string) => {
+    const u = String(url);
+    if (u.endsWith("/ad")) {
+      if (adError) return Promise.reject(new Error("net down"));
+      return Promise.resolve({ json: async () => (adPayload ?? {}) });
+    }
+    pings.push(u);
+    return Promise.resolve({ json: async () => ({}) });
+  }) as unknown as typeof fetch;
+  // Capture interval registrations BEFORE boot so the unique 10s pollAd
+  // can be driven directly (cc-pollad.test.ts pattern). Real timers keep
+  // running underneath (250ms viewTick, 80ms paint loop), same as prod.
+  const intervals: Array<{ fn: () => void; ms: number }> = [];
+  const origSetInterval = win.setInterval.bind(win);
+  (win as unknown as { setInterval: unknown }).setInterval =
+    ((fn: () => void, ms: number) => {
+      intervals.push({ fn, ms });
+      return origSetInterval(fn, ms);
+    }) as unknown as typeof setInterval;
+  let t = 1_700_000_000_000;
+  (win as unknown as { Date: DateConstructor }).Date.now = () => t;
+  win.eval(preparedAsset());
+  const pollFn = intervals.find((i) => i.ms === 10_000)?.fn;
+  if (!pollFn) throw new Error("pollAd interval (10s) not registered");
+  return {
+    dom, doc: dom.window.document, pings,
+    advance: (ms: number) => { t += ms; },
+    pollAd: async () => {
+      pollFn();
+      for (let i = 0; i < 8; i++) await Promise.resolve();
+    },
+    setAd: (p) => { adPayload = p; },
+    setAdError: (on) => { adError = on; },
+  };
+}
+
+function setRect(el: HTMLElement,
+  r: { x: number; y: number; w: number; h: number }): void {
+  (el as unknown as { getBoundingClientRect: () => DOMRect })
+    .getBoundingClientRect = () => ({
+      x: r.x, y: r.y, left: r.x, top: r.y, right: r.x + r.w,
+      bottom: r.y + r.h, width: r.w, height: r.h, toJSON() { return {}; },
+    } as DOMRect);
+}
+
+function liveShimmer(doc: Document): HTMLElement {
+  const el = doc.createElement("span");
+  el.className = "loading-shimmer-pure-text _cadencedShimmer_1bpr9_1 "
+    + "text-size-chat leading-[1.5] select-none truncate";
+  const a = doc.createElement("span"); a.textContent = "Thinking";
+  const b = doc.createElement("span"); b.textContent = "Thinking";
+  el.appendChild(a); el.appendChild(b);
+  setRect(el, { x: 100, y: 200, w: 240, h: 20 });
+  return el;
+}
+
+const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+const count = (pings: string[], re: RegExp) =>
+  pings.filter((u) => re.test(u)).length;
+const TICK = /\/view_tick\?/;
+const RENDERED = /\/impression_rendered\?/;
+const ERROR = /\/error_impression\?/;
+const overlayOf = (doc: Document) =>
+  doc.querySelector('[data-vibe-ads="codex"]');
+const anchorOf = (doc: Document) =>
+  doc.querySelector('[data-vibe-ads="codex"] a[data-vibe-ads-ad]') as
+    HTMLAnchorElement | null;
+
+describe("codex pollAd — live rotation adoption (frozen-ads fix)", () => {
+  it("adopts a rotated ad in place: text + href swap, the old session is "
+    + "ENDED (no carry-over billing), fresh impression events fire, and the "
+    + "next tick attributes to the NEW ad at visible_ms=5000", async () => {
+    const h = makeHarness();
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    await sleep(300);                       // active paint → overlay + ad A
+    expect(anchorOf(h.doc)).toBeTruthy();
+    expect(anchorOf(h.doc)!.textContent).toContain(AD_A);
+    expect(anchorOf(h.doc)!.getAttribute("href")).toBe(URL_A);
+    expect(count(h.pings, RENDERED)).toBe(1);
+
+    h.advance(5_100);                       // accrue one billable tick on A
+    await sleep(400);
+    const ticksA = h.pings.filter((u) => TICK.test(u));
+    expect(ticksA.length).toBe(1);
+    expect(ticksA[0]).toContain("ad=" + encodeURIComponent(AD_A));
+
+    // Host rotates to B (or replaces the patch-time placeholder).
+    h.setAd(PAYLOAD_B);
+    await h.pollAd();
+    await sleep(300);                       // next 80ms paint repaints
+    expect(anchorOf(h.doc)!.textContent).toContain(AD_B);
+    expect(anchorOf(h.doc)!.textContent).not.toContain(AD_A);
+    expect(anchorOf(h.doc)!.getAttribute("href")).toBe(URL_B);
+    // Fresh impression for the new creative.
+    expect(count(h.pings, RENDERED)).toBe(2);
+
+    // The old ad's 5.1s of accumulated view-time must NOT carry over: the
+    // next tick is the NEW ad's FIRST (visible_ms=5000), not a continuation.
+    h.advance(5_100);
+    await sleep(400);
+    const ticks = h.pings.filter((u) => TICK.test(u));
+    expect(ticks.length).toBe(2);
+    expect(ticks[1]).toContain("ad=" + encodeURIComponent(AD_B));
+    expect(ticks[1]).toContain("visible_ms=5000");
+    expect(count(h.pings, ERROR)).toBe(0);
+    h.dom.window.close();
+  }, 15000);
+
+  it("a click after rotation carries the ad= attribution claim for the "
+    + "creative actually on screen", async () => {
+    const h = makeHarness();
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    await sleep(300);
+    h.setAd(PAYLOAD_B);
+    await h.pollAd();
+    await sleep(300);
+    const a = anchorOf(h.doc)!;
+    expect(a.textContent).toContain(AD_B);
+    a.dispatchEvent(new h.dom.window.MouseEvent("click", { bubbles: true }));
+    const clicks = h.pings.filter((u) => /\/click\?/.test(u));
+    expect(clicks.length).toBe(1);
+    expect(clicks[0]).toContain("&ad=" + encodeURIComponent(AD_B));
+    h.dom.window.close();
+  }, 15000);
+});
+
+describe("codex pollAd — empty payload is the no-serve signal", () => {
+  it("TWO consecutive empty polls drop the overlay, END every session, and "
+    + "suppress repaint even with a live shimmer; a served payload re-arms",
+    async () => {
+    const h = makeHarness();
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.advance(5_100);
+    await sleep(400);
+    expect(count(h.pings, TICK)).toBe(1);
+
+    h.setAd(null);                          // host gate: /ad returns {}
+    await h.pollAd();
+    // Debounce: ONE empty read never tears down (could race a rotation).
+    expect(overlayOf(h.doc)).toBeTruthy();
+    await h.pollAd();                       // second consecutive empty
+    await sleep(200);
+    expect(overlayOf(h.doc)).toBeNull();
+
+    // Sessions ended + paint suppressed: the shimmer is still live, yet no
+    // repaint, no ticks, no error_impressions.
+    h.advance(20_000);
+    await sleep(400);
+    expect(overlayOf(h.doc)).toBeNull();
+    expect(count(h.pings, TICK)).toBe(1);
+    expect(count(h.pings, ERROR)).toBe(0);
+
+    // Host serves again → re-arm on the next active paint.
+    h.setAd(PAYLOAD_B);
+    await h.pollAd();
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    expect(anchorOf(h.doc)!.textContent).toContain(AD_B);
+    h.dom.window.close();
+  }, 15000);
+
+  it("ONE empty poll followed by a served payload does NOT drop (debounce "
+    + "counter resets)", async () => {
+    const h = makeHarness();
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+
+    h.setAd(null);
+    await h.pollAd();                       // empty #1
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.setAd({ adText: AD_A, clickUrl: URL_A, iconUrl: "",
+      adId: "adA", campaignId: "c1" });
+    await h.pollAd();                       // served → counter resets
+    h.setAd(null);
+    await h.pollAd();                       // empty #1 again (not #2)
+    await sleep(200);
+    expect(overlayOf(h.doc)).toBeTruthy();
+    h.dom.window.close();
+  }, 15000);
+
+  it("fetch ERRORS never drop: the last ad stays painted and keeps billing "
+    + "(transient network / CSP-blocked loopback = today's status quo)",
+    async () => {
+    const h = makeHarness();
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    await sleep(300);
+    expect(overlayOf(h.doc)).toBeTruthy();
+
+    h.setAdError(true);
+    await h.pollAd();
+    await h.pollAd();
+    await h.pollAd();                       // 3 consecutive FAILURES
+    expect(overlayOf(h.doc)).toBeTruthy();  // keep-last-ad preserved
+    h.advance(5_100);
+    await sleep(400);
+    expect(count(h.pings, TICK)).toBe(1);   // session alive
+    h.dom.window.close();
+  }, 15000);
+});
diff --git a/test/codex-viewtimer.test.ts b/test/codex-viewtimer.test.ts
new file mode 100644
index 0000000..32b5f61
--- /dev/null
+++ b/test/codex-viewtimer.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { JSDOM } from "jsdom";
+
+// Regression coverage for the codex view-timer phantom-billing loop (the
+// "why am I failing recently" report: a backgrounded Codex panel spammed
+// codex.view.error_impression every ~5s for 31 minutes on one stuck ad).
+//
+// Three fixes in extension/src/adapters/codex/block.asset.js:
+//   #1 viewEnd() on idle drop  — the session no longer ticks off-screen.
+//   #2 MAX_SESSION_MS = THRESHOLD_MS — view_threshold_met becomes the
+//      billing path; error_impression no longer fires before it.
+//   #3 pause while document.hidden — a hidden panel accrues no view-time.
+
+const ASSET = readFileSync(
+  join(__dirname, "..", "src", "adapters", "codex", "block.asset.js"), "utf8");
+
+// Small threshold so the tests run in ~1s instead of 15s. With cap===threshold
+// (fix #2) MAX_SESSION_MS tracks this too. TICK_MS is hardcoded 5000 in the
+// asset, so no view_tick fires inside these short windows — keeps assertions
+// about threshold_met vs error_impression unambiguous.
+function preparedAsset(thresholdMs: number): string {
+  const subs: Record<string, string> = {
+    __VIBE_ADS_AD__: JSON.stringify("turbopuffer: like RAG, but way better"),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ct"),
+    __VIBE_ADS_CLICKURL__: JSON.stringify("https://turbopuffer.example/lp"),
+    __VIBE_ADS_CORR__: JSON.stringify("test.codex.vt"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: String(thresholdMs),
+    __VIBE_ADS_ARG__: "e",
+    __VIBE_ADS_JSX__: "d",
+  };
+  let src = ASSET;
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  return src;
+}
+
+interface Harness {
+  dom: JSDOM;
+  doc: Document;
+  pings: string[];
+  setHidden: (h: boolean) => void;
+}
+
+function makeDom(hidden = false): Harness {
+  const dom = new JSDOM(`<body><div id="mc" class="chatpanel"></div></body>`,
+    { runScripts: "outside-only", pretendToBeVisual: true });
+  const pings: string[] = [];
+  // Codex ping() is fetch-only (no sendBeacon). Record every loopback URL.
+  (dom.window as unknown as { fetch: typeof fetch }).fetch = ((u: string) => {
+    pings.push(String(u));
+    return Promise.resolve({ json: async () => ({}) });
+  }) as unknown as typeof fetch;
+  let _hidden = hidden;
+  Object.defineProperty(dom.window.document, "hidden", {
+    configurable: true, get: () => _hidden,
+  });
+  return {
+    dom, doc: dom.window.document, pings,
+    setHidden: (h: boolean) => { _hidden = h; },
+  };
+}
+
+function setRect(el: HTMLElement,
+  r: { x: number; y: number; w: number; h: number }): void {
+  (el as unknown as { getBoundingClientRect: () => DOMRect })
+    .getBoundingClientRect = () => ({
+      x: r.x, y: r.y, left: r.x, top: r.y, right: r.x + r.w,
+      bottom: r.y + r.h, width: r.w, height: r.h, toJSON() { return {}; },
+    } as DOMRect);
+}
+
+function liveShimmer(doc: Document): HTMLElement {
+  const el = doc.createElement("span");
+  el.className = "loading-shimmer-pure-text _cadencedShimmer_1bpr9_1 "
+    + "text-size-chat leading-[1.5] select-none truncate";
+  const a = doc.createElement("span"); a.textContent = "Thinking";
+  const b = doc.createElement("span"); b.textContent = "Thinking";
+  el.appendChild(a); el.appendChild(b);
+  setRect(el, { x: 100, y: 200, w: 240, h: 20 });
+  return el;
+}
+
+function boot(h: Harness, thresholdMs: number): void {
+  (h.dom.window as unknown as { eval: (s: string) => unknown })
+    .eval(preparedAsset(thresholdMs));
+}
+const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
+const count = (pings: string[], re: RegExp) =>
+  pings.filter((u) => re.test(u)).length;
+const THRESHOLD = /\/view_threshold_met\?/;
+const ERROR = /\/error_impression\?/;
+
+describe("codex view-timer phantom-billing fixes", () => {
+  it("fix #2: view_threshold_met is the billing path; error_impression "
+    + "never fires (no 5s spam)", async () => {
+    const h = makeDom();
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    boot(h, 600);
+    await sleep(1100);                  // > threshold
+    expect(count(h.pings, THRESHOLD)).toBe(1);
+    expect(count(h.pings, ERROR)).toBe(0);
+    await sleep(1400);                  // stay active well past old 5s cap
+    expect(count(h.pings, THRESHOLD)).toBe(1);   // one-shot per session
+    expect(count(h.pings, ERROR)).toBe(0);       // still no error spam
+    h.dom.window.close();
+  }, 10000);
+
+  it("fix #1: the session ENDS on idle-drop and re-arms on the next turn "
+    + "(no immortal off-screen accumulator)", async () => {
+    const h = makeDom();
+    const row = liveShimmer(h.doc);
+    h.doc.getElementById("mc")!.appendChild(row);
+    boot(h, 600);
+    await sleep(1100);
+    expect(count(h.pings, THRESHOLD)).toBe(1);
+
+    // Turn ends: rect collapses → findRow releases → dropOverlay → viewEnd.
+    setRect(row, { x: 0, y: 0, w: 0, h: 0 });
+    await sleep(1800);                  // > GRACE_MS (1500)
+    expect(h.doc.querySelector('[data-vibe-ads="codex"]')).toBeNull();
+    const afterIdle = count(h.pings, THRESHOLD);
+
+    // New turn: a FRESH session must fire a SECOND threshold_met. If the old
+    // session had merely been hidden (not ended) its thresholdMet=true would
+    // suppress this — so 2 proves the record was actually deleted.
+    setRect(row, { x: 100, y: 200, w: 240, h: 20 });
+    await sleep(1100);
+    expect(count(h.pings, THRESHOLD)).toBe(afterIdle + 1);
+    expect(count(h.pings, ERROR)).toBe(0);
+    h.dom.window.close();
+  }, 12000);
+
+  it("fix #3: a hidden webview accrues no view-time and resumes when shown",
+    async () => {
+    const h = makeDom(true);           // start hidden
+    h.doc.getElementById("mc")!.appendChild(liveShimmer(h.doc));
+    boot(h, 600);
+    await sleep(1200);                  // well past threshold, but hidden
+    expect(count(h.pings, THRESHOLD)).toBe(0);   // paused → nothing billed
+    expect(count(h.pings, ERROR)).toBe(0);
+
+    h.setHidden(false);                // panel revealed → resume
+    await sleep(1100);
+    expect(count(h.pings, THRESHOLD)).toBe(1);
+    expect(count(h.pings, ERROR)).toBe(0);
+    h.dom.window.close();
+  }, 10000);
+});
diff --git a/test/codexFallback.test.ts b/test/codexFallback.test.ts
new file mode 100644
index 0000000..06f55ec
--- /dev/null
+++ b/test/codexFallback.test.ts
@@ -0,0 +1,57 @@
+/** Codex discovery policy ("codex fallback") — the truth table that decides
+ *  whether the Codex adapter is constructed at activation.
+ *
+ *  The contract being locked:
+ *    • Explicit opt-out beats everything (support remediation).
+ *    • Explicit opt-in beats the claude state (today's S9 smoke path).
+ *    • Default on a claude-COMPATIBLE machine is OFF — the population the
+ *      S9 "crashed Claude Code once" guard protects keeps today's behavior.
+ *    • Default on a claude-incompatible/absent machine is ON — the fallback
+ *      that makes a Codex-only install serve instead of being dead weight.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { codexDiscoveryEnabled } from "../src/activation/codexFallback";
+
+describe("codexDiscoveryEnabled truth table", () => {
+  it("opt-out beats opt-in AND the fallback", () => {
+    expect(codexDiscoveryEnabled(
+      { optIn: true, optOut: true, claudeCompatible: true })).toBe(false);
+    expect(codexDiscoveryEnabled(
+      { optIn: true, optOut: true, claudeCompatible: false })).toBe(false);
+    expect(codexDiscoveryEnabled(
+      { optIn: false, optOut: true, claudeCompatible: false })).toBe(false);
+  });
+
+  it("opt-in wins regardless of the claude state (S9 smoke path unchanged)", () => {
+    expect(codexDiscoveryEnabled(
+      { optIn: true, optOut: false, claudeCompatible: true })).toBe(true);
+    expect(codexDiscoveryEnabled(
+      { optIn: true, optOut: false, claudeCompatible: false })).toBe(true);
+  });
+
+  it("default on a claude-compatible machine stays OFF (prime-directive guard)", () => {
+    expect(codexDiscoveryEnabled(
+      { optIn: false, optOut: false, claudeCompatible: true })).toBe(false);
+  });
+
+  it("default on a claude-incompatible machine is ON (the codex fallback)", () => {
+    expect(codexDiscoveryEnabled(
+      { optIn: false, optOut: false, claudeCompatible: false })).toBe(true);
+  });
+});
+
+describe("log.ts codexDisabled (env legs)", () => {
+  // vi.importActual bypasses setup.ts's process-wide log mock — these legs
+  // exercise the REAL sentinel/env reader. Only the env legs are asserted
+  // (the sentinel leg would depend on the developer's real ~/.vibe-ads).
+  it("KICKBACKS_CODEX=0 / VIBE_ADS_CODEX=0 opt out; =1 does not", async () => {
+    const { codexDisabled } =
+      await vi.importActual<typeof import("../src/log")>("../src/log");
+    vi.stubEnv("KICKBACKS_CODEX", "0");
+    expect(codexDisabled()).toBe(true);
+    vi.stubEnv("KICKBACKS_CODEX", "1");
+    vi.stubEnv("VIBE_ADS_CODEX", "0");
+    expect(codexDisabled()).toBe(true);
+    vi.unstubAllEnvs();
+  });
+});
diff --git a/test/commands.test.ts b/test/commands.test.ts
new file mode 100644
index 0000000..c428781
--- /dev/null
+++ b/test/commands.test.ts
@@ -0,0 +1,429 @@
+// End-to-end coverage for every `vscode.commands.registerCommand` call the
+// extension makes at activation time. The activate() flow is allowed to be
+// big; the contract we lock in here is small: when a user fires a registered
+// command id (either from the palette, the status-bar menu, or a keybinding),
+// the side effect that the user *sees* — a browser tab, a toast, a cleared
+// session, a config doc opening, a restored binary — must actually happen.
+//
+// Test-only mock surface used:
+//   - _opened       : every vscode.env.openExternal(uri) call           (browser tab)
+//   - _shown        : every showInformationMessage / showErrorMessage    (toasts)
+//   - _openedDocs   : every workspace.openTextDocument(path) call         (config doc)
+//   - commands._handlers / _executed                                      (dispatch)
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// Mock the log module so "signed in" assertions reflect real token state and
+// tests never read dev-machine sentinels. Same trick auth.test.ts uses.
+vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
+  dlogRaw: () => {}, debugIconDataUri: () => "",
+  codexEnabled: () => false, codexDisabled: () => false,
+  codexCliEnabled: () => false, testHooksEnabled: () => false,
+  LOG_PATH: "/tmp/test-log" }));
+
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { setupAdRotation, type AdRotationDeps }
+  from "../src/activation/adRotation";
+import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";
+import {
+  makeContext, secrets, _opened, _shown, _openedDocs, commands, window,
+} from "./mocks/vscode";
+
+/** Test-double Claude Code adapter (compatible build). */
+const mkAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+const mkCodex = () => ({
+  name: "codex" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
+  version: () => "26.513.21555",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+/** Global fetch stub. Returns the broker 307+location for sign-in start,
+ *  an immediate access_token for sign-in poll, and a benign 200 for every
+ *  other backend call (portfolio / kill / earnings / consent). Returning
+ *  access_token on the FIRST poll avoids the 1.5s sleep inside signIn(). */
+const stubFetch = () => {
+  const calls: string[] = [];
+  const f = vi.fn(async (input: unknown, init?: unknown) => {
+    const url = typeof input === "string" ? input : String(input);
+    calls.push(url);
+    if (url.includes("/v1/auth/extension/start")) {
+      return {
+        status: 307,
+        headers: { get: (k: string) =>
+          k.toLowerCase() === "location" ? "https://broker.test/auth?state=ST1" : null },
+      } as unknown as Response;
+    }
+    if (url.includes("/v1/auth/extension/poll")) {
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT-INT", refresh_token: "RT-INT", expires_in: 3600 }) } as Response;
+    }
+    // Any other backend call: keep it benign so activation finishes without
+    // network noise. ConsentClient.read() returns null on missing
+    // current_tos_version, KillSwitchClient treats missing fields as
+    // "not killed", PortfolioClient handles {} as "no ad". Suppresses the
+    // consent toast that would otherwise pollute _shown.
+    return { ok: true, status: 200, json: async () => ({}) } as Response;
+  });
+  // AuthClient captures `fetch` as the default parameter value at construct
+  // time, so vi.stubGlobal must happen BEFORE activate(). The cleanup runs
+  // in afterEach via vi.unstubAllGlobals.
+  vi.stubGlobal("fetch", f);
+  return { f, calls };
+};
+
+/** Boot the extension hermetically. Returns the cleanup hook. The HOME
+ *  redirect is non-optional: AuthClient writes ~/.kickbacks/auth.json, and
+ *  we will NOT touch the real user's auth file from a test. */
+async function boot(opts: { codex?: boolean } = {}) {
+  const home = mkdtempSync(join(tmpdir(), "kb-cmds-"));
+  const prevHome = process.env.HOME;
+  const prevUser = process.env.USERPROFILE;
+  process.env.HOME = home;
+  process.env.USERPROFILE = home;
+  const adapter = mkAdapter();
+  const codex = opts.codex ? mkCodex() : null;
+  const statusBar = { set: vi.fn(), dispose: vi.fn() };
+  __wireForTest({ adapter, codexAdapter: codex, statusBar });
+  const fetched = stubFetch();
+  const ctx = makeContext();
+  await activate(ctx as never);
+  return {
+    home, adapter, codex, statusBar, ctx, fetched,
+    async dispose() {
+      await deactivate();
+      if (prevHome !== undefined) process.env.HOME = prevHome; else delete process.env.HOME;
+      if (prevUser !== undefined) process.env.USERPROFILE = prevUser; else delete process.env.USERPROFILE;
+      try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
+    },
+  };
+}
+
+beforeEach(() => {
+  // Fresh slate per test. Module-globals (secrets, commands._handlers,
+  // _opened, _shown, _openedDocs) survive across tests in the same file
+  // because the vscode mock module is shared.
+  secrets.clear();
+  commands._handlers.clear();
+  commands._executed.length = 0;
+  _opened.length = 0;
+  _shown.length = 0;
+  _openedDocs.length = 0;
+  __wireForTest({});
+});
+
+afterEach(() => { vi.unstubAllGlobals(); });
+
+// ---------------------------------------------------------------------------
+// Registration: every contributed command id is wired to a handler.
+// Source of truth is package.json — if a new command is added there without
+// a registerCommand() call, this fails loudly.
+// ---------------------------------------------------------------------------
+describe("contributed commands → registered handlers", () => {
+  const CONTRIBUTED = [
+    "kickbacks.signIn",
+    "kickbacks.signOut",
+    "kickbacks.restore",
+    "kickbacks.status",
+    "kickbacks.debugMenu",
+    "kickbacks.editConfig",
+    "vibe-ads.signIn",
+    "vibe-ads.signOut",
+    "vibe-ads.restore",
+    "vibe-ads.status",
+    "vibe-ads.debugMenu",
+  ];
+
+  it("registers every contributed command id", async () => {
+    const t = await boot();
+    try {
+      for (const id of CONTRIBUTED) {
+        expect(commands._handlers.has(id), `missing handler: ${id}`).toBe(true);
+      }
+    } finally { await t.dispose(); }
+  });
+
+  it("ccVersion wire label: the CLAUDE version wins when claude is"
+    + " compatible (codex present or not)", async () => {
+    // Pins the codex-only counterpart in extension.test.ts: a compatible
+    // Claude Code must keep reporting its own version on the killswitch /
+    // portfolio wire — never the codex/<ver> label.
+    const t = await boot({ codex: true });
+    try {
+      expect(t.fetched.calls.some((u) => u.includes("version=2.1.143")),
+        "killswitch poll must carry the claude version").toBe(true);
+      expect(t.fetched.calls.some((u) => u.includes("codex%2F"))).toBe(false);
+    } finally { await t.dispose(); }
+  });
+
+  it("every legacy vibe-ads.* alias points to the SAME closure as its kickbacks.* twin", async () => {
+    const t = await boot();
+    try {
+      // Shared-closure parity: a user keybinding on a legacy id should fire
+      // the exact same code path, never a drifted copy. Reference equality
+      // is the strongest assertion we can make at this layer.
+      const pairs: [string, string][] = [
+        ["kickbacks.signIn",    "vibe-ads.signIn"],
+        ["kickbacks.signOut",   "vibe-ads.signOut"],
+        ["kickbacks.restore",   "vibe-ads.restore"],
+        ["kickbacks.status",    "vibe-ads.status"],
+        ["kickbacks.debugMenu", "vibe-ads.debugMenu"],
+      ];
+      for (const [a, b] of pairs) {
+        expect(commands._handlers.get(a), `alias drift: ${a} vs ${b}`)
+          .toBe(commands._handlers.get(b));
+      }
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// kickbacks.signIn → opens the broker URL in the system browser AND
+// stores the returned tokens. This is the regression class the user is
+// most worried about ("did clicking the button actually trigger a browser").
+// ---------------------------------------------------------------------------
+describe("kickbacks.signIn", () => {
+  it("opens the broker URL via vscode.env.openExternal", async () => {
+    const t = await boot();
+    try {
+      await commands.executeCommand("kickbacks.signIn");
+      expect(_opened.some((u) => u.includes("https://broker.test/auth"))).toBe(true);
+      // And the access token landed in secrets — the round-trip happened.
+      expect(await t.ctx.secrets.get("kickbacks.access")).toBe("AT-INT");
+    } finally { await t.dispose(); }
+  });
+
+  it("the legacy vibe-ads.signIn alias also opens the broker URL", async () => {
+    const t = await boot();
+    try {
+      await commands.executeCommand("vibe-ads.signIn");
+      expect(_opened.some((u) => u.includes("https://broker.test/auth"))).toBe(true);
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// kickbacks.signOut → clears the in-process token, restores Claude Code,
+// and shows the "signed out" toast.
+// ---------------------------------------------------------------------------
+describe("kickbacks.signOut", () => {
+  it("clears the access token, calls adapter.restore, and toasts", async () => {
+    const t = await boot();
+    try {
+      // First sign in so signOut has something to clear.
+      await commands.executeCommand("kickbacks.signIn");
+      expect(await t.ctx.secrets.get("kickbacks.access")).toBe("AT-INT");
+      t.adapter.restore.mockClear();
+
+      await commands.executeCommand("kickbacks.signOut");
+
+      expect(await t.ctx.secrets.get("kickbacks.access")).toBeUndefined();
+      expect(t.adapter.restore).toHaveBeenCalled();
+      expect(_shown.some((s) =>
+        s.kind === "info" && /signed out/i.test(s.text))).toBe(true);
+      // Status bar must reflect the new state — silent failure to update
+      // the badge is exactly the "button does nothing" symptom we're guarding.
+      expect(t.statusBar.set).toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "signed-out" }));
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Regression (audit #20): sign-out must clear the live ad-rotation state.
+// Pre-fix, doSignOut restored the CC files but left the REAL ad in adRef and
+// the rotation queue — the status bar kept serving it signed-out, the next
+// rotation tick re-patched CC right after the restore, and metrics misrouted
+// the real session tokens to /v1/metrics/demo.
+// ---------------------------------------------------------------------------
+describe("kickbacks.signOut → ad-rotation clear", () => {
+  const mkAd = (adId: string): PatchAd => ({
+    adId, campaignId: "c-" + adId, adText: "Ad " + adId, iconRef: "i",
+    iconUrl: "", clickUrl: "https://x.test", bannerEnabled: false,
+    sessionToken: "tok-" + adId });
+  const mkResp = (ads: PatchAd[]): PortfolioResponse => ({
+    ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
+    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null });
+
+  it("drops the leftover real ads from the live rotation on sign-out", async () => {
+    const t = await boot();
+    const timers: NodeJS.Timeout[] = [];
+    try {
+      await commands.executeCommand("kickbacks.signIn");
+      // Stand up a live rotation holding REAL ads. The hermetic boot serves
+      // no ad (stub portfolio is empty) so activation never created one —
+      // this registers as THE live rotation the sign-out command must reach.
+      const ads = [mkAd("real-1"), mkAd("real-2")];
+      const adRef = { current: ads[0] as PatchAd | null };
+      const activeAdRef = { current: ads[0] as PatchAd | null };
+      const deps = {
+        adapter: { applyPatch: vi.fn(() => ({ ok: true })) },
+        portfolio: { fetchPortfolio: async () => null,
+                     fetchDemoPortfolio: async () => null },
+        auth: { accessToken: () => "tok", clientId: () => "cid" },
+        debugCtl: { setPortfolioAd: vi.fn() },
+        session: { set: vi.fn() },
+        ccVersion: "2.1.167", port: 12345,
+        patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
+        activeAdRef, corrRef: { current: "corr" }, adRef,
+        impDedupe: { reset: vi.fn() }, reapplyCodex: null, timers,
+      } as unknown as AdRotationDeps;
+      const handle = setupAdRotation(deps, mkResp(ads));
+      expect(handle.rotationTimer).not.toBeNull();
+
+      await commands.executeCommand("kickbacks.signOut");
+
+      // The command path must clear the rotation: queue gone, timer disarmed,
+      // shared ad refs nulled — no surface can keep serving the real ad
+      // signed-out and no rotation tick can re-patch CC post-restore.
+      expect(handle.adQueue).toEqual([]);
+      expect(handle.rotationTimer).toBeNull();
+      expect(adRef.current).toBeNull();
+      expect(activeAdRef.current).toBeNull();
+    } finally {
+      timers.forEach((tm) => clearInterval(tm));
+      await t.dispose();
+    }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Regression: signing out then back in must RE-ENABLE injection. doSignOut
+// forces injection OFF (a signed-out session can't serve ads); pre-fix the
+// sign-in path only re-enabled on neverToggled(), so the OFF that sign-out
+// wrote stuck forever and the user came back silently disabled. The fix
+// remembers the pre-sign-out state and restores it on the next sign-in.
+// ---------------------------------------------------------------------------
+describe("sign-out → sign-in re-enables injection", () => {
+  it("re-applies the patch after a sign-out/sign-in cycle", async () => {
+    const t = await boot();
+    try {
+      // First sign-in: first-run default-on patches the binary.
+      await commands.executeCommand("kickbacks.signIn");
+      expect(t.adapter.applyPatch).toHaveBeenCalled();
+
+      // Sign out: injection forced off, binary restored.
+      await commands.executeCommand("kickbacks.signOut");
+      t.adapter.applyPatch.mockClear();
+
+      // Sign back in: injection must come back on its own (Tier 2).
+      await commands.executeCommand("kickbacks.signIn");
+      expect(t.adapter.applyPatch).toHaveBeenCalled();
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// kickbacks.restore → byte-exact revert of the patched binary(s).
+// ---------------------------------------------------------------------------
+describe("kickbacks.restore", () => {
+  it("calls adapter.restore() exactly once", async () => {
+    const t = await boot();
+    try {
+      t.adapter.restore.mockClear();
+      await commands.executeCommand("kickbacks.restore");
+      expect(t.adapter.restore).toHaveBeenCalledTimes(1);
+    } finally { await t.dispose(); }
+  });
+
+  it("also calls codex.restore() when a Codex target is wired", async () => {
+    const t = await boot({ codex: true });
+    try {
+      t.adapter.restore.mockClear();
+      t.codex!.restore.mockClear();
+      await commands.executeCommand("kickbacks.restore");
+      expect(t.adapter.restore).toHaveBeenCalledTimes(1);
+      expect(t.codex!.restore).toHaveBeenCalled();
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// kickbacks.status → information toast carrying the live state. The exact
+// wording is not a contract; the regression we lock in is "the toast fires
+// AND mentions the signed-in/out state."
+// ---------------------------------------------------------------------------
+describe("kickbacks.status", () => {
+  it("shows an info toast that reflects signed-out state", async () => {
+    const t = await boot();
+    try {
+      _shown.length = 0; // ignore any activation-time toasts
+      await commands.executeCommand("kickbacks.status");
+      const last = _shown.filter((s) => s.kind === "info").pop();
+      expect(last, "no info toast fired").toBeDefined();
+      expect(last!.text).toMatch(/signed out/i);
+      expect(last!.text).toMatch(/kickbacks/i);
+    } finally { await t.dispose(); }
+  });
+
+  it("shows 'signed in' once a token is held", async () => {
+    const t = await boot();
+    try {
+      await commands.executeCommand("kickbacks.signIn");
+      _shown.length = 0;
+      await commands.executeCommand("kickbacks.status");
+      const last = _shown.filter((s) => s.kind === "info").pop();
+      expect(last!.text).toMatch(/signed in/i);
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// kickbacks.debugMenu → opens the QuickPick. We assert the menu actually
+// asks the user for input AND surfaces every must-have row. The individual
+// row → command routing is already covered by debug.test.ts.
+// ---------------------------------------------------------------------------
+describe("kickbacks.debugMenu", () => {
+  it("opens a QuickPick containing every documented menu row", async () => {
+    const t = await boot();
+    try {
+      let captured: { id?: string; label?: string }[] = [];
+      const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+        async (items: unknown) => {
+          captured = items as { id?: string }[]; return undefined;
+        });
+      await commands.executeCommand("kickbacks.debugMenu");
+      // Assert BEFORE mockRestore — vitest's restore clears the spy's call
+      // history alongside removing it (caught the hard way: spy fired but
+      // the post-restore expect saw zero calls). Lesson preserved in-line
+      // so future tests in this file don't repeat the trap.
+      expect(qp).toHaveBeenCalled();
+      const ids = captured.map((i) => i.id);
+      for (const required of ["toggle", "config", "reapply", "reload",
+                              "restore", "openlog", "builtinfo"]) {
+        expect(ids, `missing menu row: ${required}`).toContain(required);
+      }
+      qp.mockRestore();
+    } finally { await t.dispose(); }
+  });
+});
+
+// ---------------------------------------------------------------------------
+// kickbacks.editConfig → materialises ~/.vibe-ads/config.json if missing
+// and opens it in the editor. The observable side effect we assert on is
+// workspace.openTextDocument being called with a config-shaped path.
+// ---------------------------------------------------------------------------
+describe("kickbacks.editConfig", () => {
+  it("opens the config file in the editor", async () => {
+    const t = await boot();
+    try {
+      _openedDocs.length = 0;
+      await commands.executeCommand("kickbacks.editConfig");
+      // ensureConfigFile() resolves to either ~/.kickbacks/config.json or
+      // ~/.vibe-ads/config.json depending on which exists; match either.
+      expect(_openedDocs.some((p) => /config\.json$/.test(p)),
+        `openTextDocument was not called with a config path; saw: ${JSON.stringify(_openedDocs)}`
+      ).toBe(true);
+    } finally { await t.dispose(); }
+  });
+});
diff --git a/test/consent.test.ts b/test/consent.test.ts
new file mode 100644
index 0000000..0b65e3e
--- /dev/null
+++ b/test/consent.test.ts
@@ -0,0 +1,133 @@
+/** Regression tests for wave-2I-F03 — first-run consent prompt.
+ *
+ * The prompt is one-time per (extension instance × tos_version), only
+ * fires for signed-in users who have not accepted the live ToS, and
+ * never throws into activation.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { ConsentClient } from "../src/consent/client";
+import { maybePromptForConsent } from "../src/consent/prompt";
+
+const baseState = {
+  telemetry_opt_in: false,
+  tos_accepted_version: null,
+  current_tos_version: "2026-05-17",
+};
+
+function makeFetch(getResp: any, postResp: any = { ok: true, json: async () => ({ tos_version: "2026-05-17", accepted_at: "now" }) }) {
+  return (async (url: string, init?: any) => {
+    if (init?.method === "POST") return postResp;
+    return getResp;
+  }) as unknown as typeof fetch;
+}
+
+function makeCtx(initial: Record<string, unknown> = {}) {
+  const store = new Map(Object.entries(initial));
+  return {
+    globalState: {
+      get: (k: string) => store.get(k),
+      update: async (k: string, v: unknown) => { store.set(k, v); },
+    },
+    _store: store,
+  } as any;
+}
+
+function makeVsc() {
+  const calls: any[] = [];
+  return {
+    calls,
+    window: {
+      showInformationMessage: vi.fn(async (..._args: any[]) => {
+        calls.push(_args);
+        return undefined; // dismissed by default
+      }),
+    },
+    env: { openExternal: vi.fn(async () => true) },
+    Uri: { parse: (s: string) => s },
+  } as any;
+}
+
+describe("ConsentClient", () => {
+  it("returns parsed state on 200", async () => {
+    const c = new ConsentClient("http://x", () => "tok",
+      makeFetch({ ok: true, json: async () => baseState }));
+    expect(await c.read()).toEqual({
+      telemetryOptIn: false,
+      tosAcceptedVersion: null,
+      currentTosVersion: "2026-05-17",
+    });
+  });
+  it("null when signed out", async () => {
+    const c = new ConsentClient("http://x", () => null,
+      makeFetch({ ok: true, json: async () => baseState }));
+    expect(await c.read()).toBeNull();
+  });
+  it("null on bad json (no current_tos_version)", async () => {
+    const c = new ConsentClient("http://x", () => "tok",
+      makeFetch({ ok: true, json: async () => ({}) }));
+    expect(await c.read()).toBeNull();
+  });
+  it("accept POSTs and returns version", async () => {
+    const c = new ConsentClient("http://x", () => "tok",
+      makeFetch({ ok: true, json: async () => baseState }));
+    expect(await c.accept()).toEqual({ tosVersion: "2026-05-17", acceptedAt: "now" });
+  });
+});
+
+describe("maybePromptForConsent", () => {
+  it("no-ops when signed out (read returns null)", async () => {
+    const client = new ConsentClient("http://x", () => null, makeFetch({ ok: true, json: async () => baseState }));
+    const vsc = makeVsc();
+    await maybePromptForConsent({ client, ctx: makeCtx(), vsc });
+    expect(vsc.window.showInformationMessage).not.toHaveBeenCalled();
+  });
+
+  it("no-ops when already consented to current version", async () => {
+    const accepted = { ...baseState, telemetry_opt_in: true, tos_accepted_version: "2026-05-17" };
+    const client = new ConsentClient("http://x", () => "tok", makeFetch({ ok: true, json: async () => accepted }));
+    const vsc = makeVsc();
+    await maybePromptForConsent({ client, ctx: makeCtx(), vsc });
+    expect(vsc.window.showInformationMessage).not.toHaveBeenCalled();
+  });
+
+  it("does not re-prompt if globalState already marked shown for this version", async () => {
+    const client = new ConsentClient("http://x", () => "tok", makeFetch({ ok: true, json: async () => baseState }));
+    const vsc = makeVsc();
+    const ctx = makeCtx({ "vibe-ads.consent.promptShownForVersion": "2026-05-17" });
+    await maybePromptForConsent({ client, ctx, vsc });
+    expect(vsc.window.showInformationMessage).not.toHaveBeenCalled();
+  });
+
+  it("prompts when fresh + signed in + not consented", async () => {
+    const client = new ConsentClient("http://x", () => "tok", makeFetch({ ok: true, json: async () => baseState }));
+    const vsc = makeVsc();
+    const ctx = makeCtx();
+    await maybePromptForConsent({ client, ctx, vsc });
+    expect(vsc.window.showInformationMessage).toHaveBeenCalledOnce();
+    // dismissed by default → globalState marks shown for this version
+    expect(ctx._store.get("vibe-ads.consent.promptShownForVersion")).toBe("2026-05-17");
+  });
+
+  it("Agree path POSTs and marks shown only after success", async () => {
+    const client = new ConsentClient("http://x", () => "tok", makeFetch(
+      { ok: true, json: async () => baseState },
+      { ok: true, json: async () => ({ tos_version: "2026-05-17", accepted_at: "2026-05-17T00:00:00Z" }) },
+    ));
+    const vsc = makeVsc();
+    vsc.window.showInformationMessage = vi.fn(async () => "Agree") as any;
+    const ctx = makeCtx();
+    await maybePromptForConsent({ client, ctx, vsc });
+    expect(ctx._store.get("vibe-ads.consent.promptShownForVersion")).toBe("2026-05-17");
+  });
+
+  it("Privacy Policy path opens URL and does NOT mark shown", async () => {
+    const client = new ConsentClient("http://x", () => "tok", makeFetch({ ok: true, json: async () => baseState }));
+    const vsc = makeVsc();
+    vsc.window.showInformationMessage = vi.fn(async () => "Privacy Policy") as any;
+    const ctx = makeCtx();
+    await maybePromptForConsent({ client, ctx, vsc });
+    expect(vsc.env.openExternal).toHaveBeenCalledOnce();
+    // Not marked — so next session re-surfaces (gentle, not pushy)
+    expect(ctx._store.has("vibe-ads.consent.promptShownForVersion")).toBe(false);
+  });
+});
diff --git a/test/consentFlow.test.ts b/test/consentFlow.test.ts
new file mode 100644
index 0000000..c899e7e
--- /dev/null
+++ b/test/consentFlow.test.ts
@@ -0,0 +1,202 @@
+// Consent prompt wired into the full activate() lifecycle: a signed-in user
+// whose tos_accepted_version differs from current_tos_version should see the
+// one-time prompt and, on Agree, POST to /v1/me/consent. Existing
+// consent.test.ts unit-tests the prompt in isolation; this file pins the
+// integration — that activate() actually invokes maybePromptForConsent and
+// the post-Agree POST is observed on the wire.
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { ConsentClient } from "../src/consent/client";
+import { makeContext, secrets, _opened, _shown, _openedDocs, commands, window }
+  from "./mocks/vscode";
+
+const mkAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+function stubFetch(opts: { hasAccepted?: boolean;
+                            acceptResponseOk?: boolean } = {}) {
+  const calls: { url: string; method: string;
+                 headers: Record<string, string> }[] = [];
+  const f = vi.fn(async (input: unknown, init?: { method?: string;
+      headers?: Record<string, string> }) => {
+    const url = typeof input === "string" ? input : String(input);
+    const method = (init?.method || "GET").toUpperCase();
+    calls.push({ url, method, headers: init?.headers || {} });
+    if (url.includes("/v1/me/consent")) {
+      if (method === "POST") {
+        if (opts.acceptResponseOk === false) {
+          return { ok: false, status: 500, json: async () => ({}) } as Response;
+        }
+        return { ok: true, status: 200, json: async () =>
+          ({ tos_version: "v2", accepted_at: "2026-01-01T00:00:00Z" }) } as Response;
+      }
+      // GET — the user has accepted an older version OR not at all.
+      return { ok: true, status: 200, json: async () => ({
+        telemetry_opt_in: !!opts.hasAccepted,
+        tos_accepted_version: opts.hasAccepted ? "v2" : "v1",
+        current_tos_version: "v2",
+      }) } as Response;
+    }
+    // benign 200 for everything else (portfolio / killswitch / earnings).
+    return { ok: true, status: 200, json: async () => ({}) } as Response;
+  });
+  vi.stubGlobal("fetch", f);
+  return { f, calls };
+}
+
+beforeEach(() => {
+  secrets.clear();
+  commands._handlers.clear();
+  commands._executed.length = 0;
+  _opened.length = 0;
+  _shown.length = 0;
+  _openedDocs.length = 0;
+});
+
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe("consent flow — activate() wires maybePromptForConsent end-to-end", () => {
+
+  it("user with stale tos_accepted_version sees the prompt and, on Agree,"
+    + " POSTs /v1/me/consent", async () => {
+    const home = mkdtempSync(join(tmpdir(), "kb-consent-"));
+    const prevHome = process.env.HOME;
+    const prevUser = process.env.USERPROFILE;
+    process.env.HOME = home; process.env.USERPROFILE = home;
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });
+    const fetched = stubFetch({ hasAccepted: false });
+    // Force the prompt to resolve to "Agree" without showing anything to a
+    // real human. Restore the default in afterEach via vi.restoreAllMocks
+    // is not needed — the mock module is process-shared and beforeEach
+    // resets the _shown ring buffer.
+    const origShow = window.showInformationMessage;
+    window.showInformationMessage = (async (msg: unknown, ..._rest: unknown[]) => {
+      _shown.push({ kind: "info", text: String(msg) });
+      return "Agree";
+    }) as never;
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-CONSENT");
+
+    try {
+      await activate(ctx as never);
+      // maybePromptForConsent is fire-and-forget; drain a few ticks so the
+      // GET → showInformationMessage → POST chain lands.
+      await new Promise((r) => setTimeout(r, 50));
+
+      const gets = fetched.calls.filter(
+        (c) => c.url.endsWith("/v1/me/consent") && c.method === "GET");
+      const posts = fetched.calls.filter(
+        (c) => c.url.endsWith("/v1/me/consent") && c.method === "POST");
+      expect(gets.length).toBeGreaterThan(0);
+      expect(posts.length).toBe(1);
+      expect(posts[0].headers.authorization).toBe("Bearer AT-CONSENT");
+      // Prompt actually shown.
+      expect(_shown.some(
+        (s) => s.kind === "info" && /spinner/i.test(s.text))).toBe(true);
+      // Post-Agree, the SHOWN_KEY is persisted so we don't nag this session.
+      const stored = ctx.globalState.get<string>(
+        "vibe-ads.consent.promptShownForVersion");
+      expect(stored).toBe("v2");
+    } finally {
+      window.showInformationMessage = origShow;
+      await deactivate();
+      if (prevHome !== undefined) process.env.HOME = prevHome;
+      else delete process.env.HOME;
+      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+      else delete process.env.USERPROFILE;
+      try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
+    }
+  });
+
+  it("already-accepted user gets NO prompt + NO POST", async () => {
+    const home = mkdtempSync(join(tmpdir(), "kb-consent2-"));
+    const prevHome = process.env.HOME;
+    const prevUser = process.env.USERPROFILE;
+    process.env.HOME = home; process.env.USERPROFILE = home;
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });
+    const fetched = stubFetch({ hasAccepted: true });
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-CONSENT2");
+
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 50));
+      const posts = fetched.calls.filter(
+        (c) => c.url.endsWith("/v1/me/consent") && c.method === "POST");
+      expect(posts.length).toBe(0);
+      expect(_shown.some(
+        (s) => s.kind === "info" && /spinner/i.test(s.text))).toBe(false);
+    } finally {
+      await deactivate();
+      if (prevHome !== undefined) process.env.HOME = prevHome;
+      else delete process.env.HOME;
+      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+      else delete process.env.USERPROFILE;
+      try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
+    }
+  });
+
+  it("signed-out user gets NO prompt + NO POST (no token => no GET)",
+    async () => {
+      const home = mkdtempSync(join(tmpdir(), "kb-consent3-"));
+      const prevHome = process.env.HOME;
+      const prevUser = process.env.USERPROFILE;
+      process.env.HOME = home; process.env.USERPROFILE = home;
+      const adapter = mkAdapter();
+      const statusBar = { set: vi.fn(), dispose: vi.fn() };
+      __wireForTest({ adapter, statusBar });
+      const fetched = stubFetch({ hasAccepted: false });
+      const ctx = makeContext();  // NOT signed in
+      try {
+        await activate(ctx as never);
+        await new Promise((r) => setTimeout(r, 50));
+        const consentCalls = fetched.calls.filter(
+          (c) => c.url.endsWith("/v1/me/consent"));
+        expect(consentCalls).toHaveLength(0);
+      } finally {
+        await deactivate();
+        if (prevHome !== undefined) process.env.HOME = prevHome;
+        else delete process.env.HOME;
+        if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+        else delete process.env.USERPROFILE;
+        try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
+      }
+    });
+});
+
+// audit-2026-06-09 #38: ConsentClient was the only client whose DEFAULT was
+// bare `fetch` (no timeout) — the 2A-01 black-holed-connection hang class.
+// Pin that the default-constructed client (the extension.ts wiring passes no
+// fetch) sends every request with an AbortSignal.
+describe("ConsentClient default fetch carries a timeout (audit #38)", () => {
+  it("GET and POST both carry an abort signal", async () => {
+    const inits: (RequestInit | undefined)[] = [];
+    vi.stubGlobal("fetch", vi.fn(async (_u: unknown, init?: RequestInit) => {
+      inits.push(init);
+      return { ok: true, status: 200, json: async () => ({
+        telemetry_opt_in: false, tos_accepted_version: "v1",
+        current_tos_version: "v2",
+        tos_version: "v2", accepted_at: "2026-01-01T00:00:00Z",
+      }) } as Response;
+    }));
+    const c = new ConsentClient("http://x", () => "tok");
+    expect(await c.read()).not.toBeNull();
+    expect(await c.accept()).not.toBeNull();
+    expect(inits).toHaveLength(2);
+    // Pre-fix: the default was bare fetch -> no signal on either call.
+    expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
+    expect(inits[1]?.signal).toBeInstanceOf(AbortSignal);
+  });
+});
diff --git a/test/debug.test.ts b/test/debug.test.ts
new file mode 100644
index 0000000..65bb5de
--- /dev/null
+++ b/test/debug.test.ts
@@ -0,0 +1,419 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { DebugController } from "../src/debug";
+import { Loopback } from "../src/loopback";
+import { bootLoopback, resetSharedLoopbackForTest }
+  from "../src/util/loopbackBoot";
+import { resetServingGate, setKillPosture } from "../src/servingGate";
+import { makeContext, window, commands, _opened } from "./mocks/vscode";
+import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// Hermetic: the shared-loopback record and the serving gate are module-level
+// singletons (audit #7 / wave 2) — never leak one test's state into the next.
+beforeEach(() => { resetServingGate(); resetSharedLoopbackForTest(); });
+
+function authHook(signedIn: boolean) {
+  return {
+    signedIn: () => signedIn,
+    storageInfo: () => ({ scheme: "file" }),
+    signOut: vi.fn(async () => {}),
+  };
+}
+
+function mkAdapter() {
+  return {
+    name: "claude-code",
+    preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+    version: () => "2.1.143",
+    applyPatch: vi.fn(() => ({ ok: true })),
+    restore: vi.fn(() => ({ ok: true, restored: true })),
+  };
+}
+
+describe("DebugController", () => {
+  it("defaults to off with a default message", () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    expect(d.on()).toBe(false);
+    expect(d.text()).toMatch(/kickbacks/i);
+  });
+
+  it("setOn(true) patches with the custom text and reports state", async () => {
+    const adapter = mkAdapter();
+    const ctx = makeContext() as never;
+    const onState = vi.fn();
+    const d = new DebugController(adapter, ctx, onState);
+    await d.setText("Hello from debug");
+    await d.setOn(true);
+    expect(d.on()).toBe(true);
+    expect(adapter.applyPatch).toHaveBeenCalledWith(
+      expect.objectContaining({ adText: "Hello from debug", tier: 3 }));
+    expect(onState).toHaveBeenCalledWith(true);
+    await d.dispose();
+  });
+
+  it("setOn(false) restores and reports state", async () => {
+    const adapter = mkAdapter();
+    const onState = vi.fn();
+    const d = new DebugController(adapter, makeContext() as never, onState);
+    await d.setOn(false);
+    expect(adapter.restore).toHaveBeenCalled();
+    expect(onState).toHaveBeenCalledWith(false);
+  });
+
+  it("reassertTick re-applies when ON but the patch drifted (Bug B)", async () => {
+    const adapter = { ...mkAdapter(), isPatched: vi.fn(() => false) };
+    const d = new DebugController(adapter, makeContext() as never, () => {});
+    await d.setOn(true);                  // persists ON (applies once)
+    adapter.applyPatch.mockClear();
+    await d.reassertTick();
+    expect(adapter.applyPatch).toHaveBeenCalled();   // self-healed, no toggle
+    await d.dispose();
+  });
+
+  it("reassertTick is a no-op when ON and the patch is still present", async () => {
+    const adapter = { ...mkAdapter(), isPatched: vi.fn(() => true) };
+    const d = new DebugController(adapter, makeContext() as never, () => {});
+    await d.setOn(true);
+    adapter.applyPatch.mockClear();
+    await d.reassertTick();
+    expect(adapter.applyPatch).not.toHaveBeenCalled(); // healthy => no churn
+    await d.dispose();
+  });
+
+  it("reassertTick is a no-op when injection is OFF", async () => {
+    const adapter = { ...mkAdapter(), isPatched: vi.fn(() => false) };
+    const d = new DebugController(adapter, makeContext() as never, () => {});
+    await d.reassertTick();              // never turned on
+    expect(adapter.applyPatch).not.toHaveBeenCalled();
+  });
+
+  it("auth row is 'Sign in' when signed out (right under GET PAID OUT); runs the signIn command", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(false));
+    let captured: { id: string; label: string }[] = [];
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) => {
+        captured = items as { id: string; label: string }[];
+        return captured.find((i) => i.id === "signin");
+      });
+    const exec = vi.spyOn(commands, "executeCommand");
+    await d.openMenu();
+    // GET PAID OUT $$$ owns the very top row; the auth flip sits directly
+    // under it.
+    expect(captured[0].id).toBe("getpaid");
+    expect(captured[1].id).toBe("signin");
+    expect(captured[1].label).toMatch(/sign in/i);
+    expect(captured.some((i) => i.id === "signout")).toBe(false);
+    expect(exec).toHaveBeenCalledWith("kickbacks.signIn");
+    qp.mockRestore(); exec.mockRestore();
+  });
+
+  it("auth row flips between Sign in / Sign out by auth state and dispatches the right command", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    let captured: { id: string; label: string }[] = [];
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) => {
+        captured = items as { id: string; label: string }[];
+        return captured.find((i) => i.id === "signout");
+      });
+    const exec = vi.spyOn(commands, "executeCommand");
+    await d.openMenu();
+    // Auth row is the signed-in user's Sign out action — the identity
+    // appears INLINE (label or description), not as a separate row.
+    // signin must NOT appear when already signed in.
+    expect(captured[0].id).toBe("getpaid");
+    expect(captured[1].id).toBe("signout");
+    expect(captured[1].label).toMatch(/sign out/i);
+    expect(captured.some((i) => i.id === "signin")).toBe(false);
+    expect(captured.some((i) => i.id === "__identity")).toBe(false);
+    expect(exec).toHaveBeenCalledWith("kickbacks.signOut");
+    qp.mockRestore(); exec.mockRestore();
+  });
+
+  it("GET PAID OUT $$$ is the menu's first row and opens the earnings portal", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    _opened.length = 0;
+    let captured: { id: string; label: string }[] = [];
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) => {
+        captured = items as { id: string; label: string }[];
+        return captured.find((i) => i.id === "getpaid");
+      });
+    await d.openMenu();
+    expect(captured[0].id).toBe("getpaid");
+    expect(captured[0].label).toMatch(/GET PAID OUT \$\$\$/);
+    expect(_opened).toContain("https://kickbacks.ai/me");
+    qp.mockRestore();
+  });
+
+  it("GET PAID OUT $$$ stays on top even when auth never initialised", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    let captured: { id: string }[] = [];
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) => { captured = items as { id: string }[]; return undefined; });
+    await d.openMenu();
+    expect(captured[0].id).toBe("getpaid");
+    qp.mockRestore();
+  });
+
+  it("W2 menu shape: required items present, deprecated ones absent", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    let captured: { id?: string; label?: string }[] = [];
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) => {
+        captured = items as { id?: string; label?: string }[]; return undefined;
+      });
+    await d.openMenu();
+    const ids = captured.map((i) => i.id);
+    // Required
+    expect(ids).toContain("toggle");
+    expect(ids).toContain("config");
+    expect(ids).toContain("reapply");
+    expect(ids).toContain("reload");
+    expect(ids).toContain("restore");
+    expect(ids).toContain("openlog");
+    expect(ids).toContain("builtinfo");
+    // Removed: msg (rolled into config.json), plus W2-era wv/cli/diag/banner/status
+    expect(ids).not.toContain("msg");
+    expect(ids).not.toContain("wv");
+    expect(ids).not.toContain("cli");
+    expect(ids).not.toContain("diag");
+    expect(ids).not.toContain("banner");
+    expect(ids).not.toContain("status");
+    // Toggle label reflects state
+    const toggleLabel = captured.find((i) => i.id === "toggle")?.label || "";
+    expect(toggleLabel).toMatch(/enable|disable/i);
+    expect(toggleLabel).toMatch(/kickbacks/i);
+    qp.mockRestore();
+  });
+
+  it("consolidated re-apply fires BOTH CC reassert and Codex reassert", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    const ccReassert = vi.fn();
+    const codexReassert = vi.fn();
+    d.setReassert(ccReassert);
+    d.setReassertCodex(codexReassert);
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) =>
+        (items as { id?: string }[]).find((i) => i.id === "reapply"));
+    await d.openMenu();
+    expect(ccReassert).toHaveBeenCalled();
+    expect(codexReassert).toHaveBeenCalled();
+    qp.mockRestore();
+  });
+
+  it("menu omits the auth item entirely when auth is unavailable", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    let captured: { id: string }[] = [];
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) => { captured = items as { id: string }[]; return undefined; });
+    await d.openMenu();
+    expect(captured.some((i) => i.id === "signin" || i.id === "signout")).toBe(false);
+    qp.mockRestore();
+  });
+
+  it("setText live re-applies only while injection is on", async () => {
+    const adapter = mkAdapter();
+    const d = new DebugController(adapter, makeContext() as never, () => {});
+    await d.setText("off-text");
+    expect(adapter.applyPatch).not.toHaveBeenCalled();
+    await d.setOn(true);
+    await d.setText("on-text");
+    expect(adapter.applyPatch).toHaveBeenLastCalledWith(
+      expect.objectContaining({ adText: "on-text" }));
+    await d.dispose();
+  });
+
+  // ─── Tiered auto-enable across sign-out → sign-in ─────────────────────
+  // Regression: signing out forces K_ON=false; pre-fix the sign-in gate only
+  // re-enabled on neverToggled(), so once you'd signed out you stayed disabled
+  // forever. doSignOut() now remembers the pre-sign-out state so the next
+  // sign-in can restore it — while still respecting a deliberate disable.
+  it("shouldAutoEnableOnSignIn: Tier 1 — true for a first-run user (never toggled)", () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    expect(d.neverToggled()).toBe(true);
+    expect(d.shouldAutoEnableOnSignIn()).toBe(true);
+  });
+
+  it("shouldAutoEnableOnSignIn: Tier 2 — true after signing out while injection was ON", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    await d.setOn(true);            // user is running ads
+    await d.doSignOut();            // forces K_ON=false, remembers it was ON
+    expect(d.on()).toBe(false);
+    expect(d.neverToggled()).toBe(false);          // K_ON is defined now
+    expect(d.shouldAutoEnableOnSignIn()).toBe(true); // …but Tier 2 fires
+    await d.dispose();
+  });
+
+  it("shouldAutoEnableOnSignIn: false when the user deliberately disabled, then signed out", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    await d.setOn(true);
+    await d.setOn(false);          // deliberate "Disable Kickbacks"
+    await d.doSignOut();           // captures the OFF intent
+    expect(d.shouldAutoEnableOnSignIn()).toBe(false); // stays off — respected
+    await d.dispose();
+  });
+
+  it("setOn(false) clears the sign-out memory: a deliberate disable sticks across the next sign-in", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    await d.setOn(true);
+    await d.doSignOut();             // K_PRESIGNOUT=true (was ON)
+    expect(d.shouldAutoEnableOnSignIn()).toBe(true);
+    await d.setOn(false);            // deliberate disable AFTER the sign-out
+    expect(d.shouldAutoEnableOnSignIn()).toBe(false); // stale memory cleared
+    await d.dispose();
+  });
+
+  it("menu Restore is a deliberate disable: doRestore clears the sign-out memory too", async () => {
+    // Audit fix-up (wave 2): doRestore set K_ON=false but left a stale
+    // K_PRESIGNOUT=true, so the serving gate's enabled() input stayed true
+    // and the 60s reassert re-patched the just-restored install.
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    await d.setOn(true);
+    await d.doSignOut();             // stale K_PRESIGNOUT=true
+    await (d as unknown as { doRestore: () => Promise<void> }).doRestore();
+    expect(d.on()).toBe(false);
+    expect(d.shouldAutoEnableOnSignIn()).toBe(false);
+    await d.dispose();
+  });
+
+  it("clearSignOutMemory consumes the one-shot intent", async () => {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    await d.setOn(true);
+    await d.doSignOut();
+    expect(d.shouldAutoEnableOnSignIn()).toBe(true);
+    await d.clearSignOutMemory();
+    // Flag gone; only the (still-false) K_ON remains, so no auto-enable.
+    expect(d.shouldAutoEnableOnSignIn()).toBe(false);
+    await d.dispose();
+  });
+
+  it("bannerOverride cycles server → on → off → server (modes sentinel)", async () => {
+    const RH = process.env.HOME, RU = process.env.USERPROFILE;
+    const tmp = mkdtempSync(join(tmpdir(), "vibe-dbg-"));
+    process.env.HOME = tmp; process.env.USERPROFILE = tmp;
+    try {
+      const ctl = new DebugController(mkAdapter(), makeContext() as never, () => {});
+      expect(ctl.bannerOverride()).toBe("server");
+      await ctl.cycleBannerOverride();
+      expect(ctl.bannerOverride()).toBe("on");
+      expect(readFileSync(join(tmp, ".vibe-ads", "banner.mode"), "utf8").trim()).toBe("on");
+      await ctl.cycleBannerOverride();
+      expect(ctl.bannerOverride()).toBe("off");
+      await ctl.cycleBannerOverride();
+      expect(ctl.bannerOverride()).toBe("server");
+      expect(existsSync(join(tmp, ".vibe-ads", "banner.mode"))).toBe(false);
+    } finally {
+      if (RH !== undefined) process.env.HOME = RH; else delete process.env.HOME;
+      if (RU !== undefined) process.env.USERPROFILE = RU; else delete process.env.USERPROFILE;
+      rmSync(tmp, { recursive: true, force: true });
+    }
+  });
+});
+
+// ─── Audit #7 (wave 3): one loopback server per extension host ────────────
+describe("DebugController — shared loopback (audit #7)", () => {
+  it("apply() reuses ONE Loopback across re-applies — no stop/re-mint that "
+    + "would tear down the shared server", async () => {
+    const adapter = mkAdapter();
+    const d = new DebugController(adapter, makeContext() as never, () => {});
+    await d.setOn(true);
+    const lb1 = (d as unknown as { lb: Loopback | null }).lb;
+    expect(lb1).toBeTruthy();
+    expect(lb1!.isRunning()).toBe(true);
+    await d.setText("re-applied text");      // live re-apply while ON
+    const lb2 = (d as unknown as { lb: Loopback | null }).lb;
+    expect(lb2, "pre-fix: a fresh Loopback was minted per apply").toBe(lb1);
+    expect(lb1!.isRunning()).toBe(true);     // the bound server survived
+    await d.dispose();
+  });
+
+  it("apply() shares an existing production loopback (same port, no "
+    + "EADDRINUSE→port+1) and never displaces its handlers; dispose() leaves "
+    + "the production server running", async () => {
+    const ctx = makeContext();
+    // The production loopback boots first (K_ON=false boot)…
+    const prodLb = new Loopback({
+      onEvent: () => {}, onClick: () => {}, getActivity: () => ({}),
+      getCurrentAd: () => ({ adText: "prod-ad", clickUrl: "https://p.test",
+        iconUrl: "", adId: "prod", campaignId: "prod" }),
+    });
+    const prod = await bootLoopback(prodLb, ctx as never);
+    expect(prod.port).toBeGreaterThan(0);
+    // …then the user enables Kickbacks → debug apply().
+    const adapter = mkAdapter();
+    const d = new DebugController(adapter, ctx as never, () => {});
+    await d.setOn(true);
+    const calls = adapter.applyPatch.mock.calls as unknown as
+      [{ loopbackPort: number; loopbackBase: string }][];
+    const params = calls[calls.length - 1][0];
+    expect(params.loopbackPort, "pre-fix: bound prod.port + 1")
+      .toBe(prod.port);
+    expect(params.loopbackBase).toBe(prod.base);
+    // Production wiring (the billing authority) keeps the routes.
+    const ad = await (await fetch(`${prod.base}/ad`)).json();
+    expect(ad.adId).toBe("prod");
+    // The debug controller never owned the server — its dispose must not
+    // tear down production traffic.
+    await d.dispose();
+    expect(prodLb.isRunning()).toBe(true);
+    await prodLb.stop();
+  });
+
+  it("the debug loopback's /ad stays canServeAds-gated (wave-2 contract "
+    + "preserved in the sharing design)", async () => {
+    const adapter = mkAdapter();
+    const d = new DebugController(adapter, makeContext() as never, () => {});
+    d.setPortfolioAd("Debug ad", "https://d.test");
+    await d.setOn(true);
+    const calls = adapter.applyPatch.mock.calls as unknown as
+      [{ loopbackBase: string }][];
+    const params = calls[calls.length - 1][0];
+    const served = await (await fetch(`${params.loopbackBase}/ad`)).json();
+    expect(served.adText).toBe("Debug ad");
+    setKillPosture("confirmed");             // confirmed kill ⇒ stop serving
+    const gated = await (await fetch(`${params.loopbackBase}/ad`)).json();
+    expect(gated).toEqual({});
+    await d.dispose();
+  });
+});
+
+describe("DebugController — restart/open-log routing (W2 menu)", () => {
+  function mkDbg() {
+    const d = new DebugController(mkAdapter(), makeContext() as never, () => {});
+    d.setAuth(authHook(true));
+    return d;
+  }
+
+  it("reload entry restarts the extension host", async () => {
+    const d = mkDbg();
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) =>
+        (items as { id?: string }[]).find((i) => i.id === "reload"));
+    const exec = vi.spyOn(commands, "executeCommand");
+    await d.openMenu();
+    expect(exec).toHaveBeenCalledWith("workbench.action.restartExtensionHost");
+    qp.mockRestore(); exec.mockRestore();
+  });
+
+  it("openlog entry opens the debug log file", async () => {
+    const d = mkDbg();
+    const qp = vi.spyOn(window, "showQuickPick").mockImplementation(
+      async (items: unknown) =>
+        (items as { id?: string }[]).find((i) => i.id === "openlog"));
+    const exec = vi.spyOn(commands, "executeCommand");
+    await d.openMenu();
+    expect(exec).toHaveBeenCalledWith("vscode.open", expect.anything());
+    qp.mockRestore(); exec.mockRestore();
+  });
+});
diff --git a/test/dedupe.test.ts b/test/dedupe.test.ts
new file mode 100644
index 0000000..3ba7820
--- /dev/null
+++ b/test/dedupe.test.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from "vitest";
+import { ImpressionDedupe } from "../src/metrics/dedupe";
+
+describe("ImpressionDedupe", () => {
+  it("allows the first impression of a kind+adId, blocks repeats", () => {
+    const d = new ImpressionDedupe();
+    expect(d.shouldSend("impression_rendered", "ad1")).toBe(true);
+    expect(d.shouldSend("impression_rendered", "ad1")).toBe(false);
+  });
+  it("keys by kind AND adId (different kind or ad is independent)", () => {
+    const d = new ImpressionDedupe();
+    expect(d.shouldSend("impression_rendered", "ad1")).toBe(true);
+    expect(d.shouldSend("impression_viewable", "ad1")).toBe(true);
+    expect(d.shouldSend("impression_rendered", "ad2")).toBe(true);
+  });
+  it("keys by surface so overlay+banner of the same ad are independent", () => {
+    const d = new ImpressionDedupe();
+    // Same kind + same ad, different surface: each fires once.
+    expect(d.shouldSend("impression_viewable", "ad1", "overlay")).toBe(true);
+    expect(d.shouldSend("impression_viewable", "ad1", "banner")).toBe(true);
+    expect(d.shouldSend("impression_viewable", "ad1", "codex_overlay")).toBe(true);
+    expect(d.shouldSend("impression_viewable", "ad1", "statusline")).toBe(true);
+    // And then each is deduped within its surface.
+    expect(d.shouldSend("impression_viewable", "ad1", "overlay")).toBe(false);
+    expect(d.shouldSend("impression_viewable", "ad1", "banner")).toBe(false);
+  });
+  it("treats missing surface as a 'default' bucket", () => {
+    const d = new ImpressionDedupe();
+    expect(d.shouldSend("impression_rendered", "ad1")).toBe(true);
+    expect(d.shouldSend("impression_rendered", "ad1", undefined)).toBe(false);
+    // Surface-tagged variant is independent of the default bucket.
+    expect(d.shouldSend("impression_rendered", "ad1", "overlay")).toBe(true);
+  });
+});
diff --git a/test/diagnose.test.ts b/test/diagnose.test.ts
new file mode 100644
index 0000000..70f275d
--- /dev/null
+++ b/test/diagnose.test.ts
@@ -0,0 +1,124 @@
+import { describe, it, expect } from "vitest";
+import { interpret, interpretCodex, formatDiagnostics } from "../src/activation/diagnose";
+import type { CodexDiagnostics } from "../src/activation/diagnose";
+import type { AdapterDiagnostics, TargetAdapter } from "../src/adapters/types";
+
+function diag(over: Partial<AdapterDiagnostics> = {}): AdapterDiagnostics {
+  return {
+    name: "claude-code", target: "/x/anthropic.claude-code-2.1.161/webview/index.js",
+    targetExists: true, version: "2.1.161", compatible: false, isPatched: false,
+    backup: { exists: false, path: null, hasArray: false, hasBlock: false },
+    live: { hasArray: false, bareVerbPresent: false },
+    ...over,
+  };
+}
+
+describe("interpret (diagnose verdict)", () => {
+  it("compatible + live block → OK/live", () => {
+    expect(interpret(diag({ compatible: true, isPatched: true }))).toMatch(/OK.*live/i);
+  });
+
+  it("target missing → Claude Code not found", () => {
+    expect(interpret(diag({ targetExists: false }))).toMatch(/not found/i);
+  });
+
+  it("verb word present but not in an array → bundle format change → fix regex", () => {
+    const v = interpret(diag({ live: { hasArray: false, bareVerbPresent: true } }));
+    expect(v).toMatch(/bundle format/i);
+    expect(v).toMatch(/regex|anchor/i);
+  });
+
+  it("no verb word + no backup array → stripped/corrupted → reinstall Claude Code", () => {
+    const v = interpret(diag({ live: { hasArray: false, bareVerbPresent: false } }));
+    expect(v).toMatch(/reinstall.*claude code/i);
+  });
+
+  it("stale backup but live OK → self-heal → update Kickbacks", () => {
+    const v = interpret(diag({
+      backup: { exists: true, path: "/x.bak", hasArray: false, hasBlock: false },
+      live: { hasArray: true, bareVerbPresent: true },
+    }));
+    expect(v).toMatch(/self-heal|update Kickbacks/i);
+  });
+});
+
+describe("formatDiagnostics", () => {
+  it("renders the CC section, preflight, and a verdict", () => {
+    const cc = {
+      name: "claude-code",
+      diagnose: () => diag({ compatible: false, reason: "verb array not found (incompatible build)",
+        live: { hasArray: false, bareVerbPresent: false } }),
+    } as unknown as TargetAdapter;
+    const report = formatDiagnostics(cc, null);
+    expect(report).toContain("Kickbacks Diagnostics");
+    expect(report).toContain("PREFLIGHT compatible: false");
+    expect(report).toContain("preflight reason: verb array not found");
+    expect(report).toMatch(/VERDICT:/);
+    expect(report).toMatch(/reinstall.*claude code/i);
+  });
+
+  it("degrades gracefully when the adapter has no diagnose()", () => {
+    const cc = { name: "claude-code" } as unknown as TargetAdapter;
+    expect(formatDiagnostics(cc, null)).toContain("no diagnose() available");
+  });
+});
+
+// BUG-001: dual-install reports used to be silent about Codex (the adapter is
+// null whenever the serving policy keeps discovery off). The section now
+// always renders and explains the opt-in policy in plain English.
+describe("formatDiagnostics — Codex section", () => {
+  const cc = { name: "claude-code" } as unknown as TargetAdapter;
+  const codexAdapter = {
+    name: "codex",
+    preflight: () => ({ compatible: true, version: "0.5.1" }),
+  } as unknown as TargetAdapter;
+  const policy = (over: Partial<CodexDiagnostics["policy"]> = {}) => ({
+    discoveryEnabled: false, optIn: false, optOut: false,
+    claudeCompatible: true, ...over,
+  });
+
+  it("reports a missing Codex install", () => {
+    const r = formatDiagnostics(cc, { adapter: null, policy: policy() });
+    expect(r).toContain("--- Codex ---");
+    expect(r).toMatch(/not found on this machine/i);
+  });
+
+  it("dual-install default: installed but serving OFF → opt-in explanation", () => {
+    const r = formatDiagnostics(cc, { adapter: codexAdapter, policy: policy() });
+    expect(r).toContain("serving policy: OFF");
+    expect(r).toMatch(/opt-in/i);
+    expect(r).toMatch(/KICKBACKS_CODEX=1|codex\.enabled/);
+    expect(r).toMatch(/expected, not a bug/i);
+  });
+
+  it("explicit opt-out wins the verdict", () => {
+    const v = interpretCodex(policy({ optOut: true, discoveryEnabled: false }), true);
+    expect(v).toMatch(/explicitly disabled/i);
+  });
+
+  it("serving ON + compatible → OK", () => {
+    const r = formatDiagnostics(cc, {
+      adapter: codexAdapter,
+      policy: policy({ discoveryEnabled: true, optIn: true }),
+    });
+    expect(r).toContain("serving policy: ON");
+    expect(r).toMatch(/VERDICT: OK — Codex is a live ad target/);
+  });
+
+  it("serving ON + incompatible → send report", () => {
+    const v = interpretCodex(
+      policy({ discoveryEnabled: true, claudeCompatible: false }), false);
+    expect(v).toMatch(/incompatible.*send this report/i);
+  });
+
+  it("a throwing preflight degrades to incompatible instead of crashing", () => {
+    const bad = {
+      name: "codex",
+      preflight: () => { throw new Error("boom"); },
+    } as unknown as TargetAdapter;
+    const r = formatDiagnostics(cc, {
+      adapter: bad, policy: policy({ discoveryEnabled: true, optIn: true }),
+    });
+    expect(r).toContain("reason=preflight threw");
+  });
+});
diff --git a/test/e2e.test.ts b/test/e2e.test.ts
new file mode 100644
index 0000000..e2f3631
--- /dev/null
+++ b/test/e2e.test.ts
@@ -0,0 +1,266 @@
+import { describe, it, expect } from "vitest";
+import { mkdtempSync, writeFileSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { JSDOM } from "jsdom";
+import { ClaudeCodeAdapter } from "../src/adapters/claude-code/adapter";
+
+const FIX = readFileSync(join(__dirname, "fixtures/synthetic-index.js"), "utf8");
+
+// These jsdom e2e cases have multi-second real-time waits (verb idle/GRACE
+// timing) and are SLOW — they are NOT part of the standard suite. Run them
+// explicitly on demand only:  VIBE_ADS_E2E=1 npx vitest run test/e2e.test.ts
+const E2E = process.env.VIBE_ADS_E2E === "1";
+
+(E2E ? describe : describe.skip)(
+  "S3 e2e (fixture only — never a real install)", () => {
+  // Real CC structure (telemetry-confirmed): a `spinnerRow_<hash>` DIV inside
+  // `messagesContainer_<hash> stickyMode_<hash>`, with the animated glyph in
+  // a CHILD (.ccverb here). CC keeps the row for the turn (animating the
+  // child in place) and REMOVES it when the turn ends; a new turn mounts a
+  // fresh row. The block must occupy non-destructively (append OUR child,
+  // hide CC's via visibility:hidden) and pin the ad to the row's presence.
+  function makeCcDom() {
+    const dom = new JSDOM(
+      `<body><div id="mc" class="messagesContainer_X stickyMode_X"></div></body>`,
+      { runScripts: "outside-only" });
+    (dom.window as any).fetch = async () => ({ json: async () => ({}) });
+    const doc = dom.window.document;
+    const mc = doc.getElementById("mc")!;
+    let turn = false;
+    const tick = dom.window.setInterval(() => {
+      const row = mc.querySelector('[class*="spinnerRow_"]');
+      if (turn) {
+        if (!row) {
+          const r = doc.createElement("div");
+          r.className = "spinnerRow_07S1Yg";
+          const v = doc.createElement("span");
+          v.className = "ccverb";
+          v.textContent = "✢._.";
+          r.appendChild(v);
+          mc.appendChild(r);
+        } else {                                  // CC animates child IN PLACE
+          const v = row.querySelector(".ccverb");
+          if (v) v.textContent = "✢" + ".".repeat(1 + (Date.now() >> 7) % 3);
+        }
+      } else if (row) {
+        // REAL idle behavior (telemetry-confirmed): CC keeps spinnerRow
+        // MOUNTED but stops the glyph (no longer ✢-led). The fix must drop
+        // the overlay on the glyph stopping, not on row removal.
+        const v = row.querySelector(".ccverb");
+        if (v) v.textContent = "";
+      }
+    }, 120);
+    return { dom, doc, tick, setTurn: (v: boolean) => { turn = v; } };
+  }
+  // The ad is a body-level overlay we own (NOT inside CC's tree).
+  const adVisible = (doc: Document) => {
+    const o = doc.querySelector('[data-vibe-ads-overlay="1"]') as
+      HTMLElement | null;
+    return !!o && !!o.isConnected && o.parentElement === doc.body &&
+      (o.textContent || "").includes("Acme deploys faster");
+  };
+  // CC's spinner subtree must be byte-untouched: its child still present,
+  // no data-vibe-ads* injected/added inside the row (we only READ it).
+  const ccTreeUntouched = (doc: Document) => {
+    const row = doc.querySelector('[class*="spinnerRow_"]');
+    if (!row) return true;                 // row gone (idle) — trivially fine
+    const v = row.querySelector(".ccverb");
+    return !!v && v.getAttribute("data-vibe-ads-hid") === null &&
+      (v as HTMLElement).style.visibility !== "hidden" &&
+      row.querySelector("[data-vibe-ads-overlay]") === null &&
+      row.querySelector("[data-vibe-ads-host]") === null;
+  };
+
+  it("patch -> ad renders in a body overlay, CC tree untouched -> restore " +
+     "byte-exact", async () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-e2e-"));
+    const target = join(d, "index.js");
+    writeFileSync(target, FIX, "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    expect(a.applyPatch({ tier: 3, adText: "Acme deploys faster than your CI now",
+      iconRef: "icon.a", iconUrl: "", clickToken: "ck", clickUrl: "https://acme.example/lp",
+      corr: "e2e.tst", loopbackPort: 5555,
+      loopbackToken: "lt", loopbackBase: "http://127.0.0.1:5555" }).ok).toBe(true);
+    const patched = readFileSync(target, "utf8");
+    const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
+    const dom = new JSDOM(
+      `<body><div class="messagesContainer_X stickyMode_X">` +
+      `<div class="spinnerRow_07S1Yg"><span class="ccverb">✢._.</span>` +
+      `</div></div></body>`, { runScripts: "outside-only" });
+    (dom.window as any).fetch = async () => ({ json: async () => ({}) });
+    dom.window.eval(block);
+    await new Promise((r) => setTimeout(r, 300));
+    const doc = dom.window.document;
+    expect(adVisible(doc)).toBe(true);                 // ad in body overlay
+    expect(ccTreeUntouched(doc)).toBe(true);            // CC subtree untouched
+    expect(doc.querySelector(".ccverb")).not.toBeNull(); // but NOT destroyed
+    expect(a.restore().restored).toBe(true);
+    expect(readFileSync(target, "utf8")).toBe(FIX);    // byte-exact
+  });
+
+  it("ad PERSISTS frozen at idle (last ad stays in view), re-glues next " +
+     "turn, CC tree never mutated", async () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-pin-"));
+    const target = join(d, "index.js");
+    writeFileSync(target, FIX, "utf8");
+    new ClaudeCodeAdapter(target).applyPatch({ tier: 3,
+      adText: "Acme deploys faster than your CI now", iconRef: "icon.a", iconUrl: "",
+      clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.pin",
+      loopbackPort: 5555, loopbackToken: "lt",
+      loopbackBase: "http://127.0.0.1:5555" });
+    const patched = readFileSync(target, "utf8");
+    const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
+    const { dom, doc, tick, setTurn } = makeCcDom();
+    dom.window.eval(block);
+
+    setTurn(true);                                   // ---- turn 1 active
+    await new Promise((r) => setTimeout(r, 700));
+    expect(adVisible(doc)).toBe(true);
+    expect(ccTreeUntouched(doc)).toBe(true);          // CC subtree untouched (read-only)
+
+    setTurn(false);                                  // turn ends (glyph stops)
+    await new Promise((r) => setTimeout(r, 4600));   // well > GRACE_MS
+    // PERSIST-AT-IDLE: the ad is FROZEN in place, NOT dropped — the last
+    // rendered frame stays on screen indefinitely after the turn ends.
+    expect(adVisible(doc)).toBe(true);
+    expect(ccTreeUntouched(doc)).toBe(true);          // still read-only
+
+    setTurn(true);                                   // ---- brand-new turn
+    await new Promise((r) => setTimeout(r, 700));
+    expect(adVisible(doc)).toBe(true);               // thaws, re-glues
+    expect(ccTreeUntouched(doc)).toBe(true);
+
+    setTurn(false);
+    dom.window.clearInterval(tick);
+  }, 15000);
+
+  it("does NOT vanish mid-thinking while CC animates its child in place " +
+     "(read-only detection keeps the signal)", async () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-inplace-"));
+    const target = join(d, "index.js");
+    writeFileSync(target, FIX, "utf8");
+    new ClaudeCodeAdapter(target).applyPatch({ tier: 3,
+      adText: "Acme deploys faster than your CI now", iconRef: "icon.a", iconUrl: "",
+      clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.inpl",
+      loopbackPort: 5555, loopbackToken: "lt",
+      loopbackBase: "http://127.0.0.1:5555" });
+    const patched = readFileSync(target, "utf8");
+    const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
+    const { dom, doc, tick, setTurn } = makeCcDom();
+    dom.window.eval(block);
+
+    setTurn(true);                                   // row stays; child anim
+    await new Promise((r) => setTimeout(r, 1500));
+    expect(adVisible(doc)).toBe(true);
+    await new Promise((r) => setTimeout(r, 3000));   // well past old GRACE
+    expect(adVisible(doc)).toBe(true);               // STILL shown — no vanish
+    expect(ccTreeUntouched(doc)).toBe(true);
+
+    setTurn(false);
+    await new Promise((r) => setTimeout(r, 4600));
+    // PERSIST-AT-IDLE: still shown after the turn ends (frozen, not cleared).
+    expect(adVisible(doc)).toBe(true);
+    dom.window.clearInterval(tick);
+  }, 20000);
+
+  it("does NOT bill while frozen at idle (no view_tick / error_impression " +
+     "pings accrue once the turn ends)", async () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-nobill-"));
+    const target = join(d, "index.js");
+    writeFileSync(target, FIX, "utf8");
+    new ClaudeCodeAdapter(target).applyPatch({ tier: 3,
+      adText: "Acme deploys faster than your CI now", iconRef: "icon.a", iconUrl: "",
+      clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.nobill",
+      loopbackPort: 5555, loopbackToken: "lt",
+      loopbackBase: "http://127.0.0.1:5555" });
+    const patched = readFileSync(target, "utf8");
+    const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
+    const { dom, doc, tick, setTurn } = makeCcDom();
+    // Record every loopback hit (fetch + sendBeacon) so we can prove the
+    // view-time accumulator emits NOTHING once the ad freezes at idle.
+    const pings: string[] = [];
+    (dom.window as any).fetch = async (u: string) => {
+      pings.push(String(u)); return { json: async () => ({}) };
+    };
+    try {
+      Object.defineProperty(dom.window.navigator, "sendBeacon", {
+        configurable: true,
+        value: (u: string) => { pings.push(String(u)); return true; },
+      });
+    } catch { /* jsdom: no sendBeacon → ping() falls back to the fetch above */ }
+    const billing = () =>
+      pings.filter((u) => /view_tick|error_impression|view_threshold_met/.test(u));
+    dom.window.eval(block);
+
+    setTurn(true);                                   // active turn (<5s)
+    await new Promise((r) => setTimeout(r, 700));
+    expect(adVisible(doc)).toBe(true);
+
+    setTurn(false);                                  // turn ends → freeze
+    await new Promise((r) => setTimeout(r, 2500));   // > GRACE_MS: now frozen
+    expect(adVisible(doc)).toBe(true);               // persisted
+    const before = billing().length;                 // no billing yet (<5s)
+
+    // Sit idle well past the 5s MAX_SESSION_MS / TICK_MS boundary. If billing
+    // were NOT paused, the absolute-epoch clock would cross 5s of elapsed and
+    // fire a view_tick/error_impression during this window.
+    await new Promise((r) => setTimeout(r, 6000));
+    expect(adVisible(doc)).toBe(true);               // still persisted
+    expect(billing().length).toBe(before);           // …and STILL no billing
+
+    setTurn(false);
+    dom.window.clearInterval(tick);
+  }, 20000);
+
+  it("renders a clickable ad into a real usage banner when bannerOn", async () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-bn-"));
+    const target = join(d, "index.js");
+    writeFileSync(target, FIX, "utf8");
+    const a = new ClaudeCodeAdapter(target);
+    a.applyPatch({ tier: 3, adText: "Acme deploys faster", iconRef: "i", iconUrl: "",
+      clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.tst",
+      loopbackPort: 5555,
+      loopbackToken: "lt", loopbackBase: "http://127.0.0.1:5555", bannerOn: true });
+    const patched = readFileSync(target, "utf8");
+    const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
+    const dom = new JSDOM(
+      `<body><div id="b">You've used 71% of your weekly limit · resets in 4d · View usage</div></body>`,
+      { runScripts: "outside-only" });
+    (dom.window as any).fetch = async () => ({ json: async () => ({}) });
+    dom.window.eval(block);
+    await new Promise((r) => setTimeout(r, 1200)); // banner loop is 1s
+    const b = dom.window.document.getElementById("b")!;
+    expect(b.getAttribute("data-vibe-ads-banner")).toBe("1");
+    expect(b.innerHTML).toContain("Acme deploys faster");
+    expect(b.innerHTML).toContain('data-vibe-ads-ad="1"');
+  });
+
+  it("leaves the banner untouched when bannerOn is false", async () => {
+    const d = mkdtempSync(join(tmpdir(), "vibe-ads-bf-"));
+    const target = join(d, "index.js");
+    writeFileSync(target, FIX, "utf8");
+    new ClaudeCodeAdapter(target).applyPatch({ tier: 3, adText: "Acme", iconRef: "i", iconUrl: "",
+      clickToken: "ck", clickUrl: "https://acme.example/lp", corr: "e2e.tst",
+      loopbackPort: 5555,
+      loopbackToken: "lt", loopbackBase: "http://127.0.0.1:5555", bannerOn: false });
+    const patched = readFileSync(target, "utf8");
+    const block = patched.slice(patched.indexOf("/* VIBE-ADS-START */"));
+    const orig = "You've used 71% of your weekly limit · resets in 4d · View usage";
+    const dom = new JSDOM(`<body><div id="b">${orig}</div></body>`,
+      { runScripts: "outside-only" });
+    (dom.window as any).fetch = async () => ({ json: async () => ({}) });
+    dom.window.eval(block);
+    await new Promise((r) => setTimeout(r, 1200));
+    expect(dom.window.document.getElementById("b")!.textContent).toBe(orig);
+  });
+
+  it("the real-install locator is NOT used by tests (guard)", () => {
+    // extension.ts only resolves the real path inside activate(); no test
+    // imports or invokes locateClaudeCode against the real FS. Assert the
+    // fixture-only contract by construction: importing the adapter requires
+    // an explicit target (no default real path).
+    // @ts-expect-error constructor requires a target argument
+    expect(() => new ClaudeCodeAdapter()).toThrow;
+  });
+});
diff --git a/test/earnings.test.ts b/test/earnings.test.ts
new file mode 100644
index 0000000..9d80553
--- /dev/null
+++ b/test/earnings.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { EarningsClient } from "../src/earnings/client";
+
+const okFetch = (async () => ({
+  ok: true,
+  json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }),
+})) as unknown as typeof fetch;
+const errFetch = (async () => ({ ok: false })) as unknown as typeof fetch;
+const badJson = (async () => ({ ok: true, json: async () => ({}) })) as unknown as typeof fetch;
+
+describe("EarningsClient", () => {
+  it("returns today + lifetime when signed in", async () => {
+    const c = new EarningsClient("http://x", () => "tok", okFetch);
+    expect(await c.fetch()).toEqual({ lifetimeUsd: "1.20", todayUsd: "0.04" });
+  });
+  it("null when signed out (no token)", async () => {
+    const c = new EarningsClient("http://x", () => null, okFetch);
+    expect(await c.fetch()).toBeNull();
+  });
+  it("null on non-200", async () => {
+    const c = new EarningsClient("http://x", () => "tok", errFetch);
+    expect(await c.fetch()).toBeNull();
+  });
+  it("null when JSON missing fields", async () => {
+    const c = new EarningsClient("http://x", () => "tok", badJson);
+    expect(await c.fetch()).toBeNull();
+  });
+});
+
+// audit-2026-06-09 #34: fetch() collapses every failure to null, so the
+// caller (earningsRefresh) could not tell a real backend 401 from a network
+// blip and falsely flipped authHealthy to "401". fetchDetailed preserves the
+// failure KIND: "401" only for a genuine unrecovered HTTP 401.
+describe("EarningsClient.fetchDetailed (audit #34)", () => {
+  const fetch401 = (async () => ({ ok: false, status: 401 })) as unknown as typeof fetch;
+  const fetch500 = (async () => ({ ok: false, status: 500 })) as unknown as typeof fetch;
+  const fetchThrow = (async () => { throw new Error("offline"); }) as unknown as typeof fetch;
+
+  it("real 401 with no recovery hook -> outcome '401'", async () => {
+    const c = new EarningsClient("http://x", () => "tok", fetch401);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "401" });
+  });
+  it("network error -> 'error', NOT '401'", async () => {
+    const c = new EarningsClient("http://x", () => "tok", fetchThrow);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "error" });
+  });
+  it("5xx -> 'error', NOT '401'", async () => {
+    const c = new EarningsClient("http://x", () => "tok", fetch500);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "error" });
+  });
+  it("malformed body -> 'error', NOT '401'", async () => {
+    const c = new EarningsClient("http://x", () => "tok", badJson);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "error" });
+  });
+  it("401 + successful refresh + ok retry -> 'ok'", async () => {
+    let calls = 0;
+    const f = (async () => {
+      calls++;
+      if (calls === 1) return { ok: false, status: 401 };
+      return { ok: true,
+        json: async () => ({ lifetime_usd: "2.00", today_usd: "0.10" }) };
+    }) as unknown as typeof fetch;
+    const c = new EarningsClient("http://x", () => "tok", f, async () => true);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "ok",
+      earnings: { lifetimeUsd: "2.00", todayUsd: "0.10" } });
+  });
+  it("401 + successful refresh + second 401 -> '401' (really expired)", async () => {
+    const c = new EarningsClient("http://x", () => "tok", fetch401, async () => true);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "401" });
+  });
+  it("401 + FAILED refresh -> '401' (session genuinely broken)", async () => {
+    const c = new EarningsClient("http://x", () => "tok", fetch401, async () => false);
+    expect(await c.fetchDetailed()).toEqual({ outcome: "401" });
+  });
+});
+
+// audit-2026-06-09 #38: extension.ts passes bare global `fetch` positionally
+// (to reach onAuth401), bypassing the timeoutFetch(15000) default — the
+// 2A-01 black-holed-connection hang class. The constructor must re-wrap
+// exactly that case while leaving injected mocks untouched.
+describe("EarningsClient timeout wiring (audit #38)", () => {
+  afterEach(() => { vi.unstubAllGlobals(); });
+
+  it("bare global fetch passed positionally is re-wrapped with an abort signal", async () => {
+    const inits: (RequestInit | undefined)[] = [];
+    vi.stubGlobal("fetch", vi.fn(async (_u: unknown, init?: RequestInit) => {
+      inits.push(init);
+      return { ok: true,
+        json: async () => ({ lifetime_usd: "1.00", today_usd: "0.01" }) };
+    }));
+    // Mirrors the extension.ts wiring: global fetch as the 3rd positional arg.
+    const c = new EarningsClient("http://x", () => "tok", fetch,
+      async () => false);
+    expect(await c.fetch()).toEqual({ lifetimeUsd: "1.00", todayUsd: "0.01" });
+    expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
+    // The bearer header survives the re-wrap.
+    expect((inits[0]?.headers as Record<string, string>).authorization)
+      .toBe("Bearer tok");
+  });
+});
diff --git a/test/earningsRefresh.test.ts b/test/earningsRefresh.test.ts
new file mode 100644
index 0000000..0490338
--- /dev/null
+++ b/test/earningsRefresh.test.ts
@@ -0,0 +1,134 @@
+// audit-2026-06-09 #34 regression: showActive used to flip authHealthy to
+// "401" on ANY earnings-fetch failure (network blip, 5xx, malformed body),
+// so the debug menu falsely showed "Sign in again — your session expired"
+// during every offline poll. Only a REAL backend 401 may raise that signal;
+// transient failures must leave the last verdict in place. Also pins the
+// second half of the finding: a token that dies DURING the fetch paints
+// signed-out, not a stale green "active" bar.
+import { describe, it, expect, vi } from "vitest";
+import type { AuthClient } from "../src/auth/client";
+import { setupEarningsRefresh } from "../src/activation/earningsRefresh";
+import { EarningsClient } from "../src/earnings/client";
+import { SessionState } from "../src/sessionState";
+import { makeContext } from "./mocks/vscode";
+
+// Wires a real EarningsClient + SessionState behind setupEarningsRefresh.
+// Token starts null so the constructor's `void showActive()` takes the
+// signed-out path and the tests drive exactly ONE deterministic run each.
+function wire(f: typeof fetch) {
+  let tok: string | null = null;
+  const session = new SessionState();
+  const statusBar = { set: vi.fn() };
+  const capWarning = { show: vi.fn(), hide: vi.fn() };
+  const auth = { accessToken: () => tok } as unknown as AuthClient;
+  const client = new EarningsClient("http://x", () => tok, f);
+  const { showActive } = setupEarningsRefresh(
+    auth, client, session, statusBar, "2.1.143", makeContext() as never,
+    undefined, capWarning);
+  return { session, statusBar, capWarning, showActive,
+           setToken: (t: string | null) => { tok = t; } };
+}
+
+const okFetch = (async () => ({
+  ok: true,
+  json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }),
+})) as unknown as typeof fetch;
+
+describe("setupEarningsRefresh authHealthy (audit #34)", () => {
+  it("a 5xx does NOT set authHealthy '401'", async () => {
+    const f = (async () => ({ ok: false, status: 500 })) as unknown as typeof fetch;
+    const { session, showActive, setToken } = wire(f);
+    setToken("tok");
+    await showActive();
+    expect(session.get().signedIn).toBe(true);
+    expect(session.get().authHealthy).not.toBe("401");
+  });
+
+  it("a network error does NOT clobber a previously-ok verdict", async () => {
+    let mode: "ok" | "throw" = "ok";
+    const f = (async () => {
+      if (mode === "throw") throw new Error("offline");
+      return { ok: true,
+        json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }) };
+    }) as unknown as typeof fetch;
+    const { session, showActive, setToken } = wire(f);
+    setToken("tok");
+    await showActive();
+    expect(session.get().authHealthy).toBe("ok");
+    mode = "throw";                       // laptop loses connectivity
+    await showActive();
+    expect(session.get().authHealthy).toBe("ok");   // pre-fix: "401"
+    expect(session.get().signedIn).toBe(true);
+  });
+
+  it("a REAL backend 401 still sets authHealthy '401'", async () => {
+    const f = (async () => ({ ok: false, status: 401 })) as unknown as typeof fetch;
+    const { session, showActive, setToken } = wire(f);
+    setToken("tok");
+    await showActive();
+    expect(session.get().signedIn).toBe(true);
+    expect(session.get().authHealthy).toBe("401");
+  });
+
+  it("token death mid-fetch paints signed-out, not the green active bar", async () => {
+    let kill: (() => void) | null = null;
+    const f = (async () => {
+      kill?.();                           // sign-out lands during the await
+      return { ok: true,
+        json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }) };
+    }) as unknown as typeof fetch;
+    const { session, statusBar, showActive, setToken } = wire(f);
+    setToken("tok");
+    kill = () => setToken(null);
+    await showActive();
+    const last = statusBar.set.mock.calls.at(-1)?.[0] as { kind: string };
+    expect(last.kind).toBe("signed-out"); // pre-fix: "active"
+    expect(session.get().signedIn).toBe(false);
+  });
+
+  it("healthy fetch paints active with the fresh figures", async () => {
+    const { session, statusBar, showActive, setToken } = wire(okFetch);
+    setToken("tok");
+    await showActive();
+    expect(session.get().authHealthy).toBe("ok");
+    const last = statusBar.set.mock.calls.at(-1)?.[0] as
+      { kind: string; usd?: string; usdToday?: string };
+    expect(last).toEqual({ kind: "active", version: "2.1.143",
+                           usd: "1.20", usdToday: "0.04" });
+  });
+});
+
+describe("setupEarningsRefresh cap-warning pill", () => {
+  const cappedFetch = (async () => ({
+    ok: true,
+    json: async () => ({
+      lifetime_usd: "84.10", today_usd: "50.00",
+      cap: { scope: "daily", cap_usd: "50.00", reset_seconds: 22320 },
+    }),
+  })) as unknown as typeof fetch;
+
+  it("shows the pill when the earnings response carries a cap", async () => {
+    const { capWarning, showActive, setToken } = wire(cappedFetch);
+    setToken("tok");
+    await showActive();
+    expect(capWarning.show).toHaveBeenCalledWith(
+      { scope: "daily", capUsd: "50.00", resetSeconds: 22320 });
+    expect(capWarning.hide).not.toHaveBeenCalled();
+  });
+
+  it("hides the pill when under both ceilings (no cap field)", async () => {
+    const { capWarning, showActive, setToken } = wire(okFetch);
+    setToken("tok");
+    await showActive();
+    expect(capWarning.hide).toHaveBeenCalled();
+    expect(capWarning.show).not.toHaveBeenCalled();
+  });
+
+  it("hides the pill on a not-earning safety state (signed-out)", async () => {
+    // Token null → the signed-out early-return must hide the pill.
+    const { capWarning, showActive } = wire(okFetch);
+    await showActive();
+    expect(capWarning.hide).toHaveBeenCalled();
+    expect(capWarning.show).not.toHaveBeenCalled();
+  });
+});
diff --git a/test/extension-lifecycle.test.ts b/test/extension-lifecycle.test.ts
new file mode 100644
index 0000000..e49ee50
--- /dev/null
+++ b/test/extension-lifecycle.test.ts
@@ -0,0 +1,214 @@
+// Lifecycle regressions for extension.ts orchestration (audit 2026-06-09).
+//
+// #35 — the config-file watcher must NOT restart the extension host on file
+//        CREATION (ensureConfigFile materializing the template on the first
+//        "Edit Vibe-Ads config…" click) nor on a no-op save/touch; only a
+//        genuine CONTENT edit may restart.
+// #36 — deactivate() must perform the irreversible user-file restores BEFORE
+//        stopping the loopbacks, and each stop must be time-bounded so a hung
+//        http.Server.close can never exhaust VS Code's deactivation budget
+//        and strand CC/Codex/settings.json patched after uninstall.
+
+import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { homedir, tmpdir } from "node:os";
+
+// Hermetic home (wave-3 review): activate()'s incompatible-CC path now runs a
+// REAL key-scoped CLI restore against ~/.claude/settings.json (audit #22), so
+// these activation tests must never see the developer's live install. Also
+// shields the boot-canary read from suites that write the real
+// ~/.vibe-ads/boot.canary in parallel workers.
+const restoreEnv = (k: string, v: string | undefined): void => {
+  if (v === undefined) delete process.env[k]; else process.env[k] = v;
+};
+const REAL_HOME = process.env.HOME;
+const REAL_USERPROFILE = process.env.USERPROFILE;
+let tmpHome = "";
+beforeAll(() => {
+  tmpHome = mkdtempSync(join(tmpdir(), "kb-lifecycle-home-"));
+  process.env.HOME = tmpHome;
+  process.env.USERPROFILE = tmpHome;
+});
+afterAll(() => {
+  restoreEnv("HOME", REAL_HOME);
+  restoreEnv("USERPROFILE", REAL_USERPROFILE);
+  try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ok */ }
+});
+
+// Mute dlog (same pattern as extension.test.ts) so test-driven activate()
+// calls don't append to the developer's real ~/.vibe-ads/debug.log.
+vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
+  dlogRaw: () => {}, codexEnabled: () => false, codexDisabled: () => false,
+  codexCliEnabled: () => false,
+  testHooksEnabled: () => false, debugIconDataUri: () => "",
+  LOG_PATH: "/tmp/test-log" }));
+
+// Redirect the watched config file to a per-run temp path so the watcher
+// tests can create/touch/edit it without touching the real ~/.vibe-ads.
+vi.mock("../src/config", async (importOriginal) => {
+  const actual = await importOriginal<typeof import("../src/config")>();
+  const { join: j } = await import("node:path");
+  const { tmpdir } = await import("node:os");
+  const p = j(tmpdir(), `kb-lifecycle-${process.pid}`, "config.json");
+  return { ...actual, configPath: () => p };
+});
+
+// Controllable in-memory Loopback: stop() resolves instantly until a test
+// flips `hangStops`, then hangs forever — simulating http.Server.close()
+// waiting on an in-flight webview request (the #36 hazard). `order` records
+// the cross-component sequencing deactivate() must respect.
+const lb = vi.hoisted(() => ({ hangStops: false, order: [] as string[] }));
+vi.mock("../src/loopback", () => {
+  class Loopback {
+    constructor(_handlers: unknown) {}
+    start(_opts?: unknown): Promise<{ port: number; token: string }> {
+      return Promise.resolve({ port: 43217, token: "deadbeefdeadbeef" });
+    }
+    stop(): Promise<void> {
+      lb.order.push("loopback.stop");
+      return lb.hangStops
+        ? new Promise<void>(() => { /* hung close — never resolves */ })
+        : Promise.resolve();
+    }
+  }
+  return {
+    Loopback,
+    resolveLoopbackBase: async (port: number, token: string) =>
+      `http://127.0.0.1:${port}/vibe-ads/${token}`,
+  };
+});
+
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { makeContext, commands } from "./mocks/vscode";
+import { configPath } from "../src/config";
+
+const RESTART = "workbench.action.restartExtensionHost";
+const restartCount = (): number =>
+  commands._executed.filter((e) => e.id === RESTART).length;
+
+type WatchCb = (curr: { mtimeMs: number }) => void;
+
+// Incompatible preflight → activate() early-returns right after the watchers
+// are armed, keeping these tests off the network/auth/portfolio paths.
+const incompatAdapter = () => ({
+  name: "claude-code",
+  preflight: () => ({ ok: true, compatible: false, version: "9.9.9", reason: "x" }),
+  version: () => "9.9.9",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: false })),
+});
+
+describe("config-file watcher (audit #35)", { timeout: 15_000 }, () => {
+  it("creation + no-op touch never restart the host; a genuine edit does", async () => {
+    const cfgPath = configPath();
+    rmSync(cfgPath, { force: true });           // start absent, like a fresh install
+    const watchers = new Map<string, WatchCb>();
+    __wireForTest({
+      adapter: incompatAdapter() as never,
+      statusBar: { set: vi.fn(), dispose() {} },
+      watchFileFn: ((p: unknown, _o: unknown, cb: WatchCb) => {
+        watchers.set(String(p), cb);
+      }) as never,
+    });
+    await activate(makeContext() as never);
+    const cb = watchers.get(cfgPath);
+    expect(cb, "config watcher must be armed").toBeTruthy();
+    const before = restartCount();
+
+    // 1. CREATION (mtime 0 → T): pre-fix this restarted the ENTIRE extension
+    //    host within 2s of the user's first "Edit Vibe-Ads config…" click,
+    //    before they typed a single character.
+    mkdirSync(dirname(cfgPath), { recursive: true });
+    writeFileSync(cfgPath, '{\n  "debugMode": false\n}\n', "utf8");
+    cb!({ mtimeMs: 1_000 });
+    expect(restartCount(), "creation must not restart").toBe(before);
+
+    // 2. No-op save / touch: mtime changes, content identical.
+    cb!({ mtimeMs: 2_000 });
+    expect(restartCount(), "no-op touch must not restart").toBe(before);
+
+    // 3. Genuine content edit: the watcher's purpose — apply config edits.
+    writeFileSync(cfgPath, '{\n  "debugMode": true\n}\n', "utf8");
+    cb!({ mtimeMs: 3_000 });
+    expect(restartCount(), "a real edit must restart").toBe(before + 1);
+
+    await deactivate();
+    rmSync(cfgPath, { force: true });
+  });
+
+  it("pre-existing config: touches don't restart, edits still do", async () => {
+    const cfgPath = configPath();
+    mkdirSync(dirname(cfgPath), { recursive: true });
+    writeFileSync(cfgPath, '{\n  "updatePollIntervalMs": 90000\n}\n', "utf8");
+    const watchers = new Map<string, WatchCb>();
+    __wireForTest({
+      adapter: incompatAdapter() as never,
+      statusBar: { set: vi.fn(), dispose() {} },
+      watchFileFn: ((p: unknown, _o: unknown, cb: WatchCb) => {
+        watchers.set(String(p), cb);
+      }) as never,
+    });
+    await activate(makeContext() as never);
+    const cb = watchers.get(cfgPath)!;
+    const before = restartCount();
+    cb({ mtimeMs: 1_000 });                     // touch — same content
+    expect(restartCount(), "touch must not restart").toBe(before);
+    writeFileSync(cfgPath, '{\n  "updatePollIntervalMs": 60000\n}\n', "utf8");
+    cb({ mtimeMs: 2_000 });                     // real edit
+    expect(restartCount(), "edit must restart").toBe(before + 1);
+    await deactivate();
+    rmSync(cfgPath, { force: true });
+  });
+});
+
+describe("deactivate ordering + stop budget (audit #36)", { timeout: 20_000 }, () => {
+  it("restores user files BEFORE loopback stops and completes even when a close hangs", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      isPatched: () => true,
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => {
+        lb.order.push("cc.restore");
+        return { ok: true, restored: true };
+      }),
+    };
+    __wireForTest({ adapter: adapter as never,
+      statusBar: { set: vi.fn(), dispose() {} }, killed: true });
+    const ctx = makeContext();
+    // Guarantee the clean-boot canary branch so the debug auto-enable path
+    // mints a (mocked) loopback — dispose() at deactivate then has a server
+    // to stop. A recent canary from a parallel test worker would skip it.
+    // NOTE bootCanary captures its canary path at MODULE LOAD (before this
+    // suite's temp-home swap), so the canary the earlier activations wrote
+    // lives under the REAL home — clear both locations.
+    const realHome = REAL_USERPROFILE || REAL_HOME || homedir();
+    rmSync(join(realHome, ".vibe-ads", "boot.canary"), { force: true });
+    rmSync(join(homedir(), ".vibe-ads", "boot.canary"), { force: true });
+    await activate(ctx as never);
+    // Simulate the user disabling via the menu BEFORE shutdown so the
+    // uninstall-hygiene restore branch runs (same as extension.test.ts).
+    await ctx.globalState.update("kickbacks.debug.on", false);
+    adapter.restore.mockClear();
+    lb.order.length = 0;
+    lb.hangStops = true;          // every server close now hangs forever
+    const t0 = Date.now();
+    // Pre-fix: deactivate awaited the hung stop FIRST and never resolved —
+    // no restore ever ran (this test then fails by timeout).
+    await deactivate();
+    const elapsed = Date.now() - t0;
+    lb.hangStops = false;
+    expect(adapter.restore, "CC must be restored despite the hung close")
+      .toHaveBeenCalledWith({ keepCsp: true });
+    const restoreIdx = lb.order.indexOf("cc.restore");
+    const stopIdx = lb.order.indexOf("loopback.stop");
+    expect(restoreIdx, "CC restore must run").toBeGreaterThanOrEqual(0);
+    expect(stopIdx, "loopback stop must still be attempted")
+      .toBeGreaterThanOrEqual(0);
+    expect(restoreIdx, "files restored BEFORE loopback stop")
+      .toBeLessThan(stopIdx);
+    expect(elapsed, "deactivate bounded by the stop budget").toBeLessThan(8_000);
+  });
+});
diff --git a/test/extension.test.ts b/test/extension.test.ts
new file mode 100644
index 0000000..d6ed32a
--- /dev/null
+++ b/test/extension.test.ts
@@ -0,0 +1,367 @@
+import { describe, it, expect, vi } from "vitest";
+
+// Mute dlog so test-driven activate() calls don't append to the developer's
+// real ~/.vibe-ads/debug.log. Same pattern auth.test.ts and commands.test.ts
+// use. Without this, every test run leaves "build dev" / "9.9.9" preflight
+// noise interleaved with the user's real extension events, which was
+// misdiagnosed once as an extension restart loop.
+vi.mock("../src/log", () => ({ debugEnabled: () => false, dlog: () => {},
+  dlogRaw: () => {}, codexEnabled: () => false, codexDisabled: () => false,
+  codexCliEnabled: () => false, testHooksEnabled: () => false,
+  debugIconDataUri: () => "",
+  LOG_PATH: "/tmp/test-log" }));
+
+// Hermetic ~/.claude/settings.json: activate() wires the claude-cli
+// statusline adapter at the developer's REAL settings file — on the normal
+// path via cliSync, and (audit #22) on the incompatible early-return path.
+// Mock the adapter so test-driven activations never touch that file;
+// `cliRestore` records restore() calls for the #22 regression below.
+const cliRestore = vi.hoisted(() =>
+  vi.fn(() => ({ ok: true, restored: true })));
+vi.mock("../src/adapters/claude-cli/adapter", () => ({
+  resolveStatuslineAsset: () => "",
+  ClaudeCliStatuslineAdapter: class {
+    name = "claude-cli-statusline";
+    spinnerVerbsSupported = true;
+    version() { return "cli"; }
+    preflight() { return { ok: true, compatible: true, version: "cli" }; }
+    applyPatch() { return { ok: true }; }
+    restore = cliRestore;
+  },
+}));
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { makeContext, _warned, commands } from "./mocks/vscode";
+import { ImpressionDedupe } from "../src/metrics/dedupe";
+
+/** Redirect HOME/USERPROFILE to a fresh temp dir for one test: hermetic
+ *  ~/.vibe-ads/boot.canary (a lingering real-home canary < 90s old reads as
+ *  a crash and suspends auto-enable) and a guaranteed-empty extension scan
+ *  for the codex strand-restore's locateCodexTarget(). */
+function tempHome() {
+  const home = mkdtempSync(join(tmpdir(), "kb-ext-home-"));
+  const prevHome = process.env.HOME;
+  const prevUser = process.env.USERPROFILE;
+  process.env.HOME = home;
+  process.env.USERPROFILE = home;   // os.homedir() reads this on Windows
+  return () => {
+    if (prevHome !== undefined) process.env.HOME = prevHome;
+    else delete process.env.HOME;
+    if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+    else delete process.env.USERPROFILE;
+    try { rmSync(home, { recursive: true, force: true }); } catch { /* ok */ }
+  };
+}
+
+it("loopback impression path dedupes per adId (one bill per ad)", () => {
+  const d = new ImpressionDedupe();
+  const sent: string[] = [];
+  const onEvent = (k: string, adId: string) => {
+    if (d.shouldSend(k, adId)) sent.push(k + ":" + adId);
+  };
+  onEvent("impression_rendered", "adX");
+  onEvent("impression_rendered", "adX");
+  expect(sent).toEqual(["impression_rendered:adX"]);
+});
+
+describe("extension orchestration", { timeout: 15_000 }, () => {
+  it("incompatible target -> no patch, status incompatible, never throws", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: false, version: "9.9.9", reason: "x" }),
+      version: () => "9.9.9",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, statusBar: sb });
+    await expect(activate(makeContext() as never)).resolves.toBeUndefined();
+    expect(adapter.applyPatch).not.toHaveBeenCalled();
+    expect(sb.set).toHaveBeenCalledWith(expect.objectContaining({ kind: "incompatible" }));
+    // A non-structural reason ("x") must NOT pop the incompat warning — that's
+    // reserved for a genuine "verb array not found" miss. (Match the message,
+    // not a count: unrelated warnings — e.g. the boot canary — share the mock.)
+    expect(_warned.some((t) => t.includes("spinner hook"))).toBe(false);
+    await deactivate();
+  });
+
+  it("audit #22: incompatible/missing Claude Code still cleans the CLI"
+    + " settings surface and leaves it restorable at deactivate", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: false, version: "9.9.9", reason: "x" }),
+      version: () => "9.9.9",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, statusBar: sb });
+    cliRestore.mockClear();
+    await activate(makeContext() as never);
+    // Pre-fix the early return ran before ANY claude-cli adapter existed, so
+    // a stale statusLine/spinnerVerbs patch from a prior session (crash /
+    // CC uninstalled-but-terminal-CLI-kept) was stranded forever. Now it is
+    // cleaned once on this path…
+    expect(cliRestore,
+      "incompatible path must run the key-scoped CLI restore")
+      .toHaveBeenCalled();
+    // …and actx.cliStatus stays wired, so deactivate()'s (previously
+    // null-guarded-away) CLI restore still runs.
+    cliRestore.mockClear();
+    await deactivate();
+    expect(cliRestore,
+      "deactivate must still be able to restore the CLI surface")
+      .toHaveBeenCalled();
+  });
+
+  it("genuine verb-array miss -> warns the user (wiring), status incompatible", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: false, version: "2.1.161",
+        reason: "verb array not found (incompatible build)" }),
+      version: () => "2.1.161",
+      isPatched: () => false,
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, statusBar: sb });
+    await activate(makeContext() as never);
+    expect(adapter.applyPatch).not.toHaveBeenCalled();
+    expect(_warned.some((t) =>
+      t.includes("couldn't find Claude Code 2.1.161's spinner hook")))
+      .toBe(true);
+    await deactivate();
+  });
+
+  it("reload watcher is wired even when debug is OFF", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: false, version: "9.9.9", reason: "x" }),
+      version: () => "9.9.9",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    const watched: string[] = [];
+    __wireForTest({ adapter, statusBar: sb,
+      watchFileFn: ((p: unknown) => { watched.push(String(p)); }) as never });
+    await activate(makeContext() as never);
+    expect(watched.some((p) => p.endsWith("reload"))).toBe(true);
+    await deactivate();
+  });
+
+  it("kill -> restore() called and status killed", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, statusBar: sb, killed: true });
+    await activate(makeContext() as never);
+    await new Promise((r) => setTimeout(r, 10));
+    expect(adapter.restore).toHaveBeenCalled();
+    expect(sb.set).toHaveBeenCalledWith(expect.objectContaining({ kind: "killed" }));
+    await deactivate();
+  });
+
+  it("S9: kill restores the Codex target too (alongside CC)", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const codexAdapter = {
+      name: "codex",
+      preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
+      version: () => "26.513.21555",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, codexAdapter, statusBar: sb, killed: true });
+    await activate(makeContext() as never);
+    await new Promise((r) => setTimeout(r, 10));
+    expect(codexAdapter.restore).toHaveBeenCalled();
+    await deactivate();
+  });
+
+  it("wave-2P-F02: deactivate skips restore when K_ON=true (no pristine flash on reload)", async () => {
+    // Regression for the "no ad after window reload" symptom. Pre-fix,
+    // deactivate() always restored CC's index.js to pristine. The next
+    // ext-host activation re-applied the patch, but CC's webview could read
+    // pristine in between and the user saw default spinner verbs until a
+    // second reload or a 60s reassert tick. Post-fix: when K_ON=true (user
+    // opted in via the debug menu) deactivate leaves the patch in place.
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, statusBar: sb });
+    const ctx = makeContext();
+    // Seed K_ON=true so debugCtl.on() returns true at deactivate time.
+    // Canonical current key is "kickbacks.debug.on"; the "vibe-ads.debug.on"
+    // legacy key is read-through-only (see debug.ts on()). Legacy key parity
+    // is covered by debug.test.ts.
+    await ctx.globalState.update("kickbacks.debug.on", true);
+    await activate(ctx as never);
+    adapter.restore.mockClear();
+    await deactivate();
+    expect(adapter.restore, "deactivate must NOT restore when K_ON=true")
+      .not.toHaveBeenCalled();
+  });
+
+  it("wave-2P-F02: deactivate still restores when K_ON=false (uninstall hygiene)", async () => {
+    // The K_ON-gated skip applies when the user is opted in. The
+    // earlier auto-enable change means activate() flips K_ON to true
+    // by default, so this test forces K_ON=false AFTER activate
+    // (simulating a user who explicitly disabled via the menu) to
+    // exercise the legacy "never leave a user VISIBLY patched on
+    // disable/uninstall" branch — deactivate must restore in that
+    // case.
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, statusBar: sb });
+    const ctx = makeContext();
+    await activate(ctx as never);
+    // Simulate the user disabling via the menu BEFORE shutdown.
+    await ctx.globalState.update("kickbacks.debug.on", false);
+    adapter.restore.mockClear();
+    await deactivate();
+    expect(adapter.restore, "deactivate must restore when K_ON is false")
+      .toHaveBeenCalled();
+  });
+
+  it("codex-only: activation proceeds, K_ON persists, codex/<ver> on the wire,"
+    + " never labeled incompatible", async () => {
+    // The bug a public-mirror PR reported (kickbacks.ai#1): Claude Code
+    // absent (preflight "target not found") + a compatible Codex install
+    // used to hit the claude-only preflight early-return — red
+    // "incompatible" bar, no sign-in command, no serving, K_ON never
+    // persisted. Activation must now proceed on the codex leg alone.
+    const restoreHome = tempHome();
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: false, version: null,
+        reason: "target not found" }),
+      version: () => null,
+      applyPatch: vi.fn(() => ({ ok: false, reason: "target not found" })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const codexAdapter = {
+      name: "codex",
+      preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
+      version: () => "26.513.21555",
+      isPatched: vi.fn(() => true),
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    // Benign backend stub: {} reads as no-ad / not-killed / no-consent, and
+    // the captured URLs pin the ccVersion wire label below.
+    const calls: string[] = [];
+    vi.stubGlobal("fetch", vi.fn(async (input: unknown) => {
+      calls.push(typeof input === "string" ? input : String(input));
+      return { ok: true, status: 200, json: async () => ({}) } as Response;
+    }));
+    commands._handlers.clear();
+    __wireForTest({ adapter, codexAdapter, statusBar: sb });
+    const ctx = makeContext();
+    try {
+      await expect(activate(ctx as never)).resolves.toBeUndefined();
+      // Proceeds: the sign-in surface exists and the bar is never relabeled.
+      expect(commands._handlers.has("kickbacks.signIn")).toBe(true);
+      expect(sb.set).not.toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "incompatible" }));
+      // bootCanary's widened auto-enable persisted K_ON through the
+      // codex-folded DebugController.apply()…
+      expect(ctx.globalState.get("kickbacks.debug.on")).toBe(true);
+      // …which patched Codex.
+      expect(codexAdapter.applyPatch).toHaveBeenCalled();
+      // The host-version label travels the wire as codex/<ver> (killswitch
+      // poll + demo-portfolio fetch), never as a bare CC-shaped version.
+      expect(calls.some((u) => u.includes("codex%2F26.513.21555"))).toBe(true);
+    } finally {
+      await deactivate();
+      vi.unstubAllGlobals();
+      restoreHome();
+    }
+  });
+
+  it("both targets incompatible -> early-return: incompatible label, no"
+    + " sign-in, CLI strand restore still runs", async () => {
+    const restoreHome = tempHome();
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: false, version: "9.9.9", reason: "x" }),
+      version: () => "9.9.9",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const codexAdapter = {
+      name: "codex",
+      preflight: () => ({ ok: true, compatible: false, version: null,
+        reason: "thinking-shimmer anchors not found" }),
+      version: () => null,
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: false })),
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    commands._handlers.clear();
+    cliRestore.mockClear();
+    __wireForTest({ adapter, codexAdapter, statusBar: sb });
+    try {
+      await expect(activate(makeContext() as never)).resolves.toBeUndefined();
+      expect(sb.set).toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "incompatible" }));
+      expect(commands._handlers.has("kickbacks.signIn")).toBe(false);
+      expect(codexAdapter.applyPatch).not.toHaveBeenCalled();
+      // Audit #22's CLI strand restore is preserved on the narrower gate.
+      expect(cliRestore).toHaveBeenCalled();
+    } finally {
+      await deactivate();
+      restoreHome();
+    }
+  });
+
+  it("S9: a throwing Codex adapter never blocks CC or activation", async () => {
+    const adapter = {
+      name: "claude-code",
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const boom = () => { throw new Error("codex exploded"); };
+    const codexAdapter = {
+      name: "codex",
+      preflight: boom as never,
+      version: boom as never,
+      applyPatch: boom as never,
+      restore: boom as never,
+    };
+    const sb = { set: vi.fn(), dispose() {} };
+    __wireForTest({ adapter, codexAdapter, statusBar: sb, killed: true });
+    await expect(activate(makeContext() as never)).resolves.toBeUndefined();
+    expect(adapter.restore).toHaveBeenCalled();          // CC unaffected by Codex throw
+    await expect(deactivate()).resolves.toBeUndefined(); // teardown survives too
+  });
+});
diff --git a/test/fixtures/synthetic-index.js b/test/fixtures/synthetic-index.js
new file mode 100644
index 0000000..7686c5c
--- /dev/null
+++ b/test/fixtures/synthetic-index.js
@@ -0,0 +1 @@
+var X=1;var VERBS=["Accomplishing","Discombobulating","Brewing","Calculating"];console.log(VERBS);var Y=2;
\ No newline at end of file
diff --git a/test/fixtures/synthetic-thinking-shimmer.js b/test/fixtures/synthetic-thinking-shimmer.js
new file mode 100644
index 0000000..c1e1d17
--- /dev/null
+++ b/test/fixtures/synthetic-thinking-shimmer.js
@@ -0,0 +1,32 @@
+// Faithful miniature of the real Codex chunk
+// (openai.chatgpt-26.513.21555 / webview/assets/thinking-shimmer-BcRunliI.js):
+// entry component `v` re-exported `as n`, generic wrapper `g` `as t`, the
+// React-Compiler memo cache `(0,l.c)(N)`, the `({className,message,...}=e)`
+// destructure, the i18n `thinkingShimmer.default` / `Thinking` anchor, and the
+// `(0,d.jsx)` / `(0,d.jsxs)` runtime calls. Read as TEXT by the adapter tests
+// (never executed) — the imports are shape, not live modules.
+import { t as n } from "./jsx-runtime-Do_qqm2M.js";
+import { t as r } from "./compiler-runtime-DAZMUUJC.js";
+import { a as i } from "./lib-BFqEcoZz.js";
+import { c as s } from "./spinner-CuTKgRAj.js";
+var l = r(), d = n();
+function g(e){
+  let t = (0, l.c)(5), n, r, a;
+  ({ className: r, children: n, ...a } = e);
+  return (0, d.jsxs)(`span`, { className: r, ...a, children: [n] });
+}
+function v(e){
+  let t = (0, l.c)(12), n, r, a;
+  t[0] === e ? (n = t[1], r = t[2], a = t[3])
+    : ({ className: n, message: r, ...a } = e, t[0] = e, t[1] = n, t[2] = r, t[3] = a);
+  let o;
+  t[4] === n ? o = t[5] : (o = s(`text-size-chat truncate`, n), t[4] = n, t[5] = o);
+  let c;
+  t[6] === r ? c = t[7]
+    : (c = r ?? (0, d.jsx)(i, {id:`thinkingShimmer.default`,defaultMessage:`Thinking`,description:`Default placeholder shown while the assistant is thinking`}), t[6] = r, t[7] = c);
+  let u;
+  return t[8] !== a || t[9] !== o || t[10] !== c
+    ? (u = (0, d.jsx)(g, { className: o, ...a, children: c }), t[8] = a, t[9] = o, t[10] = c, t[11] = u)
+    : u = t[11], u;
+}
+export { v as n, g as t };
diff --git a/test/fleetSignals.test.ts b/test/fleetSignals.test.ts
new file mode 100644
index 0000000..7de818a
--- /dev/null
+++ b/test/fleetSignals.test.ts
@@ -0,0 +1,340 @@
+// Fleet-chattiness fix (2026-06-12): the backend piggybacks the killswitch
+// verdict (`kill`) and status-bar earnings (`balances` incl. `cap`) on the
+// portfolio + metrics responses, and the extension stands its standalone
+// /v1/killswitch + /v1/earnings pollers down while that data is fresh.
+// Load-bearing rules pinned here:
+//   - only a FRESH 2xx parse produces a signal (never the warm-cache path);
+//   - a piggybacked verdict can never produce the OFFLINE posture;
+//   - cap-KEY-presence (even null) is the new-backend marker; without it the
+//     earnings poll keeps running (old backend ⇒ today's behavior);
+//   - registration replays the boot-time buffered verdict.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import {
+  FleetSignals, parseKillField, parseBalancesField,
+  KILL_STALE_MS,
+} from "../src/fleetSignals";
+import { PortfolioClient } from "../src/portfolio/client";
+import { MetricsClient } from "../src/metrics/client";
+import { setupEarningsRefresh } from "../src/activation/earningsRefresh";
+import { EarningsClient } from "../src/earnings/client";
+import { SessionState } from "../src/sessionState";
+import type { AuthClient } from "../src/auth/client";
+import { makeContext } from "./mocks/vscode";
+
+afterEach(() => {
+  vi.useRealTimers();
+});
+
+const ok = (body: unknown) =>
+  ({ ok: true, status: 200, json: async () => body }) as Response;
+
+// ─── parsers ────────────────────────────────────────────────────────────────
+
+describe("parseKillField", () => {
+  it("maps a clear verdict; fresh 200 semantics (confirmed=killed)", () => {
+    expect(parseKillField({ killed: false, scope: null, reason: "" }))
+      .toEqual({ killed: false, confirmed: false, scope: undefined,
+                 reason: "", offline: false });
+  });
+
+  it("maps a kill verdict with confirmed=true", () => {
+    const ks = parseKillField({ killed: true, reason: "fail-safe" });
+    expect(ks).toMatchObject({ killed: true, confirmed: true,
+                               reason: "fail-safe" });
+  });
+
+  it("NEVER produces the offline posture", () => {
+    for (const raw of [{ killed: true }, { killed: false },
+                       { killed: true, offline: true }]) {
+      expect(parseKillField(raw)?.offline).toBe(false);
+    }
+  });
+
+  it("returns undefined (no signal) for missing/malformed input", () => {
+    for (const raw of [undefined, null, "x", 7, {}, { killed: "yes" }]) {
+      expect(parseKillField(raw)).toBeUndefined();
+    }
+  });
+});
+
+describe("parseBalancesField", () => {
+  it("cap KEY present (null) ⇒ capCapable, cap undefined", () => {
+    const s = parseBalancesField(
+      { lifetime_usd: "1.20", today_usd: "0.04", cap: null });
+    expect(s).toMatchObject({ lifetimeUsd: "1.20", todayUsd: "0.04",
+                              capCapable: true });
+    expect(s?.cap).toBeUndefined();
+  });
+
+  it("cap KEY absent ⇒ NOT capCapable (old backend marker)", () => {
+    const s = parseBalancesField({ lifetime_usd: "1.20", today_usd: "0.04" });
+    expect(s?.capCapable).toBe(false);
+  });
+
+  it("parses a real cap block via parseCap", () => {
+    const s = parseBalancesField({
+      lifetime_usd: "1.20", today_usd: "0.04",
+      cap: { scope: "daily", cap_usd: "50.00", reset_seconds: 3600 } });
+    expect(s?.cap).toEqual({ scope: "daily", capUsd: "50.00",
+                             resetSeconds: 3600 });
+  });
+
+  it("returns undefined for malformed input", () => {
+    for (const raw of [undefined, null, {}, { lifetime_usd: 5 },
+                       { lifetime_usd: "1", today_usd: 2 }]) {
+      expect(parseBalancesField(raw)).toBeUndefined();
+    }
+  });
+});
+
+// ─── the store ──────────────────────────────────────────────────────────────
+
+describe("FleetSignals store", () => {
+  it("noteKill stamps freshness and fires the sink", () => {
+    const fs = new FleetSignals();
+    const sink = vi.fn();
+    fs.onKillVerdict(sink);
+    expect(fs.killFreshWithin(KILL_STALE_MS)).toBe(false);
+    fs.noteKill({ killed: false });
+    expect(sink).toHaveBeenCalledTimes(1);
+    expect(fs.killFreshWithin(KILL_STALE_MS)).toBe(true);
+  });
+
+  it("malformed kill input produces NO signal and NO freshness", () => {
+    const fs = new FleetSignals();
+    const sink = vi.fn();
+    fs.onKillVerdict(sink);
+    fs.noteKill({ killed: "nope" });
+    fs.noteKill(undefined);
+    expect(sink).not.toHaveBeenCalled();
+    expect(fs.killFreshWithin(KILL_STALE_MS)).toBe(false);
+  });
+
+  it("freshness expires after KILL_STALE_MS", () => {
+    vi.useFakeTimers();
+    const fs = new FleetSignals();
+    fs.noteKill({ killed: false });
+    vi.advanceTimersByTime(KILL_STALE_MS - 1);
+    expect(fs.killFreshWithin(KILL_STALE_MS)).toBe(true);
+    vi.advanceTimersByTime(2);
+    expect(fs.killFreshWithin(KILL_STALE_MS)).toBe(false);
+  });
+
+  it("onKillVerdict REPLAYS a fresh buffered verdict at registration "
+     + "(boot portfolio fetch precedes the kill machinery)", () => {
+    const fs = new FleetSignals();
+    fs.noteKill({ killed: true, reason: "global" });   // before any sink
+    const sink = vi.fn();
+    fs.onKillVerdict(sink);
+    expect(sink).toHaveBeenCalledTimes(1);
+    expect(sink.mock.calls[0][0]).toMatchObject({ killed: true,
+                                                  confirmed: true });
+  });
+
+  it("noteBalances stores a snapshot and fires the earnings sink", () => {
+    const fs = new FleetSignals();
+    const sink = vi.fn();
+    fs.onEarningsUpdated(sink);
+    fs.noteBalances({ lifetime_usd: "2.00", today_usd: "0.10", cap: null });
+    expect(sink).toHaveBeenCalledTimes(1);
+    expect(fs.earningsSnapshot()).toMatchObject({ lifetimeUsd: "2.00" });
+    fs.clearEarnings();
+    expect(fs.earningsSnapshot()).toBeNull();
+  });
+});
+
+// ─── wire-client integration ────────────────────────────────────────────────
+
+const AD = { ad_id: "a1", campaign_id: "c1", title_text: "x".repeat(35),
+             icon_ref: "i", click_url: "https://t/x", session_token: "tok" };
+
+describe("PortfolioClient fleet-signal sink", () => {
+  it("fires kill + balances from a fresh AUTHED 2xx", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    const f = vi.fn(async () => ok({
+      ttl_seconds: 60, ads: [AD],
+      kill: { killed: false, scope: null, reason: "" },
+      balances: { lifetime_usd: "1.20", today_usd: "0.04", cap: null },
+    }));
+    const c = new PortfolioClient("http://b", () => "tok", f as never, signals);
+    await c.fetchPortfolio("2.1.143");
+    expect(signals.noteKill).toHaveBeenCalledWith(
+      { killed: false, scope: null, reason: "" });
+    expect(signals.noteBalances).toHaveBeenCalledWith(
+      { lifetime_usd: "1.20", today_usd: "0.04", cap: null });
+  });
+
+  it("fires kill even when a global kill EMPTIES the inventory", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    const f = vi.fn(async () => ok({
+      ttl_seconds: 60, ads: [], kill: { killed: true, reason: "global" } }));
+    const c = new PortfolioClient("http://b", () => "tok", f as never, signals);
+    await c.fetchPortfolio("2.1.143");
+    expect(signals.noteKill).toHaveBeenCalledWith(
+      { killed: true, reason: "global" });
+  });
+
+  it("does NOT fire from the warm-cache fallback path", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    let n = 0;
+    const f = vi.fn(async () => {
+      n++;
+      if (n === 1) return ok({ ttl_seconds: 60, ads: [AD],
+                               kill: { killed: false } });
+      throw new Error("network down");
+    });
+    const c = new PortfolioClient("http://b", () => "tok", f as never, signals);
+    await c.fetchPortfolio("2.1.143");          // primes cache, 1 signal
+    const r = await c.fetchPortfolio("2.1.143"); // error → warm cache serve
+    expect(r).not.toBeNull();
+    expect(signals.noteKill).toHaveBeenCalledTimes(1); // no second signal
+  });
+
+  it("the DEMO route fires kill but never balances", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    const f = vi.fn(async () => ok({
+      ttl_seconds: 60, ads: [AD], kill: { killed: false } }));
+    const c = new PortfolioClient("http://b", () => null, f as never, signals);
+    await c.fetchDemoPortfolio("2.1.143", "dev-1");
+    expect(signals.noteKill).toHaveBeenCalledTimes(1);
+    expect(signals.noteBalances).not.toHaveBeenCalled();
+  });
+
+  it("threads the campaign param onto the portfolio URL", async () => {
+    const urls: string[] = [];
+    const f = vi.fn(async (url: string) => {
+      urls.push(url);
+      return ok({ ttl_seconds: 60, ads: [] });
+    });
+    const c = new PortfolioClient("http://b", () => "tok", f as never);
+    await c.fetchPortfolio("2.1.143", "camp-9");
+    expect(urls[0]).toContain("&campaign=camp-9");
+  });
+});
+
+describe("MetricsClient fleet-signal sink", () => {
+  const send = async (signals: { noteKill: ReturnType<typeof vi.fn>;
+                                 noteBalances: ReturnType<typeof vi.fn> },
+                      token: string | null, body: unknown,
+                      okResp = true) => {
+    const f = vi.fn(async () => okResp
+      ? ok(body)
+      : ({ ok: false, status: 500 }) as Response);
+    const m = new MetricsClient("http://b", () => token, () => "cid",
+      "0.0.1", f as never, undefined, signals);
+    await m.send("click", { adId: "a1", campaignId: "c1",
+                            ccVersion: "2.1.143" });
+  };
+
+  it("authed 2xx: kill + balances both reach the store", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    await send(signals, "tok", {
+      measurement: "measured", billed: true,
+      kill: { killed: false },
+      balances: { lifetime_usd: "2.00", today_usd: "0.10", cap: null } });
+    expect(signals.noteKill).toHaveBeenCalledWith({ killed: false });
+    expect(signals.noteBalances).toHaveBeenCalledWith(
+      { lifetime_usd: "2.00", today_usd: "0.10", cap: null });
+  });
+
+  it("signed-out (demo route): kill only, never balances", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    await send(signals, null, {
+      measurement: "measured", billed: true, kill: { killed: false },
+      balances: { lifetime_usd: "9.99", today_usd: "9.99" } });
+    expect(signals.noteKill).toHaveBeenCalledTimes(1);
+    expect(signals.noteBalances).not.toHaveBeenCalled();
+  });
+
+  it("a non-2xx response produces no signals", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    await send(signals, "tok", {}, false);
+    expect(signals.noteKill).not.toHaveBeenCalled();
+    expect(signals.noteBalances).not.toHaveBeenCalled();
+  });
+
+  it("an old-backend empty body produces no signals and no throw", async () => {
+    const signals = { noteKill: vi.fn(), noteBalances: vi.fn() };
+    const f = vi.fn(async () => ({ ok: true, status: 200,
+      json: async () => { throw new Error("no body"); } }) as never);
+    const m = new MetricsClient("http://b", () => "tok", () => "cid",
+      "0.0.1", f as never, undefined, signals);
+    await m.send("click", { adId: "a1", campaignId: "c1",
+                            ccVersion: "2.1.143" });
+    expect(signals.noteKill).not.toHaveBeenCalled();
+  });
+});
+
+// ─── earningsRefresh fast path ──────────────────────────────────────────────
+
+function wire(f: typeof fetch, signals: FleetSignals | null) {
+  let tok: string | null = null;
+  const session = new SessionState();
+  const statusBar = { set: vi.fn() };
+  const capWarning = { show: vi.fn(), hide: vi.fn() };
+  const auth = { accessToken: () => tok } as unknown as AuthClient;
+  const client = new EarningsClient("http://x", () => tok, f);
+  const { showActive } = setupEarningsRefresh(
+    auth, client, session, statusBar, "2.1.143", makeContext() as never,
+    undefined, capWarning, signals);
+  return { session, statusBar, capWarning, showActive,
+           setToken: (t: string | null) => { tok = t; } };
+}
+
+describe("setupEarningsRefresh fleet-signal fast path", () => {
+  it("paints from a fresh capCapable snapshot with ZERO network fetches", async () => {
+    const fetchSpy = vi.fn(async () => { throw new Error("must not fetch"); });
+    const signals = new FleetSignals();
+    signals.noteBalances({ lifetime_usd: "3.00", today_usd: "0.30",
+                           cap: null });
+    const { statusBar, showActive, setToken } =
+      wire(fetchSpy as never, signals);
+    setToken("tok");
+    await showActive();
+    expect(fetchSpy).not.toHaveBeenCalled();
+    const last = statusBar.set.mock.calls.at(-1)?.[0] as { kind: string };
+    expect(last).toEqual({ kind: "active", version: "2.1.143",
+                           usd: "3.00", usdToday: "0.30" });
+  });
+
+  it("drives the cap pill from the snapshot's cap", async () => {
+    const signals = new FleetSignals();
+    signals.noteBalances({ lifetime_usd: "3.00", today_usd: "50.00",
+      cap: { scope: "daily", cap_usd: "50.00", reset_seconds: 60 } });
+    const { capWarning, showActive, setToken } =
+      wire((async () => { throw new Error("no"); }) as never, signals);
+    setToken("tok");
+    await showActive();
+    expect(capWarning.show).toHaveBeenCalledWith(
+      { scope: "daily", capUsd: "50.00", resetSeconds: 60 });
+  });
+
+  it("falls back to GET /v1/earnings when the carrier was NOT capCapable "
+     + "(old backend keeps today's behavior)", async () => {
+    const fetchSpy = vi.fn(async () => ({ ok: true,
+      json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }) }));
+    const signals = new FleetSignals();
+    signals.noteBalances({ lifetime_usd: "9.99", today_usd: "9.99" }); // no cap key
+    const { statusBar, showActive, setToken } =
+      wire(fetchSpy as never, signals);
+    setToken("tok");
+    await showActive();
+    expect(fetchSpy).toHaveBeenCalled();
+    const last = statusBar.set.mock.calls.at(-1)?.[0] as { usd?: string };
+    expect(last.usd).toBe("1.20");   // network figures, not the snapshot
+  });
+
+  it("falls back to the network once the snapshot goes stale", async () => {
+    vi.useFakeTimers();
+    const fetchSpy = vi.fn(async () => ({ ok: true,
+      json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }) }));
+    const signals = new FleetSignals();
+    signals.noteBalances({ lifetime_usd: "3.00", today_usd: "0.30",
+                           cap: null });
+    const { showActive, setToken } = wire(fetchSpy as never, signals);
+    setToken("tok");
+    vi.advanceTimersByTime(91_000);
+    await showActive();
+    expect(fetchSpy).toHaveBeenCalled();
+  });
+});
diff --git a/test/honest-incompatible.test.ts b/test/honest-incompatible.test.ts
new file mode 100644
index 0000000..60a6693
--- /dev/null
+++ b/test/honest-incompatible.test.ts
@@ -0,0 +1,20 @@
+import { describe, it, expect } from "vitest";
+import { applyMissStatus } from "../src/activation/webviewInjection";
+
+// Regression: a transient injection miss (loopback port race on
+// reload/self-update, or a single applyPatch failure) must NOT relabel a
+// still-live, ad-serving block as "incompatible". This was the cosmetic
+// "Kickbacks incompatible" label users saw while ads were actually serving.
+// The first param is "ANY webview target patched" (S9 dual target) — a live
+// Codex block keeps the label honest exactly like a live Claude Code block;
+// the union is pinned in webviewInjection-dualtarget.test.ts.
+describe("applyMissStatus (honest incompatible label)", () => {
+  it("defers to active (null) when a block is already live (either target)", () => {
+    expect(applyMissStatus(true, "2.1.161")).toBeNull();
+  });
+
+  it("reports incompatible ONLY when NO target is patched", () => {
+    expect(applyMissStatus(false, "2.1.161"))
+      .toEqual({ kind: "incompatible", version: "2.1.161" });
+  });
+});
diff --git a/test/hooksE2E.test.ts b/test/hooksE2E.test.ts
new file mode 100644
index 0000000..695f242
--- /dev/null
+++ b/test/hooksE2E.test.ts
@@ -0,0 +1,297 @@
+// Hook-driven end-to-end: spins up the real activate() pipeline (auth →
+// portfolio → ad → loopback → CC adapter → timers → CLI sync), then drives
+// `kickbacks.test.*` through the mock vscode dispatcher to exercise the
+// whole metric/click/state surface. Hermetic: every network call is captured
+// by the fetch stub, every command goes through the real registerCommand /
+// executeCommand path on the mock. No real install touched.
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// The global setup mock at test/setup.ts already returns testHooksEnabled:
+// true, debugEnabled: false. That gives the test hooks a register-and-fire
+// path without polluting the developer's ~/.vibe-ads/debug.log.
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { makeContext, secrets, _opened, _shown, _openedDocs, commands }
+  from "./mocks/vscode";
+
+const AD = {
+  ad_id: "ad-e2e", campaign_id: "camp-e2e", title_text: "Acme - faster CI",
+  icon_ref: "icon", click_url: "https://acme.example/lp", banner_enabled: false,
+};
+
+const mkAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+/** Fetch stub. Captures every URL+init so individual tests can assert what
+ *  the activation chain (or a fired hook) actually sent. Returns:
+ *    /v1/portfolio  -> one ad, view_threshold_seconds configurable
+ *    /v1/killswitch -> not killed
+ *    /v1/earnings   -> $42.00 lifetime / $1.50 today (when signed in)
+ *    /v1/metrics    -> 204 — the URL of interest for hook assertions
+ *    everything else -> {} 200
+ *  Tests mutate `viewThresholdSeconds` and `adOverride` to test variants. */
+function stubFetch(opts: { viewThresholdSeconds?: number;
+                            adOverride?: typeof AD | null;
+                            killed?: boolean } = {}) {
+  const calls: { url: string; method: string; body?: unknown;
+                 headers: Record<string, string> }[] = [];
+  const f = vi.fn(async (input: unknown, init?: { method?: string;
+      body?: string; headers?: Record<string, string> }) => {
+    const url = typeof input === "string" ? input : String(input);
+    const method = (init?.method || "GET").toUpperCase();
+    const body = init?.body ? safeJson(init.body) : undefined;
+    calls.push({ url, method, body, headers: init?.headers || {} });
+    if (url.includes("/v1/auth/extension/poll")) {
+      return { ok: true, status: 200, json: async () =>
+        ({ access_token: "AT-E2E", refresh_token: "RT-E2E", expires_in: 3600 }) } as Response;
+    }
+    if (url.includes("/v1/portfolio")) {
+      const ad = opts.adOverride === null ? null : (opts.adOverride || AD);
+      return { ok: true, status: 200, json: async () => ({
+        ttl_seconds: 30,
+        view_threshold_seconds: opts.viewThresholdSeconds ?? 15,
+        queue_id: "q-1",
+        balances: { lifetime_usd: "0.00", today_usd: "0.00", last_updated_ms: 0 },
+        ads: ad ? [ad] : [],
+      }) } as Response;
+    }
+    if (url.includes("/v1/killswitch")) {
+      return { ok: true, status: 200, json: async () => ({ killed: !!opts.killed }) } as Response;
+    }
+    if (url.includes("/v1/earnings")) {
+      return { ok: true, status: 200, json: async () =>
+        ({ lifetime_usd: "42.00", today_usd: "1.50" }) } as Response;
+    }
+    // /v1/metrics and everything else: 204 OK.
+    return { ok: true, status: 204, json: async () => ({}) } as Response;
+  });
+  vi.stubGlobal("fetch", f);
+  return { f, calls };
+}
+
+function safeJson(s: string): unknown {
+  try { return JSON.parse(s); } catch { return s; }
+}
+
+async function boot(opts: { viewThresholdSeconds?: number;
+                             adOverride?: typeof AD | null;
+                             killed?: boolean } = {}) {
+  const home = mkdtempSync(join(tmpdir(), "kb-hooksE2E-"));
+  const prevHome = process.env.HOME;
+  const prevUser = process.env.USERPROFILE;
+  process.env.HOME = home;
+  process.env.USERPROFILE = home;
+  const adapter = mkAdapter();
+  const statusBar = { set: vi.fn(), dispose: vi.fn() };
+  __wireForTest({ adapter, statusBar });
+  const fetched = stubFetch(opts);
+  const ctx = makeContext();
+  // Pre-seed an access token via the ext-host secret store. AuthClient's
+  // loadCached() reads ctx.secrets first; this gets us "signed in" without
+  // driving the broker poll loop and keeps every test ≤ a few hundred ms.
+  await ctx.secrets.store("kickbacks.access", "AT-E2E");
+  await ctx.secrets.store("kickbacks.refresh", "RT-E2E");
+  await ctx.secrets.store("kickbacks.clientId", "CID-E2E");
+  await activate(ctx as never);
+  return {
+    home, adapter, statusBar, ctx, fetched,
+    metricsPosts: () => fetched.calls.filter(
+      (c) => c.url.endsWith("/v1/metrics")),
+    async dispose() {
+      await deactivate();
+      if (prevHome !== undefined) process.env.HOME = prevHome;
+      else delete process.env.HOME;
+      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+      else delete process.env.USERPROFILE;
+      try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
+    },
+  };
+}
+
+beforeEach(() => {
+  secrets.clear();
+  commands._handlers.clear();
+  commands._executed.length = 0;
+  _opened.length = 0;
+  _shown.length = 0;
+  _openedDocs.length = 0;
+  __wireForTest({});
+});
+
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe("hooks E2E — every kickbacks.test.* command drives the real lifecycle",
+  () => {
+
+  it("fireImpressionRendered → one POST /v1/metrics with the loaded ad,"
+    + " surface=overlay, X-Vibe-Corr header, ring buffer captures the send",
+    async () => {
+      const env = await boot();
+      try {
+        const r = await commands.executeCommand(
+          "kickbacks.test.fireImpressionRendered") as { ok: boolean; sent: unknown };
+        expect(r.ok).toBe(true);
+        const metricPosts = env.metricsPosts();
+        expect(metricPosts).toHaveLength(1);
+        expect(metricPosts[0].body).toMatchObject({
+          event_type: "impression_rendered",
+          ad_id: "ad-e2e",
+          campaign_id: "camp-e2e",
+          surface: "overlay",
+          claude_code_version: "2.1.143",
+        });
+        expect(metricPosts[0].headers["X-Vibe-Corr"]).toBeTypeOf("string");
+        const snap = await commands.executeCommand(
+          "kickbacks.test.getState") as { lastEvents: { event: string }[] };
+        expect(snap.lastEvents.some((e) => e.event === "impression_rendered")).toBe(true);
+      } finally { await env.dispose(); }
+    });
+
+  it("surface coverage: impression_rendered + impression_viewable + click"
+    + " across overlay / banner / codex_overlay / statusline ⇒ 12 POSTs",
+    async () => {
+      const env = await boot();
+      try {
+        const surfaces = ["overlay", "banner", "codex_overlay", "statusline"] as const;
+        for (const surface of surfaces) {
+          await commands.executeCommand(
+            "kickbacks.test.fireImpressionRendered", { surface });
+          await commands.executeCommand(
+            "kickbacks.test.fireImpressionViewable", { surface });
+          await commands.executeCommand("kickbacks.test.fireClick", { surface });
+        }
+        const posts = env.metricsPosts();
+        expect(posts).toHaveLength(12);
+        for (const surface of surfaces) {
+          expect(posts.filter((p) =>
+            (p.body as { surface?: string }).surface === surface)).toHaveLength(3);
+        }
+        const events = posts.map((p) => (p.body as { event_type: string }).event_type);
+        expect(events.filter((e) => e === "impression_rendered")).toHaveLength(4);
+        expect(events.filter((e) => e === "impression_viewable")).toHaveLength(4);
+        expect(events.filter((e) => e === "click")).toHaveLength(4);
+      } finally { await env.dispose(); }
+    });
+
+  it("fireViewThresholdMet without visibleMs uses portfolio's viewThresholdMs"
+    + " (server-controlled credit threshold) on the wire", async () => {
+      const env = await boot({ viewThresholdSeconds: 20 });
+      try {
+        const r = await commands.executeCommand(
+          "kickbacks.test.fireViewThresholdMet") as { ok: boolean };
+        expect(r.ok).toBe(true);
+        const post = env.metricsPosts()[0];
+        expect(post.body).toMatchObject({
+          event_type: "view_threshold_met",
+          visible_ms: 20000,
+          view_ms: 20000,
+          viewable: true,
+          view_pct: 100,
+        });
+      } finally { await env.dispose(); }
+    });
+
+  it("test-hook path BYPASSES ImpressionDedupe — two impression_rendered fires"
+    + " ⇒ two POSTs (the production loopback path dedupes; hooks pin this"
+    + " distinction so a regression in the controller can't sneak in)",
+    async () => {
+      const env = await boot();
+      try {
+        await commands.executeCommand("kickbacks.test.fireImpressionRendered");
+        await commands.executeCommand("kickbacks.test.fireImpressionRendered");
+        expect(env.metricsPosts()).toHaveLength(2);
+      } finally { await env.dispose(); }
+    });
+
+  it("getState reflects ad loaded + signedIn + loopback active", async () => {
+    const env = await boot();
+    try {
+      const s = await commands.executeCommand(
+        "kickbacks.test.getState") as {
+          enabled: boolean; signedIn: boolean; killed: boolean;
+          ad: { adId: string } | null; loopback: { port: number } | null;
+          viewThresholdMs: number };
+      expect(s.enabled).toBe(true);
+      expect(s.signedIn).toBe(true);
+      expect(s.killed).toBe(false);
+      expect(s.ad?.adId).toBe("ad-e2e");
+      expect(s.viewThresholdMs).toBe(15000);
+      // Loopback comes up async during activate; either active OR null is
+      // acceptable for the snapshot — what we pin is "no crash, no garbage".
+      if (s.loopback) {
+        expect(typeof s.loopback.port).toBe("number");
+        expect(s.loopback.port).toBeGreaterThan(0);
+      }
+    } finally { await env.dispose(); }
+  });
+
+  it("clearEventLog mid-session wipes the ring; subsequent fires record again",
+    async () => {
+      const env = await boot();
+      try {
+        await commands.executeCommand("kickbacks.test.fireImpressionRendered");
+        await commands.executeCommand("kickbacks.test.fireImpressionViewable");
+        await commands.executeCommand("kickbacks.test.fireClick");
+        await commands.executeCommand("kickbacks.test.clearEventLog");
+        const before = await commands.executeCommand(
+          "kickbacks.test.getState") as { lastEvents: unknown[] };
+        expect(before.lastEvents).toHaveLength(0);
+        await commands.executeCommand("kickbacks.test.fireViewTick");
+        const after = await commands.executeCommand(
+          "kickbacks.test.getState") as { lastEvents: { event: string }[] };
+        expect(after.lastEvents).toHaveLength(1);
+        expect(after.lastEvents[0].event).toBe("view_tick");
+      } finally { await env.dispose(); }
+    });
+
+  it("refreshPortfolio re-fetches /v1/portfolio and the snapshot reflects it",
+    async () => {
+      const env = await boot();
+      try {
+        const before = env.fetched.calls.filter(
+          (c) => c.url.includes("/v1/portfolio")).length;
+        const got = await commands.executeCommand(
+          "kickbacks.test.refreshPortfolio") as { adId: string } | null;
+        expect(got?.adId).toBe("ad-e2e");
+        const after = env.fetched.calls.filter(
+          (c) => c.url.includes("/v1/portfolio")).length;
+        expect(after).toBeGreaterThan(before);
+      } finally { await env.dispose(); }
+    });
+
+  it("refreshEarnings hits /v1/earnings and returns the parsed payload",
+    async () => {
+      const env = await boot();
+      try {
+        const e = await commands.executeCommand(
+          "kickbacks.test.refreshEarnings") as
+          { lifetimeUsd: string; todayUsd: string } | null;
+        expect(e).toEqual({ lifetimeUsd: "42.00", todayUsd: "1.50" });
+      } finally { await env.dispose(); }
+    });
+
+  it("no ad loaded ⇒ fire returns ok:false (gate still on); adId override"
+    + " lets a test inject an arbitrary ad for backend-shape verification",
+    async () => {
+      const env = await boot({ adOverride: null });
+      try {
+        const noAd = await commands.executeCommand(
+          "kickbacks.test.fireImpressionRendered") as { ok: boolean; reason?: string };
+        expect(noAd.ok).toBe(false);
+        expect(noAd.reason).toMatch(/no ad/i);
+        const withOverride = await commands.executeCommand(
+          "kickbacks.test.fireImpressionRendered",
+          { adId: "ad-X", campaignId: "camp-X" }) as { ok: boolean };
+        expect(withOverride.ok).toBe(true);
+        const post = env.metricsPosts()[0];
+        expect(post.body).toMatchObject({ ad_id: "ad-X", campaign_id: "camp-X" });
+      } finally { await env.dispose(); }
+    });
+});
\ No newline at end of file
diff --git a/test/icon-render.test.ts b/test/icon-render.test.ts
new file mode 100644
index 0000000..ea9a019
--- /dev/null
+++ b/test/icon-render.test.ts
@@ -0,0 +1,109 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import vm from "node:vm";
+
+function load(iconUrl: string = "") {
+  let src = readFileSync(
+    join(__dirname, "../src/adapters/claude-code/block.asset.js"), "utf8");
+  const subs: Record<string, string> = {
+    __VIBE_ADS_TIER__: "3",
+    __VIBE_ADS_AD__: JSON.stringify("Acme deploys faster than your CI"),
+    __VIBE_ADS_ICON__: JSON.stringify("icon.a"),
+    __VIBE_ADS_ICON_URL__: JSON.stringify(iconUrl),
+    __VIBE_ADS_PORT__: "5555",
+    __VIBE_ADS_LBTOKEN__: JSON.stringify("lt"),
+    __VIBE_ADS_CLICKTOKEN__: JSON.stringify("ck"),
+    __VIBE_ADS_BASE__: JSON.stringify("http://127.0.0.1:5555/vibe-ads/lt"),
+    __VIBE_ADS_DEBUG__: "false",
+    __VIBE_ADS_CLICKURL__: JSON.stringify("https://acme.example/lp"),
+    __VIBE_ADS_BANNER_ON__: "true",
+    __VIBE_ADS_CORR__: JSON.stringify("ad1.test"),
+    __VIBE_ADS_VIEW_THRESHOLD_MS__: "15000",
+  };
+  for (const [k, v] of Object.entries(subs)) src = src.split(k).join(v);
+  const mod = { exports: {} as Record<string, unknown> };
+  vm.runInNewContext(src, { module: mod, exports: mod.exports });
+  return mod.exports;
+}
+
+describe("block.asset icon rendering (ICON_URL feature)", () => {
+  it("no icon_url: buildAdHtml tier 3 renders the fallback K SVG badge", () => {
+    const b = load("");
+    const h = (b.buildAdHtml as Function)(3, {
+      ad: "Acme", dots: "", elapsed: "1.2s",
+    });
+    expect(h).toContain("<svg");
+    expect(h).toContain("fill=\"#188a45\"");
+    expect(h).toContain(">K</text>");
+    expect(h).not.toContain("<img");
+  });
+
+  it("with icon_url: buildAdHtml tier 3 renders an <img> tag", () => {
+    const url = "https://storage.googleapis.com/bucket/ad-icons/ad-1.png";
+    const b = load(url);
+    const h = (b.buildAdHtml as Function)(3, {
+      ad: "Acme", dots: "", elapsed: "1.2s",
+    });
+    expect(h).toContain("<img");
+    expect(h).toContain(`src="${url}"`);
+    expect(h).toContain('width="13"');
+    expect(h).toContain('height="13"');
+    expect(h).toContain("object-fit:contain");
+    expect(h).not.toContain("<svg");
+    // data-va-icon marks the img for the capture-phase error→K-badge fallback.
+    expect(h).toContain('data-va-icon="1"');
+    // Inline onerror= would be CSP-blocked by CC's script-src; must not appear.
+    expect(h).not.toContain("onerror");
+  });
+
+  it("data: URI icon (the CSP-safe path) renders an <img> with that src", () => {
+    const url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
+    const b = load(url);
+    const h = (b.buildAdHtml as Function)(3, {
+      ad: "Acme", dots: "", elapsed: "1.2s",
+    });
+    expect(h).toContain("<img");
+    expect(h).toContain(`src="${url}"`);
+    expect(h).toContain('data-va-icon="1"');
+    expect(h).not.toContain("<svg");
+  });
+
+  it("icon_url with special chars is HTML-escaped in img src", () => {
+    const url = "https://storage.googleapis.com/bucket/ad-icons/test&icon.png";
+    const b = load(url);
+    const h = (b.buildAdHtml as Function)(3, {
+      ad: "Acme", dots: "", elapsed: "1.2s",
+    });
+    expect(h).toContain("test&amp;icon.png");
+    expect(h).not.toContain("test&icon.png");
+  });
+
+  it("tier 1 (bare anchor) — icon_url does NOT render <img>", () => {
+    const url = "https://storage.googleapis.com/bucket/ad-icons/ad-1.png";
+    const b = load(url);
+    const h = (b.buildAdHtml as Function)(1, { ad: "Acme", dots: "" });
+    expect(h).not.toContain("<img");
+    expect(h).not.toContain("<svg");
+  });
+
+  it("buildBannerHtml renders icon when icon_url present", () => {
+    const url = "https://storage.googleapis.com/bucket/ad-icons/banner.png";
+    const b = load(url);
+    const h = (b.buildBannerHtml as Function)(
+      "Acme banner text", "https://acme.example/lp");
+    expect(h).toContain("<img");
+    expect(h).toContain(`src="${url}"`);
+    expect(h).toContain('data-vibe-ads-ad="1"');
+  });
+
+  it("buildBannerHtml falls back to V badge when no icon_url", () => {
+    const b = load("");
+    const h = (b.buildBannerHtml as Function)(
+      "Acme banner text", "https://acme.example/lp");
+    expect(h).toContain('data-vibe-ads-ad="1"');
+    // Banner may or may not include the favicon — depends on implementation.
+    // The key invariant: no <img> tag when no icon_url.
+    expect(h).not.toContain("<img");
+  });
+});
diff --git a/test/incident-guards.test.ts b/test/incident-guards.test.ts
new file mode 100644
index 0000000..88c7907
--- /dev/null
+++ b/test/incident-guards.test.ts
@@ -0,0 +1,92 @@
+/** W7 incident-class regressions.
+ *
+ *  Each test in this file encodes ONE past incident as a static check so
+ *  the failure cannot return undetected.  Background: see memory entries
+ *  for the spinner-icon widening, MutationObserver crash ("the CC crash"),
+ *  and the version-baseline non-monotonic deploy bug. */
+
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve, join } from "node:path";
+
+const ROOT = resolve(__dirname, "..");
+const BLOCK_PATH = join(ROOT, "src", "adapters", "claude-code", "block.asset.js");
+const ADAPTER_PATH = join(ROOT, "src", "adapters", "claude-code", "adapter.ts");
+const PKG_PATH = join(ROOT, "package.json");
+
+const block = readFileSync(BLOCK_PATH, "utf8");
+const adapter = readFileSync(ADAPTER_PATH, "utf8");
+
+describe("W7: spinner detection stays class-scoped (prime-directive)", () => {
+  // The original incident: widening the glyph/icon set to include `·` or
+  // `*` made the detector match Monaco editor + markdown content and the
+  // injection clobbered the user's editor. Detection must stay scoped via
+  // CC's `spinnerRow_` class prefix.
+  it("findSpinner queries by [class*=\"spinnerRow_\"] only", () => {
+    expect(block).toMatch(/querySelectorAll\(['"]\[class\*="spinnerRow_"\]['"]\)/);
+  });
+
+  it("rowActive() recognises only the documented sparkle glyphs (no ·, no *)", () => {
+    // The four sparkle code points CC uses: ✢ U+2722, ✶ U+2736, ✻ U+273B, ✽ U+273D.
+    expect(block).toMatch(/0x2722/);
+    expect(block).toMatch(/0x2736/);
+    expect(block).toMatch(/0x273b/);
+    expect(block).toMatch(/0x273d/);
+    // Common widening candidates that previously caused clobber regressions.
+    // The string `·` (U+00B7) and `*` should never appear in a charCode
+    // comparison branch. We allow them in comments/strings elsewhere; this
+    // guard fires on the rowActive code shape specifically.
+    const rowActiveBody = /function\s+rowActive[\s\S]+?return\s+c\s*===[\s\S]+?\}/m
+      .exec(block)?.[0] ?? "";
+    expect(rowActiveBody, "rowActive must stay glyph-narrow")
+      .not.toMatch(/0x00b7/i);
+    expect(rowActiveBody).not.toMatch(/0x002a/i);
+  });
+});
+
+describe("W7: no whole-document MutationObserver (the CC crash)", () => {
+  // Memory: a `{ childList:true, subtree:true }` observer attached at body
+  // root and firing evaluate() per token caused main-thread saturation and
+  // VS Code terminated the webview. The 80ms interval + rAF + watchdog
+  // detects within 80ms with NO unbounded cost — do not reintroduce.
+  it("does not call MutationObserver.observe with subtree:true on document/body", () => {
+    // Look for any MutationObserver wiring; the block asset should have NONE
+    // (the architectural decision is "polling-only").
+    expect(block).not.toMatch(/new\s+MutationObserver/);
+    // Belt-and-suspenders: even if a future change adds one, document or
+    // document.body observation with subtree:true is the specific failure
+    // mode and is banned.
+    expect(block).not.toMatch(/\.observe\s*\(\s*document(\.body)?[^)]*subtree\s*:\s*true/);
+  });
+});
+
+describe("W7: extension version monotonic baseline", () => {
+  // Memory: a frozen committed version meant the deploy published a manifest
+  // ≤ running, and self-update silently no-op'd. This guard catches a regress
+  // by checking the committed semver is at least the documented baseline.
+  it("package.json version >= 0.3.32 (W1 rename minimum)", () => {
+    const pkg = JSON.parse(readFileSync(PKG_PATH, "utf8")) as { version: string };
+    const [maj, min, pat] = pkg.version.split(".").map((x) => parseInt(x, 10));
+    expect(Number.isFinite(maj) && Number.isFinite(min) && Number.isFinite(pat))
+      .toBe(true);
+    const ord = maj * 1_000_000 + min * 1_000 + pat;
+    const baseline = 0 * 1_000_000 + 3 * 1_000 + 32;
+    expect(ord, `version ${pkg.version} below the 0.3.32 baseline`).toBeGreaterThanOrEqual(baseline);
+  });
+});
+
+describe("W7: legacy backup suffixes still recognised (W1 + S3 backwards-compat)", () => {
+  // The rename introduces .kickbacks-backup but pre-rename users have a
+  // .vibe-ads-backup file (Vibe-Ads era) or the older .vibads-backup file
+  // (pre-S3 era) on disk. The adapter must still recognise EVERY legacy
+  // suffix or "Restore Claude Code" silently no-ops for those users.
+  it("adapter source references .kickbacks-backup (current)", () => {
+    expect(adapter).toMatch(/\.kickbacks-backup/);
+  });
+  it("adapter source references .vibe-ads-backup (Vibe-Ads era legacy)", () => {
+    expect(adapter).toMatch(/\.vibe-ads-backup/);
+  });
+  it("adapter source references .vibads-backup (pre-S3 era legacy)", () => {
+    expect(adapter).toMatch(/\.vibads-backup/);
+  });
+});
diff --git a/test/incompatNotice.test.ts b/test/incompatNotice.test.ts
new file mode 100644
index 0000000..4180a67
--- /dev/null
+++ b/test/incompatNotice.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// Mute dlog so the helper doesn't append to the developer's real
+// ~/.vibe-ads/debug.log during tests (same reason extension.test.ts mocks it).
+vi.mock("../src/log", () => ({ dlog: () => {} }));
+
+import { notifyIncompatible } from "../src/activation/incompatNotice";
+import { makeContext, _warned } from "./mocks/vscode";
+
+const adapter = (patched: boolean) => ({ isPatched: () => patched }) as never;
+const pf = (reason: string | undefined, version = "2.1.161") =>
+  ({ ok: true, compatible: false, version, reason }) as never;
+const GENUINE = "verb array not found (incompatible build)";
+
+describe("notifyIncompatible", () => {
+  beforeEach(() => { _warned.length = 0; });
+
+  it("warns exactly once per version on a genuine verb-array miss", () => {
+    const ctx = makeContext() as never;
+    notifyIncompatible(ctx, adapter(false), pf(GENUINE));
+    notifyIncompatible(ctx, adapter(false), pf(GENUINE)); // reload — deduped
+    expect(_warned.length).toBe(1);
+    expect(_warned[0]).toMatch(/2\.1\.161/);
+    expect(_warned[0]).toContain("spinner hook");
+    expect(_warned[0]).not.toContain("doesn't support");
+  });
+
+  it("does NOT warn when the live file is already patched (transient flash)", () => {
+    notifyIncompatible(makeContext() as never, adapter(true), pf(GENUINE));
+    expect(_warned.length).toBe(0);
+  });
+
+  it("does NOT warn for 'target not found' (Claude Code absent)", () => {
+    notifyIncompatible(makeContext() as never, adapter(false), pf("target not found"));
+    expect(_warned.length).toBe(0);
+  });
+
+  it("does NOT warn when preflight gave no reason (e.g. thrown-error path)", () => {
+    notifyIncompatible(makeContext() as never, adapter(false), pf(undefined));
+    expect(_warned.length).toBe(0);
+  });
+
+  it("still warns when the adapter omits the optional isPatched()", () => {
+    // isPatched is optional on TargetAdapter; an adapter without it must not
+    // suppress a genuine-miss warning.
+    notifyIncompatible(makeContext() as never, ({} as never), pf(GENUINE));
+    expect(_warned.length).toBe(1);
+  });
+
+  it("re-warns once for a different (newly broken) version", () => {
+    const ctx = makeContext() as never;
+    notifyIncompatible(ctx, adapter(false), pf(GENUINE, "2.1.161"));
+    notifyIncompatible(ctx, adapter(false), pf(GENUINE, "2.1.180"));
+    expect(_warned.length).toBe(2);
+  });
+});
diff --git a/test/insidersLocate.test.ts b/test/insidersLocate.test.ts
new file mode 100644
index 0000000..49ef09b
--- /dev/null
+++ b/test/insidersLocate.test.ts
@@ -0,0 +1,74 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// Regression for the public-mirror bug report (kickbacks.ai PR #3 / issue #2):
+// Claude Code installed under local VS Code Insiders (~/.vscode-insiders)
+// was invisible to target discovery — activation logged target:false and
+// early-returned before wiring sign-in. Both scanners (locate.ts and
+// adapters/registry.ts) must include the .vscode-insiders root.
+//
+// homedir() is mocked at a per-test temp root (same pattern as
+// locateLog.test.ts). registry.ts computes ROOTS at module load, so both
+// modules are imported dynamically after the fake home exists.
+const h = vi.hoisted(() => ({ home: "" }));
+vi.mock("node:os", async (importOriginal) => {
+  const os = await importOriginal<typeof import("node:os")>();
+  return { ...os, homedir: () => h.home };
+});
+
+function freshHome(): string {
+  h.home = mkdtempSync(join(tmpdir(), "vibe-ads-insiders-"));
+  return h.home;
+}
+function installClaudeCode(root: string, ver = "2.1.170"): string {
+  const webview = join(h.home, root, "extensions",
+    `anthropic.claude-code-${ver}`, "webview");
+  mkdirSync(webview, { recursive: true });
+  const idx = join(webview, "index.js");
+  writeFileSync(idx, "// fixture", "utf8");
+  return idx;
+}
+function installCodex(root: string, ver = "0.5.10"): string {
+  const assets = join(h.home, root, "extensions",
+    `openai.chatgpt-${ver}`, "webview", "assets");
+  mkdirSync(assets, { recursive: true });
+  const chunk = join(assets, "thinking-shimmer-abc123.js");
+  writeFileSync(chunk, "// fixture", "utf8");
+  return chunk;
+}
+
+beforeEach(() => {
+  vi.resetModules();
+  delete process.env.KICKBACKS_CC_TARGET;
+  delete process.env.VIBE_ADS_CC_TARGET;
+  delete process.env.KICKBACKS_CODEX_TARGET;
+  delete process.env.VIBE_ADS_CODEX_TARGET;
+  freshHome();
+});
+
+describe("VS Code Insiders target discovery", () => {
+  it("locateClaudeCode finds an install under ~/.vscode-insiders", async () => {
+    const idx = installClaudeCode(".vscode-insiders");
+    const { locateClaudeCode } = await import("../src/locate");
+    expect(locateClaudeCode()).toBe(idx);
+  });
+
+  it("registry discovers claude-code and codex under ~/.vscode-insiders",
+      async () => {
+    installClaudeCode(".vscode-insiders");
+    installCodex(".vscode-insiders");
+    const { discover } = await import("../src/adapters/registry");
+    const ids = discover().map((e) => e.id);
+    expect(ids).toContain("claude-code");
+    expect(ids).toContain("codex");
+  });
+
+  it("stable ~/.vscode still wins when both hosts have an install", async () => {
+    const stable = installClaudeCode(".vscode");
+    installClaudeCode(".vscode-insiders");
+    const { locateClaudeCode } = await import("../src/locate");
+    expect(locateClaudeCode()).toBe(stable);
+  });
+});
diff --git a/test/killEnforcement.test.ts b/test/killEnforcement.test.ts
new file mode 100644
index 0000000..60a005b
--- /dev/null
+++ b/test/killEnforcement.test.ts
@@ -0,0 +1,260 @@
+// Kill-switch enforcement: when the backend or the test override says
+// killed=true, activate() must restore the CC + Codex targets and skip the
+// loopback / patched-webview branch entirely. The test hooks (which talk
+// directly to MetricsClient) intentionally remain callable so an operator
+// can still drive an isolated event for diagnostics, but production-path
+// telemetry is silent.
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { rmSync } from "node:fs";
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { makeContext, secrets, _opened, _shown, _openedDocs, commands }
+  from "./mocks/vscode";
+
+const mkAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+// Guarantee the clean-boot canary branch (a recent canary from a parallel
+// test worker would otherwise flip the run into the crash-suspension path).
+const clearBootCanary = (): void => {
+  try { rmSync(join(homedir(), ".vibe-ads", "boot.canary"), { force: true }); }
+  catch { /* best-effort */ }
+};
+
+function stubFetch(opts: { killedFromBackend?: boolean;
+                           killswitchDown?: boolean } = {}) {
+  const calls: { url: string; method: string;
+                 body?: unknown; headers: Record<string, string> }[] = [];
+  const f = vi.fn(async (input: unknown, init?: { method?: string;
+      body?: string; headers?: Record<string, string> }) => {
+    const url = typeof input === "string" ? input : String(input);
+    const method = (init?.method || "GET").toUpperCase();
+    const body = init?.body ? (() => {
+      try { return JSON.parse(init.body!); } catch { return init.body; } })() : undefined;
+    calls.push({ url, method, body, headers: init?.headers || {} });
+    if (url.includes("/v1/killswitch")) {
+      if (opts.killswitchDown) throw new Error("network down");
+      return { ok: true, status: 200, json: async () =>
+        ({ killed: !!opts.killedFromBackend }) } as Response;
+    }
+    if (url.includes("/v1/portfolio")) {
+      return { ok: true, status: 200, json: async () => ({
+        ttl_seconds: 30, view_threshold_seconds: 15,
+        ads: [{ ad_id: "ad-kill", campaign_id: "camp-kill",
+          title_text: "x", icon_ref: "i", click_url: "https://x" }],
+      }) } as Response;
+    }
+    return { ok: true, status: 204, json: async () => ({}) } as Response;
+  });
+  vi.stubGlobal("fetch", f);
+  return { f, calls };
+}
+
+beforeEach(() => {
+  secrets.clear();
+  commands._handlers.clear();
+  commands._executed.length = 0;
+  _opened.length = 0;
+  _shown.length = 0;
+  _openedDocs.length = 0;
+  __wireForTest({});
+});
+
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe("kill-switch enforcement (production-path silence + hook callability)",
+  () => {
+
+  it("killed=true at activation: adapter.restore() runs, status bar = killed,"
+    + " no loopback-driven /v1/metrics POSTs", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar, killed: true });
+    const fetched = stubFetch();
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-KILL");
+    try {
+      await activate(ctx as never);
+      // Allow checkKill's async branch to settle.
+      await new Promise((r) => setTimeout(r, 30));
+      expect(adapter.restore).toHaveBeenCalled();
+      expect(statusBar.set).toHaveBeenCalledWith(
+        expect.objectContaining({ kind: expect.stringMatching(/^(killed|offline)$/) }));
+      // No metric POSTs: the loopback branch never wires because killed=true.
+      const metricsPosts = fetched.calls.filter(
+        (c) => c.url.endsWith("/v1/metrics"));
+      expect(metricsPosts).toHaveLength(0);
+    } finally { await deactivate(); }
+  });
+
+  it("test hook fireImpressionRendered is STILL callable post-kill — the"
+    + " operator-facing diagnostic surface bypasses the kill-switch (and"
+    + " state.killed=true is reflected in the snapshot so a suite can guard"
+    + " against unintended live writes)", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar, killed: true });
+    const fetched = stubFetch();
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-KILL2");
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      // Hooks are registered iff testHooksEnabled() (the global setup mock
+      // returns true). getState must reflect killed=true.
+      const snap = await commands.executeCommand(
+        "kickbacks.test.getState") as
+        { killed: boolean; ad: { adId: string } | null };
+      expect(snap.killed).toBe(true);
+      // No ad was wired to the production loopback branch (it short-
+      // circuited on killed=true). But fireImpressionRendered with an
+      // explicit ad override should still send.
+      const r = await commands.executeCommand(
+        "kickbacks.test.fireImpressionRendered",
+        { adId: "ad-kill", campaignId: "camp-kill" }) as { ok: boolean };
+      expect(r.ok).toBe(true);
+      const metricsPosts = fetched.calls.filter(
+        (c) => c.url.endsWith("/v1/metrics"));
+      expect(metricsPosts).toHaveLength(1);
+      expect(metricsPosts[0].body).toMatchObject({
+        event_type: "impression_rendered", ad_id: "ad-kill",
+      });
+    } finally { await deactivate(); }
+  });
+
+  it("backend-driven kill (test override OFF, backend killed=true) calls"
+    + " /v1/killswitch, restores the adapter, and sets the status bar to"
+    + " killed/offline. NOTE: the production code intentionally still runs"
+    + " the first synchronous CLI-statusline sync in the same tick, so the"
+    + " runtime guard for telemetry is adapter.restore() (no webview =>"
+    + " no impressions) plus the 30s reassert timer flipping `killed`.",
+    async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });  // no test-override of killed
+    const fetched = stubFetch({ killedFromBackend: true });
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-KILL3");
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      expect(fetched.calls.some(
+        (c) => c.url.includes("/v1/killswitch"))).toBe(true);
+      expect(adapter.restore).toHaveBeenCalled();
+      expect(statusBar.set).toHaveBeenCalledWith(
+        expect.objectContaining({ kind: expect.stringMatching(/^(killed|offline)$/) }));
+    } finally { await deactivate(); }
+  });
+});
+
+// ───────────────────────────────────────────────────────────────────────────
+// Wave 2 — kill HYSTERESIS (audit #3/#6/#9/#19): a CONFIRMED kill (200 with
+// killed:true) restores and persists; an UNREACHABLE killswitch (offline-
+// unsure) FREEZES — no restore, no churn; recovery (200 killed:false) clears
+// both the live and the persisted state so writers resume.
+// ───────────────────────────────────────────────────────────────────────────
+describe("kill hysteresis (wave 2)", () => {
+
+  it("offline-unsure at activation: FREEZE — no restore, offline status bar"
+    + " (pre-fix the fail-safe restored on every wifi blip)", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });
+    stubFetch({ killswitchDown: true });
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-OFFLINE");
+    clearBootCanary();
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      // The hysteresis core: the OFFLINE verdict must NOT restore. The boot
+      // cycle (cyclePatch) legitimately runs one restore→re-apply pair
+      // BEFORE the kill check, so pin the ordering: the LAST adapter write
+      // must be an applyPatch — the file is left PATCHED (frozen as-is),
+      // never stripped by the fail-safe. Pre-fix, checkKill's offline
+      // restore was the final write.
+      const lastRestore =
+        Math.max(0, ...adapter.restore.mock.invocationCallOrder);
+      const lastApply =
+        Math.max(0, ...adapter.applyPatch.mock.invocationCallOrder);
+      expect(lastApply, "offline must freeze, not restore")
+        .toBeGreaterThan(lastRestore);
+      expect(statusBar.set).toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "offline" }));
+      // And it must NOT persist the boot-gating confirmed-kill flag.
+      expect(ctx.globalState.get("kickbacks.kill.confirmed")).toBeUndefined();
+    } finally { await deactivate(); }
+  });
+
+  it("a CONFIRMED kill persists across sessions: the NEXT boot never writes"
+    + " a patch before its first kill check (audit #19 boot order)", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });
+    stubFetch({ killedFromBackend: true });
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-PERSIST");
+    clearBootCanary();
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      expect(adapter.restore).toHaveBeenCalled();
+      // The confirmed kill is persisted for the next boot's gate.
+      expect(ctx.globalState.get("kickbacks.kill.confirmed")).toBe(true);
+    } finally { await deactivate(); }
+
+    // ── Second boot, SAME globalState (K_ON=true persisted by the first
+    // boot's auto-enable). Pre-fix, bootCanary's reapplyIfOn re-patched CC
+    // files BEFORE the first kill check on every boot of a killed install.
+    // (deactivate() nulls the test override — re-wire before re-activating,
+    // else the second boot runs against the REAL ClaudeCodeAdapter.)
+    __wireForTest({ adapter, statusBar });
+    adapter.applyPatch.mockClear();
+    adapter.restore.mockClear();
+    clearBootCanary();
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      expect(adapter.applyPatch,
+        "a persisted confirmed kill must gate every boot-path patch write")
+        .not.toHaveBeenCalled();
+    } finally { await deactivate(); }
+  });
+
+  it("RECOVERY: a 200 killed:false clears the persisted flag and writers"
+    + " resume on the next boot", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });
+    stubFetch({ killedFromBackend: true });
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-RECOVER");
+    clearBootCanary();
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      expect(ctx.globalState.get("kickbacks.kill.confirmed")).toBe(true);
+    } finally { await deactivate(); }
+
+    // Backend recovers: killswitch now answers killed:false.
+    // (deactivate() nulls the test override — re-wire before re-activating.)
+    __wireForTest({ adapter, statusBar });
+    stubFetch({ killedFromBackend: false });
+    adapter.applyPatch.mockClear();
+    clearBootCanary();
+    try {
+      await activate(ctx as never);
+      await new Promise((r) => setTimeout(r, 30));
+      // The persisted flag is consumed by the healthy verdict…
+      expect(ctx.globalState.get("kickbacks.kill.confirmed")).toBeUndefined();
+      // …and the production webview apply ran again (writes resumed).
+      expect(adapter.applyPatch).toHaveBeenCalled();
+    } finally { await deactivate(); }
+  });
+});
diff --git a/test/killswitch.test.ts b/test/killswitch.test.ts
new file mode 100644
index 0000000..eab9651
--- /dev/null
+++ b/test/killswitch.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, vi } from "vitest";
+import { KillSwitchClient } from "../src/killswitch/client";
+
+const ok = (b: unknown) => ({ ok: true, status: 200, json: async () => b }) as Response;
+
+describe("KillSwitchClient", () => {
+  it("checkOnce returns killed flag from the endpoint (CONFIRMED, offline=false)", async () => {
+    const c = new KillSwitchClient("http://b",
+      (async () => ok({ killed: true, scope: "global", reason: "stop" })) as never);
+    expect(await c.checkOnce("2.1.143", "c1")).toEqual(
+      { killed: true, confirmed: true, scope: "global", reason: "stop",
+        offline: false });
+  });
+  it("a 200 killed:false clears everything (recovery shape)", async () => {
+    const c = new KillSwitchClient("http://b",
+      (async () => ok({ killed: false })) as never);
+    const r = await c.checkOnce("2.1.143", "c1");
+    expect(r.killed).toBe(false);
+    expect(r.confirmed).toBe(false);
+    expect(r.offline).toBe(false);
+  });
+  it("fail-safe: on network error treat as KILLED + offline=true but NEVER"
+    + " confirmed (wave-2 kill hysteresis)", async () => {
+    const c = new KillSwitchClient("http://b",
+      (async () => { throw new Error("down"); }) as never);
+    const r = await c.checkOnce("2.1.143", "c1");
+    expect(r.killed).toBe(true);
+    expect(r.confirmed).toBe(false);
+    expect(r.offline).toBe(true);
+  });
+  it("fail-safe: on non-ok status treat as KILLED + offline=true but NEVER"
+    + " confirmed", async () => {
+    const c = new KillSwitchClient("http://b",
+      (async () => ({ ok: false, status: 502 }) as Response) as never);
+    const r = await c.checkOnce("2.1.143", "c1");
+    expect(r.killed).toBe(true);
+    expect(r.confirmed).toBe(false);
+    expect(r.offline).toBe(true);
+  });
+});
diff --git a/test/killswitchClient.test.ts b/test/killswitchClient.test.ts
new file mode 100644
index 0000000..eacada2
--- /dev/null
+++ b/test/killswitchClient.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect } from "vitest";
+
+import { KillSwitchClient } from "../src/killswitch/client";
+
+/** Fake fetch capturing the requested URL and returning a scripted response.
+ *  The client's fetch slot is constructor-injected, so no module mocking. */
+function fakeFetch(opts: {
+  status?: number;
+  body?: unknown;
+  jsonThrows?: boolean;
+  reject?: boolean;
+}) {
+  const seen: string[] = [];
+  const f = (async (url: string) => {
+    seen.push(url);
+    if (opts.reject) throw new TypeError("fetch failed: ECONNREFUSED");
+    const status = opts.status ?? 200;
+    return {
+      ok: status >= 200 && status < 300,
+      status,
+      json: async () => {
+        if (opts.jsonThrows) throw new SyntaxError("Unexpected token < in JSON");
+        return opts.body;
+      },
+    };
+  }) as never;
+  return { f, seen };
+}
+
+const client = (f: never) => new KillSwitchClient("https://api.test", f);
+
+describe("KillSwitchClient.checkOnce", () => {
+  it("200 killed:false → serving allowed, not confirmed, not offline", async () => {
+    const { f } = fakeFetch({ body: { killed: false } });
+    const s = await client(f).checkOnce("2.1.161", "c1");
+    expect(s).toMatchObject({ killed: false, confirmed: false, offline: false });
+  });
+
+  it("200 killed:true → killed AND confirmed (the restore-everything signal)", async () => {
+    const { f } = fakeFetch({
+      body: { killed: true, scope: "global", reason: "incident" } });
+    const s = await client(f).checkOnce("2.1.161", "c1");
+    expect(s).toMatchObject({
+      killed: true, confirmed: true, offline: false,
+      scope: "global", reason: "incident",
+    });
+  });
+
+  it("coerces a truthy non-boolean killed field (hostile/loose backend JSON)", async () => {
+    const { f } = fakeFetch({ body: { killed: 1 } });
+    const s = await client(f).checkOnce("2.1.161", "c1");
+    expect(s.killed).toBe(true);
+    expect(s.confirmed).toBe(true);
+  });
+
+  it("non-2xx → fail-safe killed but UNCONFIRMED + offline (freeze, never restore)", async () => {
+    const { f } = fakeFetch({ status: 503 });
+    const s = await client(f).checkOnce("2.1.161", "c1");
+    expect(s).toMatchObject({ killed: true, confirmed: false, offline: true });
+    expect(s.reason).toBe("status 503");
+  });
+
+  it("network error → fail-safe killed, unconfirmed, offline", async () => {
+    const { f } = fakeFetch({ reject: true });
+    const s = await client(f).checkOnce("2.1.161", "c1");
+    expect(s).toMatchObject({ killed: true, confirmed: false, offline: true });
+    expect(s.reason).toContain("fail-safe");
+  });
+
+  it("malformed JSON body → fail-safe killed, unconfirmed, offline", async () => {
+    // An HTML error page behind a 200 (proxy/captive portal) must not crash
+    // the poll loop nor read as "not killed".
+    const { f } = fakeFetch({ jsonThrows: true });
+    const s = await client(f).checkOnce("2.1.161", "c1");
+    expect(s).toMatchObject({ killed: true, confirmed: false, offline: true });
+  });
+
+  it("URL-encodes version and campaign query params", async () => {
+    const { f, seen } = fakeFetch({ body: { killed: false } });
+    await client(f).checkOnce("2.1.161 (Claude Code)", "camp&aign=x");
+    expect(seen[0]).toBe(
+      "https://api.test/v1/killswitch?version=2.1.161%20(Claude%20Code)"
+      + "&campaign=camp%26aign%3Dx");
+  });
+});
diff --git a/test/localVsixWatcher.test.ts b/test/localVsixWatcher.test.ts
new file mode 100644
index 0000000..b5ce497
--- /dev/null
+++ b/test/localVsixWatcher.test.ts
@@ -0,0 +1,205 @@
+// Local-VSIX update path: when ~/.vibe-ads/config.json sets localVsixPath,
+// the extension mtime-watches the file and installs it on change via the
+// SAME installer the manifest path uses. Hermetic: a temp HOME with a
+// config.json containing localVsixPath, a watchFileFn spy that captures the
+// listener, then synthetic stat events drive the install pipe.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync }
+  from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// Under cold-start worker-pool load (npx vitest run after the .vitest cache
+// was wiped) the `writeFileSync(config.json) → vi.resetModules() → await
+// import("../src/extension")` chain hit a rare Windows-FS visibility race:
+// the freshly-written config.json wasn't readable yet when the imported
+// module's top-level `const CFG = readConfig()` fired, so CFG.localVsixPath
+// was empty, no watchFile was registered, and `listeners.get(vsixPath)`
+// returned undefined → TypeError on the listener invocation in test 2.
+// This helper read-backs until the file is visible (cheap; capped at ~1 s)
+// before we hand control to the import chain. Reproduced 1× across ~5
+// runs, never in isolation; the read-back closes the window cleanly.
+function ensureReadable(path: string, timeoutMs = 1000): void {
+  const end = Date.now() + timeoutMs;
+  while (Date.now() < end) {
+    try { readFileSync(path); return; }
+    catch { /* not yet — try again next loop tick */ }
+  }
+  // Final attempt that THROWS if still unreadable, so the test fails
+  // loudly with a precise message instead of TypeErroring downstream.
+  readFileSync(path);
+}
+// NOTE: this file intentionally does NOT static-import the vscode mock —
+// every test calls vi.resetModules() before importing the extension, which
+// re-instantiates the aliased mock. Reading assertions through a stale
+// static import would target the wrong instance. Each test re-imports
+// "./mocks/vscode" inside the resetModules window via `vsc = await import(...)`.
+
+type StatListener = (curr: { mtimeMs: number }) => void;
+
+function stubFetch() {
+  const calls: string[] = [];
+  const f = vi.fn(async (input: unknown) => {
+    const url = typeof input === "string" ? input : String(input);
+    calls.push(url);
+    return { ok: true, status: 200, json: async () => ({}) } as Response;
+  });
+  vi.stubGlobal("fetch", f);
+  return { f, calls };
+}
+
+const mkAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+afterEach(() => { vi.unstubAllGlobals(); vi.resetModules(); });
+
+describe("local VSIX watcher", () => {
+
+  it("installs the file and restarts the ext host when its mtime advances"
+    + " (the local dev rig path — no manifest server required)", async () => {
+    // Stage HOME so readConfig() picks up localVsixPath at module load.
+    const home = mkdtempSync(join(tmpdir(), "kb-lvw-"));
+    const prevHome = process.env.HOME;
+    const prevUser = process.env.USERPROFILE;
+    process.env.HOME = home;
+    process.env.USERPROFILE = home;
+    mkdirSync(join(home, ".vibe-ads"), { recursive: true });
+    const vsixPath = join(home, "kickbacks.vsix");
+    writeFileSync(vsixPath, Buffer.from("FAKE-VSIX-BYTES-v1"));
+    const configFile = join(home, ".vibe-ads", "config.json");
+    writeFileSync(configFile,
+      JSON.stringify({ localVsixPath: vsixPath }), "utf8");
+    ensureReadable(configFile);          // close the cold-start FS race
+
+    // Capture the listener handed to watchFile(localVsixPath, …).
+    const listeners = new Map<string, StatListener>();
+    const watchFileFn = ((p: unknown, _o: unknown, l: StatListener) => {
+      listeners.set(String(p), l);
+    }) as unknown as typeof import("node:fs").watchFile;
+
+    // Re-import the extension after staging HOME so CFG is read fresh.
+    // CRITICAL: vi.resetModules() also re-instantiates the aliased `vscode`
+    // mock for the freshly imported extension. The vscode `commands` array
+    // captured at this file's static `import` line is now a STALE instance —
+    // the new extension writes into a different Map. So we re-import the
+    // mock through the same resetModules window and read assertions from
+    // *that* instance. (test/setup.ts's vi.mock for ../src/log survives.)
+    vi.resetModules();
+    const { activate, deactivate, __wireForTest } =
+      await import("../src/extension");
+    const vsc = await import("./mocks/vscode");
+
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar, watchFileFn });
+    stubFetch();
+    const ctx = vsc.makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT");
+
+    try {
+      await activate(ctx as never);
+
+      // The listener for the staged vsix path must be installed.
+      expect(listeners.has(vsixPath)).toBe(true);
+      // Pre-fire baseline: no install attempted yet.
+      expect(vsc.commands._executed.some(
+        (c) => c.id === "workbench.extensions.installExtension")).toBe(false);
+
+      // Bump the file bytes + invoke the listener with a fresh mtime.
+      writeFileSync(vsixPath, Buffer.from("FAKE-VSIX-BYTES-v2"));
+      const fire = listeners.get(vsixPath)!;
+      fire({ mtimeMs: Date.now() + 5000 });
+      // The listener uses void installVsix(...); installVsix itself is async
+      // (writes a temp file, awaits the install command, awaits the restart).
+      // Yield ticks so its awaits land before we assert.
+      await new Promise((r) => setTimeout(r, 50));
+
+      const installCall = vsc.commands._executed.find(
+        (c) => c.id === "workbench.extensions.installExtension");
+      expect(installCall).toBeDefined();
+      const arg = installCall!.args[0] as { toString(): string; fsPath?: string };
+      const fsPath = arg.fsPath || arg.toString();
+      expect(fsPath).toMatch(/\.vsix$/);
+
+      // After install we no longer auto-restart the ext host — we
+      // prompt for a full window reload via showInformationMessage
+      // (empirically the ext-host restart wasn't enough to invalidate
+      // the cached webview module of the patched CC page; only a
+      // window reload reliably picks up the new build). Assert the
+      // toast fired, not a silent restart. The mock collects all
+      // showInformationMessage calls into `_shown` (see mocks/vscode.ts).
+      // The toast is fired via a void IIFE so we wait one more tick
+      // for its await chain to land before checking.
+      await new Promise((r) => setTimeout(r, 50));
+      expect((vsc._shown as { kind: string; text: string }[])
+        .some((t) => /Kickbacks updated/i.test(t.text))).toBe(true);
+      expect(vsc.commands._executed.some(
+        (c) => c.id === "workbench.action.restartExtensionHost")).toBe(false);
+    } finally {
+      await deactivate();
+      if (prevHome !== undefined) process.env.HOME = prevHome;
+      else delete process.env.HOME;
+      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+      else delete process.env.USERPROFILE;
+      try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
+    }
+  });
+
+  it("a stale mtime is ignored (no install storm on activation)", async () => {
+    const home = mkdtempSync(join(tmpdir(), "kb-lvw2-"));
+    const prevHome = process.env.HOME;
+    const prevUser = process.env.USERPROFILE;
+    process.env.HOME = home;
+    process.env.USERPROFILE = home;
+    mkdirSync(join(home, ".vibe-ads"), { recursive: true });
+    const vsixPath = join(home, "kickbacks.vsix");
+    writeFileSync(vsixPath, Buffer.from("v1"));
+    const configFile2 = join(home, ".vibe-ads", "config.json");
+    writeFileSync(configFile2,
+      JSON.stringify({ localVsixPath: vsixPath }), "utf8");
+    ensureReadable(configFile2);
+
+    const listeners = new Map<string, StatListener>();
+    const watchFileFn = ((p: unknown, _o: unknown, l: StatListener) => {
+      listeners.set(String(p), l);
+    }) as unknown as typeof import("node:fs").watchFile;
+
+    vi.resetModules();
+    const { activate, deactivate, __wireForTest } =
+      await import("../src/extension");
+    const vsc = await import("./mocks/vscode");
+
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar, watchFileFn });
+    stubFetch();
+    const ctx = vsc.makeContext();
+    try {
+      await activate(ctx as never);
+      // Surface the cold-start FS race here too: if config.json wasn't
+      // visible at module-load, CFG.localVsixPath was empty and watchFile
+      // was never registered → listeners.get(vsixPath) returns undefined
+      // and invoking it crashes with a confusing TypeError. Assert
+      // presence FIRST so the failure message points at the actual cause.
+      expect(listeners.has(vsixPath)).toBe(true);
+      const fire = listeners.get(vsixPath)!;
+      // mtime === 0 → must NOT trigger an install (the !curr.mtimeMs guard).
+      fire({ mtimeMs: 0 });
+      await new Promise((r) => setTimeout(r, 50));
+      expect(vsc.commands._executed.some(
+        (c) => c.id === "workbench.extensions.installExtension")).toBe(false);
+    } finally {
+      await deactivate();
+      if (prevHome !== undefined) process.env.HOME = prevHome;
+      else delete process.env.HOME;
+      if (prevUser !== undefined) process.env.USERPROFILE = prevUser;
+      else delete process.env.USERPROFILE;
+      try { rmSync(home, { recursive: true, force: true }); } catch { /* best-effort */ }
+    }
+  });
+});
diff --git a/test/locateLog.test.ts b/test/locateLog.test.ts
new file mode 100644
index 0000000..ee79250
--- /dev/null
+++ b/test/locateLog.test.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync, utimesSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// locate.ts reads join(homedir(), ".claude", "projects") — point homedir at a
+// per-test temp root (everything else on node:os stays real).
+const h = vi.hoisted(() => ({ home: "" }));
+vi.mock("node:os", async (importOriginal) => {
+  const os = await importOriginal<typeof import("node:os")>();
+  return { ...os, homedir: () => h.home };
+});
+
+import { locateClaudeCodeLog, locateClaudeCliLog,
+         transcriptEntrypoint } from "../src/locate";
+
+function freshHome(): string {
+  h.home = mkdtempSync(join(tmpdir(), "vibe-ads-locate-"));
+  return h.home;
+}
+function projDir(home: string, name = "p1"): string {
+  const d = join(home, ".claude", "projects", name);
+  mkdirSync(d, { recursive: true });
+  return d;
+}
+// One transcript record per call; `entrypoint` omitted when undefined
+// (queue-operation lines / older CC builds carry no tag).
+const rec = (entrypoint?: string, extra: object = {}): string =>
+  JSON.stringify({ type: "user",
+    message: { role: "user", content: "hi" },
+    ...(entrypoint ? { entrypoint } : {}), ...extra }) + "\n";
+function backdate(f: string, ms: number): void {
+  const t = (Date.now() - ms) / 1000;
+  utimesSync(f, t, t);
+}
+
+beforeEach(() => {
+  delete process.env.KICKBACKS_CC_LOG;
+  delete process.env.VIBE_ADS_CC_LOG;
+  delete process.env.KICKBACKS_CLI_LOG;
+  freshHome();
+});
+
+describe("transcriptEntrypoint", () => {
+  it('reads the first tagged record, skipping untagged head lines', () => {
+    const d = projDir(h.home);
+    const f = join(d, "s.jsonl");
+    writeFileSync(f,
+      JSON.stringify({ type: "queue-operation", operation: "enqueue" }) + "\n"
+      + rec("claude-vscode"), "utf8");
+    expect(transcriptEntrypoint(f)).toBe("claude-vscode");
+  });
+
+  it("null for an untagged transcript (older CC builds)", () => {
+    const d = projDir(h.home);
+    const f = join(d, "s.jsonl");
+    writeFileSync(f, rec(undefined), "utf8");
+    expect(transcriptEntrypoint(f)).toBeNull();
+  });
+
+  it("null for a missing file (never throws)", () => {
+    expect(transcriptEntrypoint(join(h.home, "nope.jsonl"))).toBeNull();
+  });
+
+  it("is not spoofed by message TEXT that quotes the tag", () => {
+    const d = projDir(h.home);
+    const f = join(d, "s.jsonl");
+    writeFileSync(f, JSON.stringify({ type: "user",
+      message: { role: "user", content: 'see "entrypoint":"cli" in docs' },
+    }) + "\n", "utf8");
+    expect(transcriptEntrypoint(f)).toBeNull();
+  });
+});
+
+// Audit #24: the documented 'prefer entrypoint:"claude-vscode"' filter — a
+// terminal-CLI transcript must not masquerade as VS Code panel activity.
+describe("locateClaudeCodeLog entrypoint filter", () => {
+  it("prefers the newest claude-vscode transcript over a NEWER terminal-CLI one", () => {
+    const d = projDir(h.home);
+    const vscode = join(d, "vscode.jsonl");
+    const cli = join(d, "cli.jsonl");
+    writeFileSync(vscode, rec("claude-vscode"), "utf8");
+    writeFileSync(cli, rec("cli"), "utf8");
+    backdate(vscode, 60_000);                  // CLI session moved last
+    expect(locateClaudeCodeLog()).toBe(vscode);
+  });
+
+  it("falls back to the newest UNTAGGED transcript (older CC builds)", () => {
+    const d = projDir(h.home);
+    const older = join(d, "older.jsonl");
+    const newer = join(d, "newer.jsonl");
+    writeFileSync(older, rec(undefined), "utf8");
+    writeFileSync(newer, rec(undefined), "utf8");
+    backdate(older, 60_000);
+    expect(locateClaudeCodeLog()).toBe(newer);
+  });
+
+  it('returns "" (no signal) when every candidate is a terminal-CLI session', () => {
+    const d = projDir(h.home);
+    writeFileSync(join(d, "a.jsonl"), rec("cli"), "utf8");
+    writeFileSync(join(d, "b.jsonl"), rec("cli"), "utf8");
+    expect(locateClaudeCodeLog()).toBe("");
+  });
+
+  it('returns "" when no transcripts exist at all', () => {
+    expect(locateClaudeCodeLog()).toBe("");
+  });
+});
+
+// The terminal-side mirror (the Steven fix): the statusbar/statusline billing
+// signal for TUI-only users, where locateClaudeCodeLog correctly returns "".
+describe("locateClaudeCliLog", () => {
+  it("returns the newest cli-tagged transcript", () => {
+    const d = projDir(h.home);
+    const older = join(d, "older.jsonl");
+    const newer = join(d, "newer.jsonl");
+    writeFileSync(older, rec("cli"), "utf8");
+    writeFileSync(newer, rec("cli"), "utf8");
+    backdate(older, 60_000);
+    expect(locateClaudeCliLog()).toBe(newer);
+  });
+
+  it("skips claude-vscode transcripts even when they are newer", () => {
+    const d = projDir(h.home);
+    const vscode = join(d, "vscode.jsonl");
+    const cli = join(d, "cli.jsonl");
+    writeFileSync(cli, rec("cli"), "utf8");
+    writeFileSync(vscode, rec("claude-vscode"), "utf8");
+    backdate(cli, 60_000);                     // panel session moved last
+    expect(locateClaudeCliLog()).toBe(cli);
+  });
+
+  // 2026-06-11 launch-day cohort fix: untagged transcripts (older CC builds)
+  // are now claimed as a FALLBACK — pre-fix the strict tag filter left TUI
+  // users on untagged builds with impressions-but-zero-ticks (26 users at $0
+  // while cliSessionActive counted their sessions fail-open).
+  it("falls back to the newest UNTAGGED transcript when no cli tag exists", () => {
+    const d = projDir(h.home);
+    const older = join(d, "older.jsonl");
+    const newer = join(d, "newer.jsonl");
+    writeFileSync(older, rec(undefined), "utf8");
+    writeFileSync(newer, rec(undefined), "utf8");
+    backdate(older, 60_000);
+    expect(locateClaudeCliLog()).toBe(newer);
+  });
+
+  it("prefers a cli-tagged transcript over a NEWER untagged one", () => {
+    const d = projDir(h.home);
+    const cli = join(d, "cli.jsonl");
+    const untagged = join(d, "untagged.jsonl");
+    writeFileSync(cli, rec("cli"), "utf8");
+    writeFileSync(untagged, rec(undefined), "utf8");
+    backdate(cli, 60_000);                     // untagged moved last
+    expect(locateClaudeCliLog()).toBe(cli);
+  });
+
+  it('returns "" when only panel transcripts exist', () => {
+    const d = projDir(h.home);
+    writeFileSync(join(d, "a.jsonl"), rec("claude-vscode"), "utf8");
+    expect(locateClaudeCliLog()).toBe("");
+  });
+
+  it("never claims OTHER positively-tagged transcripts (sdk/desktop sessions render no statusline)", () => {
+    const d = projDir(h.home);
+    writeFileSync(join(d, "sdk.jsonl"), rec("sdk-ts"), "utf8");
+    writeFileSync(join(d, "desktop.jsonl"), rec("desktop"), "utf8");
+    expect(locateClaudeCliLog()).toBe("");
+  });
+
+  it("honours the KICKBACKS_CLI_LOG override", () => {
+    const d = projDir(h.home);
+    const f = join(d, "explicit.jsonl");
+    writeFileSync(f, rec("cli"), "utf8");
+    process.env.KICKBACKS_CLI_LOG = f;
+    expect(locateClaudeCliLog()).toBe(f);
+  });
+});
diff --git a/test/log.test.ts b/test/log.test.ts
new file mode 100644
index 0000000..9f9e9de
--- /dev/null
+++ b/test/log.test.ts
@@ -0,0 +1,168 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
+import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+// This file tests the REAL log module. The global setupFile mocks it
+// process-wide (to keep every other test from polluting the dev machine's
+// ~/.vibe-ads/debug.log); we opt out here so the assertions exercise the
+// actual dlog/debugEnabled/codexEnabled implementations.
+vi.unmock("../src/log");
+
+// DIR/LOG/SENTINEL in log.ts are module-load-time constants computed from
+// homedir(). We must reset modules AND set HOME/USERPROFILE BEFORE each
+// dynamic import so the constants are captured against the temp dir.
+
+const REAL_HOME = process.env.HOME;
+const REAL_UP = process.env.USERPROFILE;
+let dir: string;
+
+beforeEach(() => {
+  dir = mkdtempSync(join(tmpdir(), "vibe-log-"));
+  process.env.HOME = dir;
+  process.env.USERPROFILE = dir;
+  delete process.env.VIBE_ADS_DEBUG;
+  vi.resetModules();
+});
+
+afterEach(() => {
+  if (REAL_HOME !== undefined) process.env.HOME = REAL_HOME;
+  else delete process.env.HOME;
+  if (REAL_UP !== undefined) process.env.USERPROFILE = REAL_UP;
+  else delete process.env.USERPROFILE;
+  rmSync(dir, { recursive: true, force: true });
+});
+
+describe("log.ts", () => {
+  it("off by default: no file, no throw", async () => {
+    const { dlog } = await import("../src/log");
+    dlog("ext", "evt", { a: 1 });
+    expect(existsSync(join(dir, ".vibe-ads", "debug.log"))).toBe(false);
+  });
+
+  it("sentinel on: writes a line carrying level + corr", async () => {
+    const vd = join(dir, ".vibe-ads");
+    mkdirSync(vd, { recursive: true });
+    writeFileSync(join(vd, "debug.enabled"), "");
+    const { dlog } = await import("../src/log");
+    dlog("ext", "evt", { a: 1 }, { level: "debug", corr: "ad7.zz" });
+    const txt = readFileSync(join(vd, "debug.log"), "utf8");
+    expect(txt).toContain(" debug ");
+    expect(txt).toContain("ad7.zz");
+    expect(txt).toContain("evt");
+  });
+
+  it("default level is info when opts omitted", async () => {
+    const vd = join(dir, ".vibe-ads");
+    mkdirSync(vd, { recursive: true });
+    writeFileSync(join(vd, "debug.enabled"), "");
+    const { dlog } = await import("../src/log");
+    dlog("ext", "evt2");
+    expect(readFileSync(join(vd, "debug.log"), "utf8")).toContain(" info ");
+  });
+
+  it("codexEnabled: OFF by default (prime-directive kill-switch)", async () => {
+    delete process.env.VIBE_ADS_CODEX;
+    const { codexEnabled } = await import("../src/log");
+    expect(codexEnabled()).toBe(false);
+  });
+
+  it("codexEnabled: env opt-in VIBE_ADS_CODEX=1", async () => {
+    process.env.VIBE_ADS_CODEX = "1";
+    const { codexEnabled } = await import("../src/log");
+    expect(codexEnabled()).toBe(true);
+    delete process.env.VIBE_ADS_CODEX;
+  });
+
+  it("codexEnabled: ~/.vibe-ads/codex.enabled sentinel opt-in", async () => {
+    const vd = join(dir, ".vibe-ads");
+    mkdirSync(vd, { recursive: true });
+    writeFileSync(join(vd, "codex.enabled"), "");
+    const { codexEnabled } = await import("../src/log");
+    expect(codexEnabled()).toBe(true);
+  });
+
+  it("rolling trim: caps log at MAX_LOG_LINES (oldest dropped)", async () => {
+    const vd = join(dir, ".vibe-ads");
+    mkdirSync(vd, { recursive: true });
+    writeFileSync(join(vd, "debug.enabled"), "");
+    const log = join(vd, "debug.log");
+    // Seed > MAX_LOG_LINES with marker lines (each big enough that the
+    // total file exceeds TRIM_MIN_BYTES = 64KB). 1500 × 100B = 150KB.
+    const filler = "x".repeat(80);
+    const seed = Array.from({ length: 1500 },
+      (_, i) => `2026-05-21T00:00:00.000Z [ext] info - seed-${i} ${filler}`)
+      .join("\n") + "\n";
+    writeFileSync(log, seed);
+    const { dlog, _forceTrimLogForTest, MAX_LOG_LINES } = await import("../src/log");
+    // One real append, then a manual trim trigger so the throttle doesn't
+    // hide the assertion. Production hits the trim every ~50 writes.
+    dlog("ext", "tail-marker", { ok: 1 });
+    _forceTrimLogForTest();
+    const out = readFileSync(log, "utf8");
+    const lineCount = out.split("\n").length - 1; // strip trailing-empty
+    expect(lineCount).toBeLessThanOrEqual(MAX_LOG_LINES);
+    // Oldest seed lines are gone; the freshest are still there.
+    expect(out).not.toContain("seed-0 ");
+    expect(out).not.toContain("seed-499 ");
+    expect(out).toContain("seed-1499 ");
+    expect(out).toContain("tail-marker");
+  });
+
+  it("lifecycle event writes even with the firehose OFF (debug disabled)", async () => {
+    // No sentinel, no env: the verbose firehose is off, but lifecycle
+    // events (auth / self-update / activation health) must still land so an
+    // incident is diagnosable from a stock install.
+    const { dlog } = await import("../src/log");
+    dlog("ext", "session.state", { signedIn: false });
+    dlog("ext", "selfupdate.installed", { path: "x" });
+    dlog("ext", "auth.refresh", { ok: false }); // future auth.* family
+    const log = join(dir, ".vibe-ads", "debug.log");
+    expect(existsSync(log)).toBe(true);
+    const txt = readFileSync(log, "utf8");
+    expect(txt).toContain("session.state");
+    expect(txt).toContain("selfupdate.installed");
+    expect(txt).toContain("auth.refresh");
+  });
+
+  it("firehose event stays gated when debug is OFF", async () => {
+    const { dlog } = await import("../src/log");
+    dlog("ext", "metric.send", { event: "view_tick" });
+    dlog("ext", "loopback.event", { route: "impression_rendered" });
+    expect(existsSync(join(dir, ".vibe-ads", "debug.log"))).toBe(false);
+  });
+
+  it("isLifecycleEvent: allowlist + family prefixes true; firehose false", async () => {
+    const { isLifecycleEvent } = await import("../src/log");
+    // Allowlist singletons + the real emitter names (guards against an
+    // event being renamed out from under the always-on tier).
+    for (const e of ["activate", "activate.fatal", "preflight",
+                     "session.state", "cli.spinnerVerbs",
+                     "selfupdate.installed", "selfupdate.failed",
+                     "boot.cycle.error", "auth.refresh"]) {
+      expect(isLifecycleEvent(e)).toBe(true);
+    }
+    // Firehose / render plumbing must NOT be always-on.
+    for (const e of ["metric.send", "loopback.event", "portfolio.rotated",
+                     "csp.patch", "evt"]) {
+      expect(isLifecycleEvent(e)).toBe(false);
+    }
+  });
+
+  it("rolling trim: small file is left alone (no read, no rewrite)", async () => {
+    const vd = join(dir, ".vibe-ads");
+    mkdirSync(vd, { recursive: true });
+    writeFileSync(join(vd, "debug.enabled"), "");
+    const log = join(vd, "debug.log");
+    // 5 lines = far below TRIM_MIN_BYTES; trim must be a no-op even when
+    // the throttle is bypassed (size gate skips the read).
+    const seed = ["a", "b", "c", "d", "e"].map(
+      (s, i) => `2026-05-21T00:00:00.000Z [ext] info - seed-${i} ${s}`).join("\n") + "\n";
+    writeFileSync(log, seed);
+    const before = readFileSync(log, "utf8");
+    const { _forceTrimLogForTest } = await import("../src/log");
+    _forceTrimLogForTest();
+    expect(readFileSync(log, "utf8")).toBe(before);
+  });
+
+});
diff --git a/test/logTail.test.ts b/test/logTail.test.ts
new file mode 100644
index 0000000..3218441
--- /dev/null
+++ b/test/logTail.test.ts
@@ -0,0 +1,189 @@
+import { describe, it, expect } from "vitest";
+import { mkdtempSync, writeFileSync, utimesSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { LogTail } from "../src/activity/logTail";
+
+// Mirrors Claude Code's JSONL transcript: one JSON record per line; assistant
+// lines carry message.content[] (tool_use blocks) + message.stop_reason.
+function jsonl(records: object[]): string {
+  return records.map((r) => JSON.stringify(r)).join("\n") + "\n";
+}
+const asst = (blocks: object[], stop: string | null) => ({
+  type: "assistant", timestamp: "2026-05-16T22:54:24.104Z",
+  message: { role: "assistant", stop_reason: stop, content: blocks },
+});
+const tmp = () => join(mkdtempSync(join(tmpdir(), "vibe-ads-log-")), "s.jsonl");
+
+describe("LogTail (JSONL)", () => {
+  it("returns null when no log file (best-effort, never throws)", () => {
+    expect(new LogTail("/no/such/file.jsonl").current()).toBeNull();
+  });
+
+  it("can resolve the transcript lazily after activation", () => {
+    let f = "";
+    const tail = new LogTail(() => f);
+    expect(tail.current()).toBeNull();
+    f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Edit" }], "tool_use"),
+    ]), "utf8");
+    expect(tail.current()?.tool).toBe("Edit");
+    expect(tail.activityAgeMs()).not.toBeNull();
+  });
+
+  it("extracts the most-recent tool_use name + numeric ts", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      { type: "user", message: { role: "user", content: "hi" } },
+      asst([{ type: "tool_use", name: "Read" }], "tool_use"),
+      asst([{ type: "tool_use", name: "Bash" }], "tool_use"),
+    ]), "utf8");
+    const a = new LogTail(f).current();
+    expect(a?.tool).toBe("Bash");
+    expect(typeof a?.ts).toBe("number");
+    expect(a?.done).toBe(false);          // stop_reason "tool_use" => not done
+  });
+
+  it("done=true when the latest assistant turn ended (stop_reason=end_turn)", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Grep" }], "tool_use"),
+      asst([{ type: "text", text: "all set." }], "end_turn"),
+    ]), "utf8");
+    const a = new LogTail(f).current();
+    expect(a?.done).toBe(true);
+    expect(a?.tool).toBe("Grep");         // last tool still reported for context
+  });
+
+  it("done=false when a user prompt is newer than the latest assistant", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Grep" }], "tool_use"),
+      asst([{ type: "text", text: "done" }], "end_turn"),
+      { type: "queue-operation", operation: "enqueue" },
+      { type: "user", message: { role: "user", content: "next" } },
+      { type: "file-history-snapshot", messageId: "u1" },
+    ]), "utf8");
+    const a = new LogTail(f).current();
+    expect(a?.done).toBe(false);
+    expect(a?.tool).toBe("Grep");
+  });
+
+  it("keeps done=true when only a stop hook is newer than the assistant", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Grep" }], "tool_use"),
+      asst([{ type: "text", text: "done" }], "end_turn"),
+      { type: "system", subtype: "stop_hook_summary" },
+    ]), "utf8");
+    expect(new LogTail(f).current()?.done).toBe(true);
+  });
+
+  it("malformed / non-JSON content yields null, never throws", () => {
+    const f = tmp();
+    writeFileSync(f, "\x00\x01 not json at all\n{ broken", "utf8");
+    expect(new LogTail(f).current()).toBeNull();
+  });
+
+  it("skips a sliced first line and still parses the rest", () => {
+    const f = tmp();
+    writeFileSync(f, '{"partial":  \n' +
+      JSON.stringify(asst([{ type: "tool_use", name: "Edit" }], "tool_use")) +
+      "\n", "utf8");
+    expect(new LogTail(f).current()?.tool).toBe("Edit");
+  });
+
+  it("activityAgeMs: null when no transcript (idle/unknown)", () => {
+    expect(new LogTail("/no/such/file.jsonl").activityAgeMs()).toBeNull();
+  });
+
+  it("activityAgeMs: small age right after a transcript write (CC in use)", () => {
+    const f = tmp();
+    writeFileSync(f, "{}\n", "utf8");
+    const age = new LogTail(f).activityAgeMs();
+    expect(age).not.toBeNull();
+    expect(age as number).toBeGreaterThanOrEqual(0);
+    expect(age as number).toBeLessThan(60_000);
+  });
+});
+
+// Backdate a file's mtime by `ms` (utimes takes SECONDS).
+function backdate(f: string, ms: number): void {
+  const t = (Date.now() - ms) / 1000;
+  utimesSync(f, t, t);
+}
+
+// Audit #2: transcripts are never deleted, so a new chat session writes a NEW
+// .jsonl while the pinned one merely goes quiet. The pin must not be forever.
+describe("LogTail re-resolution (new-session adoption)", () => {
+  it("adopts a newer transcript once the pinned one goes idle-stale", () => {
+    const dir = mkdtempSync(join(tmpdir(), "vibe-ads-log-"));
+    const a = join(dir, "old-session.jsonl");
+    const b = join(dir, "new-session.jsonl");
+    writeFileSync(a, jsonl([
+      asst([{ type: "tool_use", name: "Read" }], "end_turn"),
+    ]), "utf8");
+    let resolved = a;
+    const tail = new LogTail(() => resolved);
+    expect(tail.current()?.done).toBe(true);   // pinned to A, turn ended
+    // A goes quiet past IDLE_STALE_MS; the user's NEW session writes to B.
+    backdate(a, 120_000);
+    writeFileSync(b, jsonl([
+      { type: "user", message: { role: "user", content: "next" } },
+      asst([{ type: "tool_use", name: "Bash" }], "tool_use"),
+    ]), "utf8");
+    resolved = b;
+    const act = tail.current();
+    expect(act?.tool).toBe("Bash");            // NEW session's activity…
+    expect(act?.done).toBe(false);             // …live, not stale done:true
+    const age = tail.activityAgeMs();          // watchdog signal follows too
+    expect(age).not.toBeNull();
+    expect(age as number).toBeLessThan(90_000);
+  });
+
+  it("does NOT invoke the resolver while the pinned transcript is fresh", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Edit" }], "tool_use"),
+    ]), "utf8");
+    let calls = 0;
+    const tail = new LogTail(() => { calls++; return f; });
+    tail.current();
+    tail.current();
+    tail.activityAgeMs();
+    expect(calls).toBe(1);                     // only the initial resolve
+  });
+
+  it("throttles stale re-resolution (not re-globbed on every poll)", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Edit" }], "end_turn"),
+    ]), "utf8");
+    backdate(f, 120_000);
+    let calls = 0;
+    const tail = new LogTail(() => { calls++; return f; });
+    tail.current();                            // initial resolve (empty path)
+    expect(calls).toBe(1);
+    tail.current();                            // stale → one re-resolve…
+    expect(calls).toBe(2);
+    tail.current();                            // …then throttled
+    tail.activityAgeMs();
+    expect(calls).toBe(2);
+  });
+
+  it("keeps the pinned transcript when the resolver finds no candidate", () => {
+    const f = tmp();
+    writeFileSync(f, jsonl([
+      asst([{ type: "tool_use", name: "Edit" }], "end_turn"),
+    ]), "utf8");
+    let resolved = f;
+    const tail = new LogTail(() => resolved);
+    expect(tail.current()?.tool).toBe("Edit");
+    backdate(f, 120_000);
+    resolved = "";                             // e.g. all candidates filtered
+    const act = tail.current();
+    expect(act?.tool).toBe("Edit");           // pinned path retained
+    expect(act?.done).toBe(true);             // stale ⇒ done (idle)
+  });
+});
diff --git a/test/loopback.test.ts b/test/loopback.test.ts
new file mode 100644
index 0000000..7907c16
--- /dev/null
+++ b/test/loopback.test.ts
@@ -0,0 +1,240 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { Loopback } from "../src/loopback";
+
+let lb: Loopback | null = null;
+afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });
+
+describe("Loopback", () => {
+  it("token-gated routes fire callbacks; bad token 404s", async () => {
+    const events: string[] = [];
+    let clicked = "";
+    lb = new Loopback({
+      onEvent: (k) => events.push(k),
+      onClick: (ct) => { clicked = ct; },
+      getActivity: () => ({ tool: "Bash", ts: 42 }),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+
+    expect((await fetch(`${base}/impression_rendered`, { method: "POST" })).status).toBe(204);
+    expect((await fetch(`${base}/click?ct=abc`, { method: "POST" })).status).toBe(204);
+    const act = await (await fetch(`${base}/activity`)).json();
+    expect(act).toEqual({ tool: "Bash", ts: 42 });
+    expect(events).toContain("impression_rendered");
+    expect(clicked).toBe("abc");
+
+    const bad = await fetch(`http://127.0.0.1:${port}/vibe-ads/WRONG/activity`);
+    expect(bad.status).toBe(404);
+  });
+
+  it("/click accepts the corr query param without regressing ct parsing", async () => {
+    // Task 6 added `&corr=<id>` to the click ping; the route now also reads
+    // url.searchParams.get("corr"). Guard that the extra param does not break
+    // ct extraction / the 204 contract (the corr→dlog hop is covered by
+    // log.test.ts + the live C-check).
+    let clicked: string | null = null;
+    lb = new Loopback({
+      onEvent: () => {},
+      onClick: (ct) => { clicked = ct; },
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    const r = await fetch(`${base}/click?ct=ck&corr=debug.973525`,
+      { method: "POST" });
+    expect(r.status).toBe(204);
+    expect(clicked).toBe("ck");
+    // corr-only (no ct) must still 204 and fire onClick with "" — never throw
+    const r2 = await fetch(`${base}/click?corr=ad1.zz`, { method: "POST" });
+    expect(r2.status).toBe(204);
+    expect(clicked).toBe("");
+  });
+
+  it("/click relays event_uuid for end-to-end ledger tracing", async () => {
+    let saw: { ct: string; eventUuid?: string } | null = null;
+    lb = new Loopback({
+      onEvent: () => {},
+      onClick: (ct, _surface, _visibleMs, eventUuid) => {
+        saw = { ct, eventUuid };
+      },
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    const eventUuid = "123e4567-e89b-42d3-a456-426614174000";
+
+    const r = await fetch(`${base}/click?ct=ck&event_uuid=${eventUuid}`,
+      { method: "POST" });
+
+    expect(r.status).toBe(204);
+    expect(saw).toEqual({ ct: "ck", eventUuid });
+  });
+
+  it("/click extracts surface for all four product surfaces", async () => {
+    // Surface attribution must reach the onClick callback verbatim across
+    // every product line. A regression here (e.g. accepting only "overlay"
+    // and silently dropping codex_overlay / statusline / banner) would
+    // bucket all clicks under the CC default and break per-product
+    // revenue reporting. Whitelist is in loopback.ts; this is its boundary
+    // contract.
+    const captured: Array<{ ct: string; surface?: string }> = [];
+    lb = new Loopback({
+      onEvent: () => {},
+      onClick: (ct, surface) => { captured.push({ ct, surface }); },
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    for (const s of ["overlay", "banner", "codex_overlay", "statusline"]) {
+      const r = await fetch(`${base}/click?ct=ck&corr=cx&surface=${s}`,
+        { method: "POST" });
+      expect(r.status).toBe(204);
+    }
+    expect(captured.map((c) => c.surface)).toEqual(
+      ["overlay", "banner", "codex_overlay", "statusline"]);
+    expect(captured.every((c) => c.ct === "ck")).toBe(true);
+  });
+
+  it("/click ignores an UNKNOWN surface value (defensive whitelist; never "
+    + "blocks the click)", async () => {
+    // The whitelist is server-authoritative — a client sending a typo or
+    // an attacker-crafted surface must NOT inject a bogus attribution
+    // label into the ledger. The click still bills (defensive: never lose
+    // revenue over a label), but `surface` is undefined downstream.
+    let saw: { ct: string; surface?: string } | null = null;
+    lb = new Loopback({
+      onEvent: () => {},
+      onClick: (ct, surface) => { saw = { ct, surface }; },
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    const r = await fetch(`${base}/click?ct=ck&surface=javascript:alert(1)`,
+      { method: "POST" });
+    expect(r.status).toBe(204);
+    expect(saw).not.toBeNull();
+    expect((saw as unknown as { ct: string }).ct).toBe("ck");
+    expect((saw as unknown as { surface?: string }).surface).toBeUndefined();
+  });
+
+  it("relays view tracking routes with visibility metadata", async () => {
+    const seen: unknown[] = [];
+    lb = new Loopback({
+      onEvent: (k, payload) => { seen.push(k, payload); },
+      onClick: () => {},
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+
+    const r = await fetch(
+      `${base}/view_threshold_met?surface=overlay&visible_ms=15100&session=session123&event_uuid=123e4567-e89b-42d3-a456-426614174000`,
+      { method: "POST" },
+    );
+
+    expect(r.status).toBe(204);
+    expect(seen).toEqual([
+      "view_threshold_met",
+      {
+        surface: "overlay",
+        visibleMs: 15100,
+        sessionNonce: "session123",
+        eventUuid: "123e4567-e89b-42d3-a456-426614174000",
+        viewable: true,
+        viewPct: 100,
+        viewMs: 15100,
+      },
+    ]);
+  });
+
+  it("answers the CORS preflight so the vscode-webview origin can POST", async () => {
+    lb = new Loopback({ onEvent: () => {}, onClick: () => {}, getActivity: () => ({}), getCurrentAd: () => null });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+
+    // The block's dlog POST sends content-type: application/json, which makes
+    // the webview fire an OPTIONS preflight first. It must succeed with ACAO.
+    const pre = await fetch(`${base}/log`, { method: "OPTIONS" });
+    expect(pre.status).toBe(204);
+    expect(pre.headers.get("access-control-allow-origin")).toBe("*");
+    expect((pre.headers.get("access-control-allow-methods") || "")).toContain("POST");
+    expect((pre.headers.get("access-control-allow-headers") || "").toLowerCase())
+      .toContain("content-type");
+  });
+
+  it("every real response carries Access-Control-Allow-Origin", async () => {
+    lb = new Loopback({
+      onEvent: () => {}, onClick: () => {},
+      getActivity: () => ({ tool: "Bash" }),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+
+    const act = await fetch(`${base}/activity`);
+    expect(act.headers.get("access-control-allow-origin")).toBe("*");
+    const imp = await fetch(`${base}/impression_rendered`, { method: "POST" });
+    expect(imp.headers.get("access-control-allow-origin")).toBe("*");
+    // even a 404 must carry it (a preflight to an unknown path still needs it)
+    const miss = await fetch(`http://127.0.0.1:${port}/vibe-ads/WRONG/x`);
+    expect(miss.status).toBe(404);
+    expect(miss.headers.get("access-control-allow-origin")).toBe("*");
+  });
+
+  it("start() is resilient and stop() is idempotent", async () => {
+    lb = new Loopback({ onEvent: () => {}, onClick: () => {}, getActivity: () => ({}), getCurrentAd: () => null });
+    const a = await lb.start();
+    expect(a.port).toBeGreaterThan(0);
+    await lb.stop(); await lb.stop(); // no throw
+  });
+
+  it("setHandlers live-swaps every route on the running server (audit #7 — "
+    + "the shared-loopback takeover)", async () => {
+    const seen: string[] = [];
+    lb = new Loopback({
+      onEvent: (k) => seen.push(`a:${k}`),
+      onClick: () => seen.push("a:click"),
+      getActivity: () => ({ who: "a" }),
+      getCurrentAd: () => ({ adText: "a-ad", clickUrl: "https://a.test",
+        iconUrl: "", adId: "a", campaignId: "a" }),
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    expect((await (await fetch(`${base}/ad`)).json()).adId).toBe("a");
+    lb.setHandlers({
+      onEvent: (k) => seen.push(`b:${k}`),
+      onClick: () => seen.push("b:click"),
+      getActivity: () => ({ who: "b" }),
+      getCurrentAd: () => ({ adText: "b-ad", clickUrl: "https://b.test",
+        iconUrl: "", adId: "b", campaignId: "b" }),
+    });
+    // Same bound port + token; every route now dispatches to B.
+    expect((await (await fetch(`${base}/ad`)).json()).adId).toBe("b");
+    expect(await (await fetch(`${base}/activity`)).json())
+      .toEqual({ who: "b" });
+    expect((await fetch(`${base}/view_tick?surface=overlay`,
+      { method: "POST" })).status).toBe(204);
+    expect((await fetch(`${base}/click?ct=ck`, { method: "POST" })).status)
+      .toBe(204);
+    expect(seen).toContain("b:view_tick");
+    expect(seen).toContain("b:click");
+    expect(seen.filter((s) => s.startsWith("a:"))).toEqual([]);
+  });
+
+  it("isRunning() tracks bind state across start/stop (audit #7 — stale "
+    + "shared-server detection)", async () => {
+    lb = new Loopback({ onEvent: () => {}, onClick: () => {},
+      getActivity: () => ({}), getCurrentAd: () => null });
+    expect(lb.isRunning()).toBe(false);
+    await lb.start();
+    expect(lb.isRunning()).toBe(true);
+    await lb.stop();
+    expect(lb.isRunning()).toBe(false);
+  });
+});
diff --git a/test/loopbackSingleton.test.ts b/test/loopbackSingleton.test.ts
new file mode 100644
index 0000000..4a68972
--- /dev/null
+++ b/test/loopbackSingleton.test.ts
@@ -0,0 +1,138 @@
+/** Audit #7 (wave 3): ONE loopback server per extension host.
+ *
+ *  Pre-fix, every normal boot ran TWO loopbacks: the boot-canary debug
+ *  apply() bound the persisted stable port P first and held it all session,
+ *  so the production loopback EADDRINUSE'd onto P+1 and OVERWROTE the
+ *  "stable" port — the persisted port crept +1 per session, and a stale
+ *  webview from the prior session (whose patch baked port P) reconnected to
+ *  the DEBUG server whose wiring drops demo billing and blinds the desync
+ *  watchdog. bootLoopback() now shares the first successfully-bound server:
+ *  later callers get the SAME port/token/base, and the production (primary)
+ *  caller takes the routes over via a live handler swap. The /ad
+ *  canServeAds gating (wave 2) lives in the HANDLER closures themselves
+ *  (debug.ts / webviewInjection.ts), so it rides along with whichever
+ *  handler set is live — pinned in servingGate.test.ts and debug.test.ts. */
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { Loopback, type LoopbackHandlers } from "../src/loopback";
+import { bootLoopback, resetSharedLoopbackForTest }
+  from "../src/util/loopbackBoot";
+import { makeContext } from "./mocks/vscode";
+
+const PORT_KEY = "kickbacks.loopback.port";
+
+function mkHandlers(tag: string, log: string[]): LoopbackHandlers {
+  return {
+    onEvent: (k) => log.push(`${tag}:${k}`),
+    onClick: () => log.push(`${tag}:click`),
+    getActivity: () => ({ tag }),
+    getCurrentAd: () => ({ adText: `${tag}-ad`, clickUrl: "https://x.test",
+      iconUrl: "", adId: tag, campaignId: tag }),
+  };
+}
+
+let live: Loopback[] = [];
+const track = (lb: Loopback): Loopback => { live.push(lb); return lb; };
+
+beforeEach(() => { resetSharedLoopbackForTest(); });
+afterEach(async () => {
+  for (const lb of live) await lb.stop();
+  live = [];
+  resetSharedLoopbackForTest();
+});
+
+describe("bootLoopback shared server (audit #7)", () => {
+  it("two sequential consumers get the SAME port and ONE server; the "
+    + "production (primary) wiring takes over /ad from the debug stub", async () => {
+    const ctx = makeContext();
+    const log: string[] = [];
+    // Boot order of a normal session: debug stub first (boot canary)…
+    const debugLb = track(new Loopback(mkHandlers("debug", log)));
+    const a = await bootLoopback(debugLb, ctx as never, { secondary: true });
+    expect(a.port).toBeGreaterThan(0);
+    // …then the production loopback. Pre-fix this EADDRINUSE'd onto port+1.
+    const prodLb = track(new Loopback(mkHandlers("prod", log)));
+    const b = await bootLoopback(prodLb, ctx as never);
+    expect(b.port).toBe(a.port);
+    expect(b.token).toBe(a.token);
+    expect(b.base).toBe(a.base);
+    // ONE server: production shares the debug-owned bind, no second listen.
+    expect(debugLb.isRunning()).toBe(true);
+    expect(prodLb.isRunning()).toBe(false);
+    // The production handlers now serve the shared routes (the takeover):
+    // a stale webview reconnecting to the stable port reaches PRODUCTION
+    // wiring, not the debug stub.
+    const ad = await (await fetch(`${b.base}/ad`)).json();
+    expect(ad.adId).toBe("prod");
+    await fetch(`${b.base}/view_tick?surface=overlay&visible_ms=5000`,
+      { method: "POST" });
+    expect(log).toContain("prod:view_tick");
+    expect(log.filter((l) => l.startsWith("debug:"))).toEqual([]);
+    // The persisted stable port was NOT overwritten with port+1.
+    expect(ctx.globalState.get<number>(PORT_KEY)).toBe(a.port);
+  });
+
+  it("a later SECONDARY (debug) registrant never displaces the primary's "
+    + "handlers — but still shares the port", async () => {
+    const ctx = makeContext();
+    const log: string[] = [];
+    // K_ON=false boot: production boots first…
+    const prodLb = track(new Loopback(mkHandlers("prod", log)));
+    const a = await bootLoopback(prodLb, ctx as never);
+    expect(a.port).toBeGreaterThan(0);
+    // …then the user enables Kickbacks → debug apply() boots its stub.
+    const debugLb = track(new Loopback(mkHandlers("debug", log)));
+    const b = await bootLoopback(debugLb, ctx as never, { secondary: true });
+    expect(b.port).toBe(a.port);
+    expect(debugLb.isRunning()).toBe(false);
+    // Production wiring (the billing authority) keeps the routes.
+    const ad = await (await fetch(`${a.base}/ad`)).json();
+    expect(ad.adId).toBe("prod");
+  });
+
+  it("restart simulation: the persisted stable port does NOT creep across "
+    + "sessions (debug-then-production each session re-binds the SAME port)",
+  async () => {
+    const ctx = makeContext();   // ONE globalState across both "sessions"
+    const log: string[] = [];
+    // ── Session 1: normal boot order, both consumers.
+    const s1debug = track(new Loopback(mkHandlers("debug", log)));
+    const s1a = await bootLoopback(s1debug, ctx as never, { secondary: true });
+    const s1prod = track(new Loopback(mkHandlers("prod", log)));
+    const s1b = await bootLoopback(s1prod, ctx as never);
+    const P = s1a.port;
+    expect(P).toBeGreaterThan(0);
+    expect(s1b.port).toBe(P);
+    expect(ctx.globalState.get<number>(PORT_KEY)).toBe(P);
+    // ── Host restart: server torn down, module state rebuilt.
+    await s1debug.stop(); await s1prod.stop();
+    resetSharedLoopbackForTest();
+    // ── Session 2: same boot order against the SAME persisted state.
+    const s2debug = track(new Loopback(mkHandlers("debug", log)));
+    const s2a = await bootLoopback(s2debug, ctx as never, { secondary: true });
+    const s2prod = track(new Loopback(mkHandlers("prod", log)));
+    const s2b = await bootLoopback(s2prod, ctx as never);
+    // Pre-fix: session 1 persisted P+1, so session 2 bound P+1 / P+2 — the
+    // creep that broke last session's baked-in patch URL every reload.
+    expect(s2a.port).toBe(P);
+    expect(s2b.port).toBe(P);
+    expect(ctx.globalState.get<number>(PORT_KEY)).toBe(P);
+  });
+
+  it("a stopped owner doesn't poison the share: the next consumer re-binds "
+    + "fresh on the same stable port", async () => {
+    const ctx = makeContext();
+    const log: string[] = [];
+    const lb1 = track(new Loopback(mkHandlers("one", log)));
+    const a = await bootLoopback(lb1, ctx as never);
+    expect(a.port).toBeGreaterThan(0);
+    await lb1.stop();
+    // The shared record now points at a dead server → fresh bind, not the
+    // stale cached result.
+    const lb2 = track(new Loopback(mkHandlers("two", log)));
+    const b = await bootLoopback(lb2, ctx as never);
+    expect(lb2.isRunning()).toBe(true);
+    expect(b.port).toBe(a.port);   // preferred-port reuse, same stable port
+    const ad = await (await fetch(`${b.base}/ad`)).json();
+    expect(ad.adId).toBe("two");
+  });
+});
diff --git a/test/loopbackTest.test.ts b/test/loopbackTest.test.ts
new file mode 100644
index 0000000..9adf3e0
--- /dev/null
+++ b/test/loopbackTest.test.ts
@@ -0,0 +1,233 @@
+// /test/<name> driver routes on the loopback: lets external scripts (curl,
+// CI runners) fire every kickbacks.test.* hook against a running extension
+// without going through VS Code's command dispatcher. The extension wires
+// these up in activate() (extension.ts) by passing `onTestRoute` to the
+// Loopback constructor; this suite drives the contract directly against a
+// real Loopback + TestHooks pair so it stays hermetic.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { Loopback } from "../src/loopback";
+import { TestHooks, type TestHooksContext } from "../src/testHooks";
+import { MetricsClient } from "../src/metrics/client";
+import { PortfolioClient, type PatchAd } from "../src/portfolio/client";
+import { EarningsClient } from "../src/earnings/client";
+
+const AD: PatchAd = { adId: "ad-route", campaignId: "c-route",
+  adText: "x", iconRef: "i", iconUrl: "", clickUrl: "https://x", bannerEnabled: false,
+  sessionToken: "test-token" };
+
+let lb: Loopback | null = null;
+afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });
+
+function wire(opts: { adOverride?: PatchAd | null;
+                       viewThresholdMs?: number } = {}) {
+  const calls: { url: string; method: string;
+                 body?: unknown; headers: Record<string, string> }[] = [];
+  const fakeFetch = vi.fn(async (url: string, init?: { method?: string;
+      body?: string; headers?: Record<string, string> }) => {
+    const method = (init?.method || "GET").toUpperCase();
+    const body = init?.body ? (() => {
+      try { return JSON.parse(init.body!); } catch { return init.body; } })() : undefined;
+    calls.push({ url, method, body, headers: init?.headers || {} });
+    if (url.includes("/v1/portfolio")) {
+      return { ok: true, status: 200, json: async () => ({
+        ttl_seconds: 30, view_threshold_seconds: 18,
+        ads: [{ ad_id: "ad-fresh", campaign_id: "c-fresh",
+          title_text: "x", icon_ref: "i", click_url: "https://x" }],
+      }) } as Response;
+    }
+    if (url.includes("/v1/earnings")) {
+      return { ok: true, status: 200, json: async () =>
+        ({ lifetime_usd: "5.00", today_usd: "0.50" }) } as Response;
+    }
+    return { ok: true, status: 204, json: async () => ({}) } as Response;
+  });
+  const metrics = new MetricsClient("http://b", () => "tok",
+    () => "cid", "0.1.0", fakeFetch as never);
+  const portfolio = new PortfolioClient("http://b", () => "tok",
+    fakeFetch as never);
+  const earnings = new EarningsClient("http://b", () => "tok",
+    fakeFetch as never);
+  const ctx: TestHooksContext = {
+    ad: opts.adOverride === undefined ? AD : opts.adOverride,
+    signedIn: true,
+    killed: false,
+    ccVersion: "2.1.143",
+    viewThresholdMs: opts.viewThresholdMs ?? 15000,
+    loopback: null,
+  };
+  const hooks = new TestHooks(metrics, portfolio, earnings, () => ctx);
+  return { hooks, calls };
+}
+
+async function makeServer(hooks: TestHooks): Promise<string> {
+  lb = new Loopback({
+    onEvent: () => {},
+    onClick: () => {},
+    getActivity: () => ({}),
+    getCurrentAd: () => null,
+    onTestRoute: (n, p) => hooks.handleTestRoute(n, p),
+  });
+  const { port, token } = await lb.start();
+  return `http://127.0.0.1:${port}/vibe-ads/${token}`;
+}
+
+describe("loopback /test/<name> driver routes", () => {
+
+  it("fireClick returns 200 with FireResult JSON and POSTs /v1/metrics",
+    async () => {
+      const { hooks, calls } = wire();
+      const base = await makeServer(hooks);
+      const r = await fetch(`${base}/test/fireClick?surface=banner&ct=mytok`);
+      expect(r.status).toBe(200);
+      const body = await r.json() as { ok: boolean; sent: { event: string; surface: string; adId: string } };
+      expect(body.ok).toBe(true);
+      expect(body.sent.event).toBe("click");
+      expect(body.sent.surface).toBe("banner");
+      expect(body.sent.adId).toBe("ad-route");
+      const metricsPosts = calls.filter((c) => c.url.endsWith("/v1/metrics"));
+      expect(metricsPosts).toHaveLength(1);
+      expect(metricsPosts[0].body).toMatchObject({
+        event_type: "click", ad_id: "ad-route", campaign_id: "c-route",
+        surface: "banner",
+      });
+    });
+
+  it("fireImpressionRendered / fireImpressionViewable / fireViewTick /"
+    + " fireViewThresholdMet each round-trip through the route and into"
+    + " MetricsClient (every event type exercised over HTTP)", async () => {
+    const { hooks, calls } = wire({ viewThresholdMs: 20000 });
+    const base = await makeServer(hooks);
+    const r1 = await (await fetch(`${base}/test/fireImpressionRendered`)).json();
+    const r2 = await (await fetch(`${base}/test/fireImpressionViewable?visibleMs=8000`)).json();
+    const r3 = await (await fetch(`${base}/test/fireViewTick?visibleMs=5000`)).json();
+    const r4 = await (await fetch(`${base}/test/fireViewThresholdMet`)).json();
+    expect((r1 as { ok: boolean }).ok).toBe(true);
+    expect((r2 as { ok: boolean }).ok).toBe(true);
+    expect((r3 as { ok: boolean }).ok).toBe(true);
+    expect((r4 as { ok: boolean }).ok).toBe(true);
+    const posts = calls.filter((c) => c.url.endsWith("/v1/metrics"));
+    expect(posts.map((p) => (p.body as { event_type: string }).event_type))
+      .toEqual([
+        "impression_rendered", "impression_viewable",
+        "view_tick", "view_threshold_met",
+      ]);
+    // view_threshold_met defaults visibleMs to viewThresholdMs from context.
+    const last = posts[3].body as { visible_ms: number; view_ms: number };
+    expect(last.visible_ms).toBe(20000);
+    expect(last.view_ms).toBe(20000);
+  });
+
+  it("adId/campaignId/corr query params override the live ad context",
+    async () => {
+      const { hooks, calls } = wire({ adOverride: null });
+      const base = await makeServer(hooks);
+      const url = `${base}/test/fireImpressionRendered`
+        + `?surface=codex_overlay&adId=X&campaignId=Y&corr=my.fixed.corr`;
+      const r = await (await fetch(url)).json() as { ok: boolean };
+      expect(r.ok).toBe(true);
+      const post = calls.find((c) => c.url.endsWith("/v1/metrics"))!;
+      expect(post.body).toMatchObject({
+        event_type: "impression_rendered", ad_id: "X",
+        campaign_id: "Y", surface: "codex_overlay",
+      });
+      expect(post.headers["X-Vibe-Corr"]).toBe("my.fixed.corr");
+    });
+
+  it("refreshPortfolio + refreshEarnings + getState + clearEventLog "
+    + "read-only / state routes", async () => {
+    const { hooks } = wire();
+    const base = await makeServer(hooks);
+    // refreshPortfolio returns the new ad
+    const port = await (await fetch(`${base}/test/refreshPortfolio`)).json() as
+      { adId: string };
+    expect(port.adId).toBe("ad-fresh");
+    // refreshEarnings returns parsed payload
+    const earn = await (await fetch(`${base}/test/refreshEarnings`)).json() as
+      { lifetimeUsd: string; todayUsd: string };
+    expect(earn).toEqual({ lifetimeUsd: "5.00", todayUsd: "0.50" });
+    // getState reflects ring state
+    await fetch(`${base}/test/fireImpressionRendered`);
+    const s1 = await (await fetch(`${base}/test/getState`)).json() as
+      { lastEvents: { event: string }[] };
+    expect(s1.lastEvents).toHaveLength(1);
+    // clearEventLog wipes the ring
+    await fetch(`${base}/test/clearEventLog`);
+    const s2 = await (await fetch(`${base}/test/getState`)).json() as
+      { lastEvents: unknown[] };
+    expect(s2.lastEvents).toHaveLength(0);
+  });
+
+  it("unknown route returns 404 JSON instead of crashing", async () => {
+    const { hooks } = wire();
+    const base = await makeServer(hooks);
+    const r = await fetch(`${base}/test/notARealHook`);
+    expect(r.status).toBe(404);
+    const body = await r.json() as { ok: boolean; reason: string };
+    expect(body.ok).toBe(false);
+    expect(body.reason).toMatch(/unknown route/);
+  });
+
+  it("/test/* is invisible without an onTestRoute handler (default Loopback"
+    + " falls through to 404 — confirms routes are opt-in per host)",
+    async () => {
+      lb = new Loopback({
+        onEvent: () => {},
+        onClick: () => {},
+        getActivity: () => ({}),
+      getCurrentAd: () => null,
+      });
+      const { port, token } = await lb.start();
+      const r = await fetch(
+        `http://127.0.0.1:${port}/vibe-ads/${token}/test/fireClick`);
+      expect(r.status).toBe(404);
+    });
+
+  it("CORS preflight: OPTIONS /test/* gets a 204 with the loopback headers",
+    async () => {
+      const { hooks } = wire();
+      const base = await makeServer(hooks);
+      const r = await fetch(`${base}/test/fireClick`, { method: "OPTIONS" });
+      expect(r.status).toBe(204);
+      expect(r.headers.get("access-control-allow-origin")).toBe("*");
+    });
+});
+
+describe("loopback /test/* gated off", () => {
+  afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });
+
+  it("returns 403 when testHooksEnabled() is false at request time",
+    async () => {
+      vi.resetModules();
+      vi.doMock("../src/log", () => ({
+        debugEnabled: () => false, dlog: () => {}, dlogRaw: () => {},
+        codexEnabled: () => false, testHooksEnabled: () => false,
+        LOG_PATH: "/tmp/test-log",
+      }));
+      const { Loopback: L } = await import("../src/loopback");
+      const { TestHooks: TH } = await import("../src/testHooks");
+      const { MetricsClient: M } = await import("../src/metrics/client");
+      const { PortfolioClient: P } = await import("../src/portfolio/client");
+      const { EarningsClient: E } = await import("../src/earnings/client");
+      const fakeFetch = vi.fn(async () =>
+        ({ ok: true, status: 204, json: async () => ({}) } as Response));
+      const hooks = new TH(
+        new M("http://b", () => "tok", () => "cid", "0.1.0", fakeFetch as never),
+        new P("http://b", () => "tok", fakeFetch as never),
+        new E("http://b", () => "tok", fakeFetch as never),
+        () => ({ ad: AD, signedIn: true, killed: false,
+          ccVersion: "v", viewThresholdMs: 15000, loopback: null }));
+      lb = new L({
+        onEvent: () => {}, onClick: () => {}, getActivity: () => ({}),
+        getCurrentAd: () => null,
+        onTestRoute: (n, p) => hooks.handleTestRoute(n, p),
+      });
+      const { port, token } = await lb.start();
+      const r = await fetch(
+        `http://127.0.0.1:${port}/vibe-ads/${token}/test/fireClick`);
+      expect(r.status).toBe(403);
+      const body = await r.json() as { ok: boolean; reason: string };
+      expect(body.ok).toBe(false);
+      expect(body.reason).toMatch(/disabled/);
+      vi.doUnmock("../src/log");
+    });
+});
diff --git a/test/manifestSigning.test.ts b/test/manifestSigning.test.ts
new file mode 100644
index 0000000..9c7a507
--- /dev/null
+++ b/test/manifestSigning.test.ts
@@ -0,0 +1,70 @@
+import { describe, it, expect } from "vitest";
+import { generateKeyPairSync, sign } from "node:crypto";
+import { manifestSignedString } from "../scripts/deploy.mjs";
+import { _verifyManifestSignature } from "../src/update/client";
+
+// The producer (deploy.mjs::manifestSignedString) and the consumer
+// (client.ts::_verifyManifestSignature) must agree on the signed-payload format
+// byte-for-byte, or signature verification silently breaks once a pubkey-
+// embedded client requires it ("Step C"). These tests sign with the REAL
+// producer string and verify with the REAL consumer so a divergence fails CI.
+describe("manifest signing contract (deploy.mjs producer <-> client.ts consumer)", () => {
+  const { publicKey, privateKey } = generateKeyPairSync("ed25519");
+  const pubPem = publicKey.export({ type: "spki", format: "pem" }) as string;
+  const SHA = "a".repeat(64);
+  const URL = "https://storage.googleapis.com/kickbacks-vsix/kickbacks.vsix";
+
+  // Sign exactly what deploy.mjs would sign for a given manifest shape.
+  const signFor = (m: { version: string; sha256: string; url: string; rollback_to?: string }) =>
+    sign(null, Buffer.from(
+      manifestSignedString(m.version, m.sha256, m.url, m.rollback_to ?? "")), privateKey)
+      .toString("base64");
+
+  it("forward manifest (rollback_to empty) verifies", () => {
+    const m = { version: "0.3.200", sha256: SHA, url: URL };
+    expect(_verifyManifestSignature({ ...m, signature: signFor(m) }, pubPem)).toBe(true);
+  });
+
+  it("signed rollback (rollback_to set) verifies", () => {
+    const m = { version: "0.3.150", sha256: SHA, url: URL, rollback_to: "0.3.200" };
+    expect(_verifyManifestSignature({ ...m, signature: signFor(m) }, pubPem)).toBe(true);
+  });
+
+  it("missing signature -> false", () => {
+    expect(_verifyManifestSignature({ version: "0.3.200", sha256: SHA, url: URL }, pubPem))
+      .toBe(false);
+  });
+
+  it("tampered sha256 -> false", () => {
+    const m = { version: "0.3.200", sha256: SHA, url: URL };
+    expect(_verifyManifestSignature(
+      { ...m, signature: signFor(m), sha256: "b".repeat(64) }, pubPem)).toBe(false);
+  });
+
+  it("tampered url (attacker-host swap) -> false", () => {
+    const m = { version: "0.3.200", sha256: SHA, url: URL };
+    expect(_verifyManifestSignature(
+      { ...m, signature: signFor(m), url: "https://evil.example.com/x.vsix" }, pubPem))
+      .toBe(false);
+  });
+
+  it("grafted rollback_to onto a forward signature -> false (downgrade-graft defense)", () => {
+    const m = { version: "0.3.200", sha256: SHA, url: URL };
+    const forwardSig = signFor(m); // signed with rollback_to=""
+    expect(_verifyManifestSignature(
+      { ...m, signature: forwardSig, rollback_to: "0.3.100" }, pubPem)).toBe(false);
+  });
+
+  it("signature from a different key -> false", () => {
+    const other = generateKeyPairSync("ed25519");
+    const otherPem = other.publicKey.export({ type: "spki", format: "pem" }) as string;
+    const m = { version: "0.3.200", sha256: SHA, url: URL };
+    expect(_verifyManifestSignature({ ...m, signature: signFor(m) }, otherPem)).toBe(false);
+  });
+
+  it("malformed base64 signature -> false (never throws)", () => {
+    expect(_verifyManifestSignature(
+      { version: "0.3.200", sha256: SHA, url: URL, signature: "!!!not-base64!!!" }, pubPem))
+      .toBe(false);
+  });
+});
diff --git a/test/metrics.test.ts b/test/metrics.test.ts
new file mode 100644
index 0000000..7883331
--- /dev/null
+++ b/test/metrics.test.ts
@@ -0,0 +1,225 @@
+import { describe, it, expect, vi } from "vitest";
+import { MetricsClient, noteMetricsSignOut } from "../src/metrics/client";
+
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+describe("MetricsClient", () => {
+  it("POSTs a well-formed metrics_event with a fresh UUID nonce + auth", async () => {
+    const calls: { url: string; body: any; hdr: any }[] = [];
+    const f = vi.fn(async (url: string, init: any) => {
+      calls.push({ url, body: JSON.parse(init.body), hdr: init.headers });
+      return { ok: true, status: 200 } as Response;
+    });
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0", f as never);
+    await c.send("click", { adId: "a1", campaignId: "c1", ccVersion: "2.1.143" });
+    await c.send("click", { adId: "a1", campaignId: "c1", ccVersion: "2.1.143" });
+    expect(calls[0].url).toBe("http://b/v1/metrics");
+    const b = calls[0].body;
+    expect(b).toMatchObject({ event_type: "click", ad_id: "a1", campaign_id: "c1",
+      client_id: "cid", claude_code_version: "2.1.143", extension_version: "0.1.0" });
+    expect(typeof b.ts).toBe("string");
+    expect(b.nonce).toMatch(UUID_RE);
+    expect(calls[0].body.nonce).not.toBe(calls[1].body.nonce); // fresh per event
+    expect(calls[0].hdr.authorization).toBe("Bearer tok");
+  });
+  it("includes the client-env fingerprint in body.ext when configured", async () => {
+    const calls: { body: any }[] = [];
+    const f = vi.fn(async (_url: string, init: any) => {
+      calls.push({ body: JSON.parse(init.body) });
+      return { ok: true, status: 200 } as Response;
+    });
+    const env = { os: "win32", arch: "x64", os_version: "10.0.26200",
+      editor: "Visual Studio Code" };
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0",
+      f as never, env);
+    await c.send("click", { adId: "a1", campaignId: "c1", ccVersion: "2.1.143" });
+    expect(calls[0].body.ext).toEqual(env);
+  });
+  it("omits body.ext when no client-env is configured", async () => {
+    const calls: { body: any }[] = [];
+    const f = vi.fn(async (_url: string, init: any) => {
+      calls.push({ body: JSON.parse(init.body) });
+      return { ok: true, status: 200 } as Response;
+    });
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0", f as never);
+    await c.send("click", { adId: "a1", campaignId: "c1", ccVersion: "2.1.143" });
+    expect("ext" in calls[0].body).toBe(false);
+  });
+  it("uses an explicit event UUID as the transmitted nonce", async () => {
+    const calls: { body: any }[] = [];
+    const f = vi.fn(async (_url: string, init: any) => {
+      calls.push({ body: JSON.parse(init.body) });
+      return { ok: true, status: 200 } as Response;
+    });
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0", f as never);
+    const eventUuid = "123e4567-e89b-42d3-a456-426614174000";
+    await c.send("click", {
+      adId: "a1",
+      campaignId: "c1",
+      ccVersion: "2.1.143",
+      eventUuid,
+    });
+
+    expect(calls[0].body.nonce).toBe(eventUuid);
+  });
+  it("sends X-Vibe-Corr only when corr is provided", async () => {
+    const calls: { hdr: any }[] = [];
+    const f = vi.fn(async (_url: string, init: any) => {
+      calls.push({ hdr: init.headers });
+      return { ok: true, status: 200 } as Response;
+    });
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0", f as never);
+    await c.send("click", { adId: "a", campaignId: "c", ccVersion: "v",
+      corr: "a.r3nd" });
+    await c.send("click", { adId: "a", campaignId: "c", ccVersion: "v" }); // no corr
+    expect(calls[0].hdr["X-Vibe-Corr"]).toBe("a.r3nd");
+    expect(calls[0].hdr.authorization).toBe("Bearer tok"); // existing hdrs intact
+    expect(calls[0].hdr["content-type"]).toBe("application/json");
+    expect("X-Vibe-Corr" in calls[1].hdr).toBe(false); // omitted when absent
+  });
+  it("sends view threshold fields for billable view tracking", async () => {
+    const calls: { body: any }[] = [];
+    const f = vi.fn(async (_url: string, init: any) => {
+      calls.push({ body: JSON.parse(init.body) });
+      return { ok: true, status: 200 } as Response;
+    });
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0", f as never);
+    await c.send("view_threshold_met", {
+      adId: "a",
+      campaignId: "c",
+      ccVersion: "v",
+      surface: "overlay",
+      visibleMs: 15100,
+      sessionNonce: "session123",
+      viewable: true,
+      viewPct: 100,
+      viewMs: 15100,
+    });
+
+    expect(calls[0].body).toMatchObject({
+      event_type: "view_threshold_met",
+      surface: "overlay",
+      visible_ms: 15100,
+      session_nonce: "session123",
+      viewable: true,
+      view_pct: 100,
+      view_ms: 15100,
+    });
+  });
+  it("never throws on network failure", async () => {
+    const c = new MetricsClient("http://b", () => null, () => "cid", "0.1.0",
+      (async () => { throw new Error("down"); }) as never);
+    await expect(c.send("impression_rendered",
+      { adId: "a", campaignId: "c", ccVersion: "v" })).resolves.toBeUndefined();
+  });
+
+  it("routes a tokenless (signed-out demo) send to /v1/metrics/demo with NO auth", async () => {
+    const calls: { url: string; hdr: any; body: any }[] = [];
+    const f = vi.fn(async (url: string, init: any) => {
+      calls.push({ url, hdr: init.headers, body: JSON.parse(init.body) });
+      return { ok: true, status: 200 } as Response;
+    });
+    // token() => null ⇒ signed out ⇒ demo routing.
+    const c = new MetricsClient("http://b", () => null, () => "cid", "0.1.0", f as never);
+    await c.send("view_threshold_met", { adId: "a", campaignId: "c", ccVersion: "v",
+      sessionToken: "demo-tok" });
+    expect(calls[0].url).toBe("http://b/v1/metrics/demo");
+    expect("authorization" in calls[0].hdr).toBe(false);     // public endpoint
+    expect(calls[0].body.client_id).toBe("cid");             // demo identity anchor
+    expect(calls[0].body.session_token).toBe("demo-tok");
+  });
+
+  it("routes a signed-in send to /v1/metrics with the bearer (unchanged)", async () => {
+    const calls: { url: string; hdr: any }[] = [];
+    const f = vi.fn(async (url: string, init: any) => {
+      calls.push({ url, hdr: init.headers });
+      return { ok: true, status: 200 } as Response;
+    });
+    const c = new MetricsClient("http://b", () => "tok", () => "cid", "0.1.0", f as never);
+    await c.send("view_threshold_met", { adId: "a", campaignId: "c", ccVersion: "v" });
+    expect(calls[0].url).toBe("http://b/v1/metrics");
+    expect(calls[0].hdr.authorization).toBe("Bearer tok");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Audit #25: a mid-session token death silently demotes sends to
+// /v1/metrics/demo with REAL ad ids. Demoted sends must stay distinguishable:
+// every demo-route send is stamped ext.demo, and a was-signed-in (demoted)
+// send additionally carries ext.demoted. The stamp rides inside `ext` — the
+// only schema-allowed free-form field (the backend 400s unknown top-level
+// keys, see backend/app/ads/router.py::_validate_metric_contract).
+// ---------------------------------------------------------------------------
+describe("demo-route demotion stamping", () => {
+  const capture = () => {
+    const calls: { url: string; body: any }[] = [];
+    const f = vi.fn(async (url: string, init: any) => {
+      calls.push({ url, body: JSON.parse(init.body) });
+      return { ok: true, status: 200 } as Response;
+    });
+    return { calls, f };
+  };
+
+  it("stamps ext.demoted when a previously-authed client loses its token mid-session", async () => {
+    const { calls, f } = capture();
+    let tok: string | null = "tok";
+    const c = new MetricsClient("http://b", () => tok, () => "cid", "0.1.0", f as never);
+    await c.send("view_tick", { adId: "real-ad", campaignId: "c1", ccVersion: "v" });
+    tok = null; // mid-session token death (refresh rejected / nulled)
+    await c.send("view_tick", { adId: "real-ad", campaignId: "c1", ccVersion: "v" });
+    expect(calls[0].url).toBe("http://b/v1/metrics");
+    expect("ext" in calls[0].body).toBe(false);     // authed send unchanged
+    expect(calls[1].url).toBe("http://b/v1/metrics/demo");
+    expect(calls[1].body.ext).toMatchObject({ demo: true, demoted: true });
+  });
+
+  it("stamps ext.demo (but NOT demoted) on never-authed signed-out demo sends", async () => {
+    const { calls, f } = capture();
+    const c = new MetricsClient("http://b", () => null, () => "cid", "0.1.0", f as never);
+    await c.send("view_tick", { adId: "a", campaignId: "c", ccVersion: "v" });
+    expect(calls[0].url).toBe("http://b/v1/metrics/demo");
+    expect(calls[0].body.ext.demo).toBe(true);
+    expect("demoted" in calls[0].body.ext).toBe(false);
+  });
+
+  it("does NOT stamp demoted after a deliberate sign-out (noteMetricsSignOut)", async () => {
+    const { calls, f } = capture();
+    let tok: string | null = "tok";
+    const c = new MetricsClient("http://b", () => tok, () => "cid", "0.1.0", f as never);
+    await c.send("view_tick", { adId: "a", campaignId: "c", ccVersion: "v" });
+    tok = null;
+    noteMetricsSignOut(); // cmdSignOut resets the demotion tracking
+    await c.send("view_tick", { adId: "a", campaignId: "c", ccVersion: "v" });
+    expect(calls[1].url).toBe("http://b/v1/metrics/demo");
+    expect(calls[1].body.ext.demo).toBe(true);
+    expect("demoted" in calls[1].body.ext).toBe(false);
+  });
+
+  it("merges the demo stamp with the client-env fingerprint without mutating it", async () => {
+    const { calls, f } = capture();
+    const env = { os: "win32", arch: "x64" };
+    let tok: string | null = "tok";
+    const c = new MetricsClient("http://b", () => tok, () => "cid", "0.1.0",
+      f as never, env);
+    await c.send("click", { adId: "a", campaignId: "c", ccVersion: "v" });
+    tok = null;
+    await c.send("click", { adId: "a", campaignId: "c", ccVersion: "v" });
+    expect(calls[0].body.ext).toEqual(env); // authed: fingerprint untouched
+    expect(calls[1].body.ext).toEqual(
+      { os: "win32", arch: "x64", demo: true, demoted: true });
+    expect(env).toEqual({ os: "win32", arch: "x64" }); // source not mutated
+  });
+
+  it("a recovered token routes authed again with no demo stamp", async () => {
+    const { calls, f } = capture();
+    let tok: string | null = "tok";
+    const c = new MetricsClient("http://b", () => tok, () => "cid", "0.1.0", f as never);
+    await c.send("view_tick", { adId: "a", campaignId: "c", ccVersion: "v" });
+    tok = null;
+    await c.send("view_tick", { adId: "a", campaignId: "c", ccVersion: "v" });
+    tok = "tok2"; // refresh recovered mid-session
+    await c.send("view_tick", { adId: "a", campaignId: "c", ccVersion: "v" });
+    expect(calls[2].url).toBe("http://b/v1/metrics");
+    expect("ext" in calls[2].body).toBe(false);
+  });
+});
diff --git a/test/mocks/vscode.ts b/test/mocks/vscode.ts
new file mode 100644
index 0000000..69256d9
--- /dev/null
+++ b/test/mocks/vscode.ts
@@ -0,0 +1,101 @@
+// Minimal hermetic mock of the `vscode` module surface S3 uses.
+export const secrets = new Map<string, string>();
+export const _opened: string[] = [];
+// _shown: every showInformationMessage / showErrorMessage call, in order.
+// Lets commands.test.ts assert that `kickbacks.status` produced the expected
+// toast without having to spy() each handler individually.
+export const _shown: { kind: "info" | "error"; text: string }[] = [];
+// _warned: every showWarningMessage call, in order. Kept separate from _shown
+// so adding warning capture never perturbs a test asserting on _shown counts.
+export const _warned: string[] = [];
+// _opened docs: every workspace.openTextDocument(path) call. Used to verify
+// that `kickbacks.editConfig` actually opened the config file.
+export const _openedDocs: string[] = [];
+export const window = {
+  createStatusBarItem: () => ({
+    text: "", tooltip: "", command: "" as string | undefined,
+    color: undefined as string | undefined,
+    backgroundColor: undefined as ThemeColor | undefined,
+    show() {}, hide() {}, dispose() {},
+  }),
+  showInformationMessage: async (msg: unknown, ..._rest: unknown[]) => {
+    _shown.push({ kind: "info", text: String(msg) }); return undefined;
+  },
+  showErrorMessage: async (msg: unknown, ..._rest: unknown[]) => {
+    _shown.push({ kind: "error", text: String(msg) }); return undefined;
+  },
+  showWarningMessage: async (msg: unknown, ..._rest: unknown[]) => {
+    _warned.push(String(msg)); return undefined;
+  },
+  showQuickPick: async (_items: unknown[], _o?: unknown) => undefined as unknown,
+  showInputBox: async (_o?: unknown) => undefined as string | undefined,
+  showTextDocument: async (_doc: unknown, _o?: unknown) => undefined as unknown,
+};
+export const workspace = {
+  openTextDocument: async (p: unknown) => {
+    const path = typeof p === "string" ? p
+      : (p as { fsPath?: string; toString(): string })?.fsPath
+        ?? (p as { toString(): string }).toString();
+    _openedDocs.push(path);
+    return { uri: { fsPath: path }, getText: () => "" };
+  },
+};
+export const env = {
+  openExternal: async (u: { toString(): string }) => { _opened.push(u.toString()); return true; },
+  // Remote port-forwarding tunnel; identity in tests/local desktop.
+  asExternalUri: async (u: { toString(): string }) => u,
+  clipboard: {
+    _last: "" as string,
+    writeText: async function (t: string) { this._last = t; },
+  },
+};
+export const commands = {
+  _handlers: new Map<string, (...a: unknown[]) => unknown>(),
+  // Every executeCommand call, in order. Useful when a handler delegates
+  // (e.g. the debug menu's "Sign in" item executes "kickbacks.signIn").
+  _executed: [] as { id: string; args: unknown[] }[],
+  registerCommand(id: string, h: (...a: unknown[]) => unknown) {
+    this._handlers.set(id, h); return { dispose() {} };
+  },
+  // Dispatch to a registered handler when present so tests can drive the
+  // exact command pipeline VS Code itself would. Unknown ids (e.g.
+  // "workbench.action.restartExtensionHost") fall through to undefined,
+  // matching the pre-dispatch behavior — extension code that fires them
+  // for side effect keeps working in tests.
+  async executeCommand(id: string, ...args: unknown[]) {
+    commands._executed.push({ id, args });
+    const h = commands._handlers.get(id);
+    if (!h) return undefined;
+    return await h(...args);
+  },
+};
+export const Uri = {
+  parse: (s: string) => ({ toString: () => s }),
+  file: (p: string) => ({ fsPath: p, toString: () => `file://${p}` }),
+};
+export const QuickPickItemKind = { Default: 0, Separator: -1 };
+// Theme-token reference (e.g. "statusBarItem.errorBackground"); the mock just
+// records the id so tests can assert which token a surface painted with.
+export class ThemeColor { constructor(public readonly id: string) {} }
+export class EventEmitter<T> { event = (_l: (e: T) => void) => ({ dispose() {} }); fire(_e: T) {} }
+export const StatusBarAlignment = { Left: 1, Right: 2 };
+export interface ExtensionContext {
+  secrets: { get(k: string): Thenable<string | undefined>;
+             store(k: string, v: string): Thenable<void>;
+             delete(k: string): Thenable<void>; };
+  globalState: { get<T>(k: string): T | undefined; update(k: string, v: unknown): Thenable<void> };
+  subscriptions: { dispose(): void }[];
+}
+export function makeContext(): ExtensionContext {
+  const gs = new Map<string, unknown>();
+  return {
+    secrets: {
+      get: async (k) => secrets.get(k),
+      store: async (k, v) => { secrets.set(k, v); },
+      delete: async (k) => { secrets.delete(k); },
+    },
+    globalState: { get: <T>(k: string) => gs.get(k) as T | undefined,
+                   update: async (k, v) => { gs.set(k, v); } },
+    subscriptions: [],
+  };
+}
diff --git a/test/modes.test.ts b/test/modes.test.ts
new file mode 100644
index 0000000..0f28d3a
--- /dev/null
+++ b/test/modes.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const REAL_HOME = process.env.HOME;
+const REAL_UP = process.env.USERPROFILE;
+let dir: string;
+let vd: string;
+
+async function fresh() {
+  const m = await import("../src/modes");
+  return m;
+}
+
+beforeEach(() => {
+  dir = mkdtempSync(join(tmpdir(), "vibe-modes-"));
+  process.env.HOME = dir;
+  process.env.USERPROFILE = dir;
+  vd = join(dir, ".vibe-ads");
+  mkdirSync(vd, { recursive: true });
+});
+
+afterEach(() => {
+  if (REAL_HOME !== undefined) process.env.HOME = REAL_HOME; else delete process.env.HOME;
+  if (REAL_UP !== undefined) process.env.USERPROFILE = REAL_UP; else delete process.env.USERPROFILE;
+  rmSync(dir, { recursive: true, force: true });
+});
+
+describe("modes", () => {
+  it("defaults: webview on, cli on, banner server", async () => {
+    const m = await fresh();
+    expect(m.webviewMode()).toBe("on");
+    expect(m.cliMode()).toBe("on");
+    expect(m.bannerOverride()).toBe("server");
+  });
+
+  it("webview.off / cli.off sentinels force off; round-trips via setters", async () => {
+    const m = await fresh();
+    m.setWebviewMode(false);
+    m.setCliMode(false);
+    expect(existsSync(join(vd, "webview.off"))).toBe(true);
+    expect(m.webviewMode()).toBe("off");
+    expect(m.cliMode()).toBe("off");
+    m.setWebviewMode(true);
+    expect(existsSync(join(vd, "webview.off"))).toBe(false);
+    expect(m.webviewMode()).toBe("on");
+  });
+
+  it("banner.mode reads on/off; invalid -> server; setter writes/removes", async () => {
+    const m = await fresh();
+    writeFileSync(join(vd, "banner.mode"), " on \n");
+    expect(m.bannerOverride()).toBe("on");
+    writeFileSync(join(vd, "banner.mode"), "garbage");
+    expect(m.bannerOverride()).toBe("server");
+    m.setBannerOverride("off");
+    expect(m.bannerOverride()).toBe("off");
+    m.setBannerOverride("server");
+    expect(existsSync(join(vd, "banner.mode"))).toBe(false);
+    expect(m.bannerOverride()).toBe("server");
+  });
+
+  it("never throws on fs errors (returns safe defaults)", async () => {
+    const m = await fresh();
+    process.env.HOME = "\0invalid";
+    process.env.USERPROFILE = "\0invalid";
+    expect(() => m.webviewMode()).not.toThrow();
+    expect(m.webviewMode()).toBe("on");
+    expect(m.bannerOverride()).toBe("server");
+  });
+});
diff --git a/test/outdatedCliNotice.test.ts b/test/outdatedCliNotice.test.ts
new file mode 100644
index 0000000..4729837
--- /dev/null
+++ b/test/outdatedCliNotice.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+
+// Mute dlog so the helper doesn't append to the developer's real
+// ~/.vibe-ads/debug.log during tests (same reason incompatNotice.test.ts mocks it).
+vi.mock("../src/log", () => ({ dlog: () => {} }));
+
+import { notifyOutdatedCli } from "../src/activation/outdatedCliNotice";
+import { SPINNER_VERBS_FLOOR, type SemVer } from "../src/adapters/claude-cli/cliVersion";
+import { makeContext, _warned } from "./mocks/vscode";
+
+const OLD: SemVer = [2, 0, 14];
+
+describe("notifyOutdatedCli", () => {
+  beforeEach(() => { _warned.length = 0; });
+
+  it("warns exactly once per detected version (reload never re-nags)", () => {
+    const ctx = makeContext() as never;
+    notifyOutdatedCli(ctx, OLD);
+    notifyOutdatedCli(ctx, OLD); // reload — deduped via globalState
+    expect(_warned.length).toBe(1);
+  });
+
+  it("names the detected version, the floor, and the fix", () => {
+    notifyOutdatedCli(makeContext() as never, OLD);
+    const floor = SPINNER_VERBS_FLOOR.join(".");
+    expect(_warned[0]).toContain("2.0.14");
+    expect(_warned[0]).toContain(floor);
+    expect(_warned[0]).toContain("claude update");
+  });
+
+  it("re-warns once for a DIFFERENT old version", () => {
+    const ctx = makeContext() as never;
+    notifyOutdatedCli(ctx, OLD);
+    notifyOutdatedCli(ctx, [2, 1, 100]);
+    expect(_warned.length).toBe(2);
+    expect(_warned[1]).toContain("2.1.100");
+  });
+
+  it("dedupe is per-context-state, keyed by version (fresh install re-notifies)", () => {
+    notifyOutdatedCli(makeContext() as never, OLD);
+    notifyOutdatedCli(makeContext() as never, OLD); // new globalState
+    expect(_warned.length).toBe(2);
+  });
+
+  it("never throws when globalState is broken (best-effort contract)", () => {
+    const ctx = {
+      globalState: {
+        get: () => { throw new Error("storage dead"); },
+        update: () => { throw new Error("storage dead"); },
+      },
+    } as never;
+    expect(() => notifyOutdatedCli(ctx, OLD)).not.toThrow();
+    expect(_warned.length).toBe(0); // threw before the toast — but silently
+  });
+});
diff --git a/test/portfolio.test.ts b/test/portfolio.test.ts
new file mode 100644
index 0000000..88bb7c4
--- /dev/null
+++ b/test/portfolio.test.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect, vi } from "vitest";
+import { PortfolioClient, fetchPortfolioWithDemoFallback } from "../src/portfolio/client";
+
+const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body }) as Response;
+
+describe("PortfolioClient", () => {
+  it("maps S2's real flat shape into a PatchAd; caches with ttl", async () => {
+    const fetchMock = vi.fn(async () => ok({
+      ttl_seconds: 60,
+      ads: [{ ad_id: "a1", campaign_id: "c1", seat: "c1", weight: 1,
+              title_text: "Acme deploys faster than your CI now", icon_ref: "icon.a",
+              click_url: "https://acme.test/x" }],
+    }));
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const ad = await c.fetchAd("2.1.143");
+    // `seat` in the wire payload above is ignored by the parser and absent
+    // from the mapped ad (toEqual asserts the exact shape — no `seat` key).
+    expect(ad).toEqual({ adId: "a1", campaignId: "c1",
+      adText: "Acme deploys faster than your CI now", iconRef: "icon.a",
+      iconUrl: "", clickUrl: "https://acme.test/x", bannerEnabled: false,
+      sessionToken: "" });
+  });
+
+  it("banner_enabled:true in payload => bannerEnabled true", async () => {
+    const fetchMock = vi.fn(async () => ok({
+      ttl_seconds: 60,
+      ads: [{ ad_id: "a2", campaign_id: "c2", seat: "c2", weight: 1,
+              title_text: "Banner ad text", icon_ref: "icon.b",
+              click_url: "https://banner.test/x", banner_enabled: true }],
+    }));
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const ad = await c.fetchAd("2.1.143");
+    expect(ad?.bannerEnabled).toBe(true);
+  });
+
+  it("banner_enabled absent in payload => bannerEnabled false", async () => {
+    const fetchMock = vi.fn(async () => ok({
+      ttl_seconds: 60,
+      ads: [{ ad_id: "a3", campaign_id: "c3", seat: "c3", weight: 1,
+              title_text: "No banner ad text", icon_ref: "icon.c",
+              click_url: "https://nobanner.test/x" }],
+    }));
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const ad = await c.fetchAd("2.1.143");
+    expect(ad?.bannerEnabled).toBe(false);
+  });
+
+  it("empty ads -> null (valid: no patch)", async () => {
+    const c = new PortfolioClient("http://b", () => "tok",
+      (async () => ok({ ttl_seconds: 60, ads: [] })) as never);
+    expect(await c.fetchAd("2.1.143")).toBeNull();
+  });
+
+  it("on fetch error serves last good ad until ttl, then null", async () => {
+    let n = 0;
+    const fetchMock = vi.fn(async () => {
+      n++;
+      if (n === 1) return ok({ ttl_seconds: 0, ads: [{ ad_id: "a1", campaign_id: "c1",
+        seat: "c1", weight: 1, title_text: "x".repeat(35), icon_ref: "i",
+        click_url: "https://t/x" }] });
+      throw new Error("network down");
+    });
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    expect((await c.fetchAd("2.1.143"))?.adId).toBe("a1");   // primes cache
+    // ttl_seconds:0 -> cache already expired, error -> null
+    expect(await c.fetchAd("2.1.143")).toBeNull();
+  });
+
+  describe("fetchDemoPortfolio (signed-out preview)", () => {
+    it("hits /v1/portfolio/demo with client_id and NO auth header, stamps demo:true", async () => {
+      const calls: { url: string; init: any }[] = [];
+      const fetchMock = vi.fn(async (url: string, init: any) => {
+        calls.push({ url, init });
+        return ok({ ttl_seconds: 60, ads: [{ ad_id: "a1", campaign_id: "c1",
+          seat: "c1", title_text: "x".repeat(35), icon_ref: "i",
+          click_url: "https://t/x", session_token: "demo-tok" }] });
+      });
+      // token() returns a value, but fetchDemoPortfolio must NOT send it as auth.
+      const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+      const r = await c.fetchDemoPortfolio("2.1.143", "dev-123");
+      expect(calls[0].url).toBe(
+        "http://b/v1/portfolio/demo?claude_code_version=2.1.143&client_id=dev-123");
+      // No Authorization header on the public demo surface.
+      expect(calls[0].init.headers.authorization).toBeUndefined();
+      expect(r!.ads[0].demo).toBe(true);
+      expect(r!.ads[0].sessionToken).toBe("demo-tok");
+      expect(r!.ad?.demo).toBe(true);
+    });
+
+    it("real fetchPortfolio does NOT stamp demo", async () => {
+      const c = new PortfolioClient("http://b", () => "tok",
+        (async () => ok({ ttl_seconds: 60, ads: [{ ad_id: "a1", campaign_id: "c1",
+          seat: "c1", title_text: "x".repeat(35), icon_ref: "i",
+          click_url: "https://t/x" }] })) as never);
+      const r = await c.fetchPortfolio("2.1.143");
+      expect(r!.ads[0].demo).toBeUndefined();
+    });
+  });
+});
+
+describe("fetchPortfolioWithDemoFallback", () => {
+  const real = (token: string) => ok({
+    ttl_seconds: 60, ads: [{ ad_id: "real1", campaign_id: "c1", seat: "s",
+      title_text: "x".repeat(35), icon_ref: "i", click_url: "https://r.test",
+      session_token: token }],
+  });
+  const demo = ok({
+    ttl_seconds: 60, ads: [{ ad_id: "demo1", campaign_id: "cd", seat: "s",
+      title_text: "y".repeat(35), icon_ref: "i", click_url: "https://d.test",
+      session_token: "demo-tok" }],
+  });
+  const empty = ok({ ttl_seconds: 60, ads: [] });
+  // A non-2xx makes PortfolioClient.fetchPortfolio throw internally → null
+  // (the "hard failure / 401" signal, distinct from a valid-but-empty 200).
+  const fail = { ok: false, status: 401, json: async () => ({}) } as Response;
+
+  function authStub(over: Partial<{ token: string | null; refresh: () => Promise<boolean> }> = {}) {
+    return {
+      accessToken: () => (("token" in over) ? over.token! : "tok"),
+      clientId: () => "cid",
+      refresh: over.refresh ?? (async () => false),
+    };
+  }
+
+  it("signed in with a valid token → real portfolio (no refresh, no demo)", async () => {
+    const refresh = vi.fn(async () => true);
+    const fetchMock = vi.fn(async (_url: string) => real("real-tok"));
+    const pc = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: "tok", refresh }), "v");
+    expect(r!.ad?.adId).toBe("real1");
+    expect(refresh).not.toHaveBeenCalled();          // valid token ⇒ no refresh
+    expect(fetchMock).toHaveBeenCalledTimes(1);
+    expect(fetchMock.mock.calls[0][0]).toContain("/v1/portfolio?"); // not demo
+  });
+
+  it("signed out (no token) → demo straight away, never refreshes", async () => {
+    const refresh = vi.fn(async () => false);
+    const fetchMock = vi.fn(async (_url: string) => demo);
+    const pc = new PortfolioClient("http://b", () => null, fetchMock as never);
+    const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: null, refresh }), "v");
+    expect(r!.ad?.adId).toBe("demo1");
+    expect(refresh).not.toHaveBeenCalled();
+    expect(fetchMock.mock.calls[0][0]).toContain("/v1/portfolio/demo");
+  });
+
+  it("signed in, EMPTY inventory (valid 200) → keep it, NO refresh, NO demo", async () => {
+    // The critical guard: empty inventory must not be mistaken for a dead token.
+    // Refreshing here could clear a valid session on a transient empty poll.
+    const refresh = vi.fn(async () => true);
+    const fetchMock = vi.fn(async (_url: string) => empty);
+    const pc = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: "tok", refresh }), "v");
+    expect(r!.ad).toBeNull();                         // empty portfolio preserved
+    expect(refresh).not.toHaveBeenCalled();           // empty ≠ dead token
+    expect(fetchMock).toHaveBeenCalledTimes(1);       // no demo fetch
+  });
+
+  it("dead token (real fetch hard-fails/401 → null, refresh fails) → demo fallback", async () => {
+    let tok: string | null = "stale";
+    const refresh = vi.fn(async () => { tok = null; return false; });
+    const fetchMock = vi.fn(async (url: string) =>
+      url.includes("/demo") ? demo : fail);          // real fetch → null
+    const pc = new PortfolioClient("http://b", () => tok, fetchMock as never);
+    const auth = { accessToken: () => tok, clientId: () => "cid", refresh };
+    const r = await fetchPortfolioWithDemoFallback(pc, auth, "v");
+    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(r!.ad?.adId).toBe("demo1");               // demo fallback served
+  });
+
+  it("TRANSIENT refresh failure (token kept) → null, NO demo demotion (audit #10)", async () => {
+    // Offline at activation: the real fetch throws, the forced refresh fails
+    // TRANSIENTLY (network, not 401) — post-#10, refresh() keeps the access
+    // token. The fallback must NOT demote the session to demo (demo metrics
+    // credit no user); it serves nothing this tick and the next periodic
+    // refresh retries. Pre-fix: any failed refresh routed straight to demo.
+    const refresh = vi.fn(async () => false);        // token survives (transient)
+    const fetchMock = vi.fn(async (url: string) => {
+      if (url.includes("/demo")) return demo;        // trap: wrong demotion
+      throw new Error("ENOTFOUND");                  // real fetch: offline
+    });
+    const pc = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await fetchPortfolioWithDemoFallback(pc, authStub({ token: "tok", refresh }), "v");
+    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(r).toBeNull();                            // nothing this tick — not demo
+    expect(fetchMock.mock.calls.some(([u]) => String(u).includes("/demo")))
+      .toBe(false);                                  // demo endpoint never hit
+  });
+
+  it("dead-then-revived token (real hard-fails, refresh re-mints) → real portfolio", async () => {
+    let tok: string | null = "stale";
+    const refresh = vi.fn(async () => { tok = "fresh"; return true; });
+    const fetchMock = vi.fn(async (url: string) =>
+      url.includes("/demo") ? demo : (tok === "fresh" ? real("fresh-tok") : fail));
+    const pc = new PortfolioClient("http://b", () => tok, fetchMock as never);
+    const auth = { accessToken: () => tok, clientId: () => "cid", refresh };
+    const r = await fetchPortfolioWithDemoFallback(pc, auth, "v");
+    expect(refresh).toHaveBeenCalledTimes(1);
+    expect(r!.ad?.adId).toBe("real1");               // re-minted ⇒ real ads
+  });
+});
diff --git a/test/reassert.test.ts b/test/reassert.test.ts
new file mode 100644
index 0000000..220c015
--- /dev/null
+++ b/test/reassert.test.ts
@@ -0,0 +1,102 @@
+import { describe, it, expect } from "vitest";
+import { shouldReassert, desyncDecision } from "../src/reassert";
+
+describe("shouldReassert", () => {
+  const healthy = { haveAd: true, killed: false };
+
+  it("reasserts when an ad is in hand and not killed", () => {
+    expect(shouldReassert(healthy)).toBe(true);
+  });
+  it("reasserts while signed out as long as a (demo) ad is in hand", () => {
+    // Sign-in is no longer part of the gate — a signed-out demo ad must
+    // self-heal like the real product. `haveAd` is the only positive signal.
+    expect(shouldReassert({ haveAd: true, killed: false })).toBe(true);
+  });
+  it("does not reassert when there is no ad (signed out with empty demo)", () => {
+    expect(shouldReassert({ ...healthy, haveAd: false })).toBe(false);
+  });
+  it("does not reassert when kill-switched (never fights checkKill)", () => {
+    expect(shouldReassert({ ...healthy, killed: true })).toBe(false);
+  });
+});
+
+describe("desyncDecision (tiered self-heal policy)", () => {
+  // Desynced + CC actively in use + past the grace window: applied ~9.9 min
+  // ago, overlay never rendered since (lastBlockStartAt < lastApplyAt), CC
+  // wrote its transcript 1s ago.
+  const base = {
+    now: 10_000_000,
+    startedAt: 10_000_000 - 600_000,
+    lastApplyAt: 10_000_000 - 590_000,
+    lastBlockStartAt: 10_000_000 - 595_000,
+    ccActivityAgeMs: 1_000,
+    healthy: true,
+    cyclePatchTried: false,
+    reloadTried: false,
+    toastShownAt: 0,
+  };
+
+  it("escalates to cycle first (cheapest heal)", () => {
+    expect(desyncDecision(base).action).toBe("cycle");
+  });
+  it("then reload once a cycle was tried", () => {
+    expect(desyncDecision({ ...base, cyclePatchTried: true }).action).toBe("reload");
+  });
+  it("then a window-reload toast once reload was tried", () => {
+    expect(desyncDecision({ ...base, cyclePatchTried: true, reloadTried: true })
+      .action).toBe("toast");
+  });
+  it("holds the toast on cooldown", () => {
+    const r = desyncDecision({ ...base, cyclePatchTried: true, reloadTried: true,
+      toastShownAt: base.now - 1_000 });
+    expect(r.action).toBe("none");
+    expect(r.reason).toBe("cooldown");
+  });
+
+  // Sub-agent guard: disruptive actions (reload, toast) are deferred while a
+  // CC tool_use turn is in progress. cycle (file-identity nudge) is still safe.
+  it("defers reload while a CC turn is active (sub-agent running)", () => {
+    const r = desyncDecision({ ...base, cyclePatchTried: true, ccTurnActive: true });
+    expect(r.action).toBe("none");
+    expect(r.reason).toBe("cc-turn-active");
+  });
+  it("still fires cycle even while a CC turn is active", () => {
+    // cycle is safe (file touch only); deferral only applies to reload/toast
+    expect(desyncDecision({ ...base, ccTurnActive: true }).action).toBe("cycle");
+  });
+  it("fires reload once the CC turn completes", () => {
+    // cyclePatchTried=true + turn no longer active → reload is now safe
+    const r = desyncDecision({ ...base, cyclePatchTried: true, ccTurnActive: false });
+    expect(r.action).toBe("reload");
+  });
+  it("treats ccTurnActive=null as not active (fires reload normally)", () => {
+    const r = desyncDecision({ ...base, cyclePatchTried: true, ccTurnActive: null });
+    expect(r.action).toBe("reload");
+  });
+
+  it("does NOTHING when CC is idle — the key non-aggression guard", () => {
+    expect(desyncDecision({ ...base, ccActivityAgeMs: null }).reason).toBe("cc-idle");
+    expect(desyncDecision({ ...base, ccActivityAgeMs: null }).action).toBe("none");
+    expect(desyncDecision({ ...base, ccActivityAgeMs: 10 * 60_000 }).reason)
+      .toBe("cc-idle");
+  });
+  it("does nothing when unhealthy (signed-out / no-ad / killed)", () => {
+    expect(desyncDecision({ ...base, healthy: false }).action).toBe("none");
+  });
+  it("does nothing before the first apply", () => {
+    expect(desyncDecision({ ...base, lastApplyAt: 0 }).reason).toBe("no-apply");
+  });
+  it("does nothing while in sync (overlay rendered since apply)", () => {
+    const r = desyncDecision({ ...base, lastBlockStartAt: base.lastApplyAt + 1 });
+    expect(r.action).toBe("none");
+    expect(r.reason).toBe("in-sync");
+  });
+  it("waits out the grace window before escalating", () => {
+    const r = desyncDecision({ ...base,
+      startedAt: base.now - 60_000,
+      lastApplyAt: base.now - 50_000,
+      lastBlockStartAt: base.now - 60_000 });
+    expect(r.action).toBe("none");
+    expect(r.reason).toBe("within-grace");
+  });
+});
diff --git a/test/registry.test.ts b/test/registry.test.ts
new file mode 100644
index 0000000..4397963
--- /dev/null
+++ b/test/registry.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { discover } from "../src/adapters/registry";
+
+function ccTarget(): string {
+  const d = mkdtempSync(join(tmpdir(), "cc-"));
+  const p = join(d, "index.js");
+  writeFileSync(p, 'var a=["Discombobulating","x"];', "utf8");
+  return p;
+}
+function codexTarget(): string {
+  const r = mkdtempSync(join(tmpdir(), "cx-"));
+  const dir = join(r, "openai.chatgpt-26.0.0", "webview", "assets");
+  mkdirSync(dir, { recursive: true });
+  const p = join(dir, "thinking-shimmer-x.js");
+  writeFileSync(p,
+    "function v(e){};export{v as n,g as t};var q=(0,d.jsx);defaultMessage:`Thinking`",
+    "utf8");
+  return p;
+}
+
+const ENV = { ...process.env };
+afterEach(() => { process.env = { ...ENV }; });
+
+describe("registry.discover", () => {
+  it("neither present -> empty", () => {
+    process.env.VIBE_ADS_CC_TARGET = "/nope";
+    process.env.VIBE_ADS_CODEX_TARGET = "/nope";
+    expect(discover().length).toBe(0);
+  });
+  it("CC only", () => {
+    process.env.VIBE_ADS_CC_TARGET = ccTarget();
+    process.env.VIBE_ADS_CODEX_TARGET = "/nope";
+    expect(discover().map((x) => x.id)).toEqual(["claude-code"]);
+  });
+  it("Codex only", () => {
+    process.env.VIBE_ADS_CC_TARGET = "/nope";
+    process.env.VIBE_ADS_CODEX_TARGET = codexTarget();
+    expect(discover().map((x) => x.id)).toEqual(["codex"]);
+  });
+  it("both -> claude-code first (primary precedence)", () => {
+    process.env.VIBE_ADS_CC_TARGET = ccTarget();
+    process.env.VIBE_ADS_CODEX_TARGET = codexTarget();
+    expect(discover().map((x) => x.id)).toEqual(["claude-code", "codex"]);
+  });
+  it("adapters are constructed and typed", () => {
+    process.env.VIBE_ADS_CC_TARGET = ccTarget();
+    process.env.VIBE_ADS_CODEX_TARGET = codexTarget();
+    const d = discover();
+    expect(d.find((x) => x.id === "codex")!.adapter.name).toBe("codex");
+    expect(d.find((x) => x.id === "claude-code")!.adapter.name)
+      .toBe("claude-code");
+  });
+});
diff --git a/test/reloadNudge.test.ts b/test/reloadNudge.test.ts
new file mode 100644
index 0000000..0e7bffb
--- /dev/null
+++ b/test/reloadNudge.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { window, commands } from "./mocks/vscode";
+import { showInstallReloadNudge, showSignInReloadNudge }
+  from "../src/activation/reloadNudge";
+
+vi.mock("../src/log", () => ({
+  dlog: vi.fn(),
+}));
+
+afterEach(() => { vi.restoreAllMocks(); });
+
+type ToastArgs = { msg: string; opts?: { modal?: boolean; detail?: string };
+                  buttons: string[] };
+
+function spyToast(answer: string | undefined): { calls: ToastArgs[] } {
+  const calls: ToastArgs[] = [];
+  vi.spyOn(window, "showInformationMessage").mockImplementation(
+    async (msg: unknown, ...rest: unknown[]) => {
+      const opts = (rest[0] && typeof rest[0] === "object")
+        ? rest[0] as ToastArgs["opts"] : undefined;
+      const buttons = (opts ? rest.slice(1) : rest) as string[];
+      calls.push({ msg: String(msg), opts, buttons });
+      return answer as never;
+    });
+  return { calls };
+}
+
+describe("install reload nudge", () => {
+  it("is modal, says earnings need a reload, and Reload Now reloads the window", async () => {
+    const t = spyToast("Reload Now");
+    const exec = vi.spyOn(commands, "executeCommand");
+    await showInstallReloadNudge();
+    expect(t.calls).toHaveLength(1);
+    expect(t.calls[0].msg).toMatch(/won't earn money until you reload/i);
+    expect(t.calls[0].opts?.modal).toBe(true);
+    expect(t.calls[0].buttons).toContain("Reload Now");
+    expect(exec).toHaveBeenCalledWith("workbench.action.reloadWindow");
+  });
+
+  it("dismissing does not reload", async () => {
+    spyToast(undefined);
+    const exec = vi.spyOn(commands, "executeCommand");
+    await showInstallReloadNudge();
+    expect(exec).not.toHaveBeenCalled();
+  });
+});
+
+describe("sign-in reload nudge", () => {
+  it("is modal, says reload to start collecting money, and Reload Now reloads", async () => {
+    const t = spyToast("Reload Now");
+    const exec = vi.spyOn(commands, "executeCommand");
+    await showSignInReloadNudge();
+    expect(t.calls).toHaveLength(1);
+    expect(t.calls[0].msg).toMatch(/reload to start collecting money/i);
+    expect(t.calls[0].opts?.modal).toBe(true);
+    expect(t.calls[0].buttons).toContain("Reload Now");
+    expect(exec).toHaveBeenCalledWith("workbench.action.reloadWindow");
+  });
+
+  it("dismissing does not reload", async () => {
+    spyToast(undefined);
+    const exec = vi.spyOn(commands, "executeCommand");
+    await showSignInReloadNudge();
+    expect(exec).not.toHaveBeenCalled();
+  });
+});
diff --git a/test/reloadSignal.test.ts b/test/reloadSignal.test.ts
new file mode 100644
index 0000000..4963bab
--- /dev/null
+++ b/test/reloadSignal.test.ts
@@ -0,0 +1,41 @@
+import { describe, it, expect } from "vitest";
+import { join } from "node:path";
+import { homedir } from "node:os";
+import { reloadSentinelPath, parseSentinel, decideReload } from "../src/reloadSignal";
+
+describe("reloadSignal", () => {
+  it("sentinel path is ~/.vibe-ads/reload", () => {
+    expect(reloadSentinelPath()).toBe(join(homedir(), ".vibe-ads", "reload"));
+  });
+
+  it("parses a valid payload", () => {
+    expect(parseSentinel('{"version":"0.3.7","ts":"2026-05-17T00:00:00Z"}'))
+      .toEqual({ version: "0.3.7", ts: "2026-05-17T00:00:00Z" });
+  });
+
+  it("returns null for corrupt / half-written / missing-version input", () => {
+    expect(parseSentinel("{not json")).toBeNull();
+    expect(parseSentinel("")).toBeNull();
+    expect(parseSentinel('{"ts":"x"}')).toBeNull();
+  });
+
+  it("decideReload: newer mtime + differing version + debug -> reload-now", () => {
+    expect(decideReload({ mtimeMs: 200, armedAt: 100, sentinelVersion: "0.3.7",
+      runningVersion: "0.3.6", debug: true })).toBe("reload-now");
+  });
+
+  it("decideReload: newer mtime + differing version + non-debug -> nudge", () => {
+    expect(decideReload({ mtimeMs: 200, armedAt: 100, sentinelVersion: "0.3.7",
+      runningVersion: "0.3.6", debug: false })).toBe("nudge");
+  });
+
+  it("decideReload: stale mtime -> none", () => {
+    expect(decideReload({ mtimeMs: 50, armedAt: 100, sentinelVersion: "0.3.7",
+      runningVersion: "0.3.6", debug: false })).toBe("none");
+  });
+
+  it("decideReload: same version -> none (no-op deploy / re-activation)", () => {
+    expect(decideReload({ mtimeMs: 200, armedAt: 100, sentinelVersion: "0.3.6",
+      runningVersion: "0.3.6", debug: true })).toBe("none");
+  });
+});
diff --git a/test/rotation.test.ts b/test/rotation.test.ts
new file mode 100644
index 0000000..39293d4
--- /dev/null
+++ b/test/rotation.test.ts
@@ -0,0 +1,409 @@
+import { describe, it, expect, afterEach, vi } from "vitest";
+import { Loopback, type LoopbackAdPayload } from "../src/loopback";
+import { PortfolioClient, type PatchAd,
+  type PortfolioResponse } from "../src/portfolio/client";
+import { setupAdRotation, clearAdRotationOnSignOut,
+  type AdRotationDeps } from "../src/activation/adRotation";
+
+const AD_LINEAR: PatchAd = {
+  adId: "ad-linear", campaignId: "c-linear",
+  adText: "Linear -- plan, build, ship", iconRef: "icon.linear",
+  iconUrl: "https://icons.test/linear.png", clickUrl: "https://linear.app",
+  bannerEnabled: false, sessionToken: "tok-linear",
+};
+
+const AD_RAILWAY: PatchAd = {
+  adId: "ad-railway", campaignId: "c-railway",
+  adText: "Railway -- deploy in seconds", iconRef: "icon.railway",
+  iconUrl: "https://icons.test/railway.png", clickUrl: "https://railway.app",
+  bannerEnabled: false, sessionToken: "tok-railway",
+};
+
+const AD_WARP: PatchAd = {
+  adId: "ad-warp", campaignId: "c-warp",
+  adText: "Warp -- the terminal reimagined", iconRef: "icon.warp",
+  iconUrl: "https://icons.test/warp.png", clickUrl: "https://warp.dev",
+  bannerEnabled: false, sessionToken: "tok-warp",
+};
+
+let lb: Loopback | null = null;
+afterEach(async () => { if (lb) { await lb.stop(); lb = null; } });
+
+describe("rotation → loopback /ad contract", () => {
+  it("getCurrentAd reflects the most recent ad written to it", async () => {
+    let currentAd: LoopbackAdPayload | null = {
+      adId: AD_LINEAR.adId, adText: AD_LINEAR.adText,
+      clickUrl: AD_LINEAR.clickUrl, iconUrl: AD_LINEAR.iconUrl,
+      campaignId: AD_LINEAR.campaignId,
+    };
+    lb = new Loopback({
+      onEvent: () => {}, onClick: () => {},
+      getActivity: () => ({}),
+      getCurrentAd: () => currentAd,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+
+    // Initial state: Linear
+    let r = await (await fetch(`${base}/ad`)).json();
+    expect(r.adId).toBe("ad-linear");
+    expect(r.adText).toBe("Linear -- plan, build, ship");
+
+    // Simulate rotation: update the closure variable (mirrors applyAd writing activeAd)
+    currentAd = {
+      adId: AD_RAILWAY.adId, adText: AD_RAILWAY.adText,
+      clickUrl: AD_RAILWAY.clickUrl, iconUrl: AD_RAILWAY.iconUrl,
+      campaignId: AD_RAILWAY.campaignId,
+    };
+
+    // Loopback must now serve the rotated ad
+    r = await (await fetch(`${base}/ad`)).json();
+    expect(r.adId).toBe("ad-railway");
+    expect(r.adText).toBe("Railway -- deploy in seconds");
+    expect(r.clickUrl).toBe("https://railway.app");
+  });
+
+  it("getCurrentAd serves empty object when ad is null (no-ad state)", async () => {
+    lb = new Loopback({
+      onEvent: () => {}, onClick: () => {},
+      getActivity: () => ({}),
+      getCurrentAd: () => null,
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+    const r = await (await fetch(`${base}/ad`)).json();
+    expect(r).toEqual({});
+  });
+
+  it("multiple rapid rotations always serve the latest ad", async () => {
+    const ads = [AD_LINEAR, AD_RAILWAY, AD_WARP];
+    let idx = 0;
+    lb = new Loopback({
+      onEvent: () => {}, onClick: () => {},
+      getActivity: () => ({}),
+      getCurrentAd: () => {
+        const a = ads[idx];
+        return { adId: a.adId, adText: a.adText,
+          clickUrl: a.clickUrl, iconUrl: a.iconUrl,
+          campaignId: a.campaignId };
+      },
+    });
+    const { port, token } = await lb.start();
+    const base = `http://127.0.0.1:${port}/vibe-ads/${token}`;
+
+    for (let i = 0; i < ads.length; i++) {
+      idx = i;
+      const r = await (await fetch(`${base}/ad`)).json();
+      expect(r.adId).toBe(ads[i].adId);
+      expect(r.adText).toBe(ads[i].adText);
+    }
+  });
+});
+
+describe("PortfolioClient rotation fields", () => {
+  it("parses rotation_interval_seconds into rotationIntervalMs", async () => {
+    const fetchMock = vi.fn(async () => ({
+      ok: true, status: 200,
+      json: async () => ({
+        ttl_seconds: 60,
+        rotation_interval_seconds: 45,
+        queue_id: "q123",
+        ads: [
+          { ad_id: "a1", campaign_id: "c1", title_text: "Ad one text here ok",
+            icon_ref: "i", click_url: "https://one.test" },
+          { ad_id: "a2", campaign_id: "c2", title_text: "Ad two text here ok",
+            icon_ref: "i", click_url: "https://two.test" },
+        ],
+      }),
+    }) as unknown as Response);
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await c.fetchPortfolio("2.1.143");
+    expect(r).not.toBeNull();
+    expect(r!.rotationIntervalMs).toBe(45_000);
+    expect(r!.ads).toHaveLength(2);
+    expect(r!.queueId).toBe("q123");
+  });
+
+  it("defaults rotationIntervalMs to 120s when server omits the field", async () => {
+    const fetchMock = vi.fn(async () => ({
+      ok: true, status: 200,
+      json: async () => ({
+        ttl_seconds: 60,
+        ads: [{ ad_id: "a1", campaign_id: "c1", title_text: "x".repeat(30),
+                icon_ref: "i", click_url: "https://x.test" }],
+      }),
+    }) as unknown as Response);
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await c.fetchPortfolio("2.1.143");
+    expect(r!.rotationIntervalMs).toBe(120_000);
+  });
+
+  it("returns multiple ads in queue order for client-side rotation", async () => {
+    const fetchMock = vi.fn(async () => ({
+      ok: true, status: 200,
+      json: async () => ({
+        ttl_seconds: 60,
+        rotation_interval_seconds: 60,
+        ads: [
+          { ad_id: "first", campaign_id: "c1", title_text: "First ad visible here",
+            icon_ref: "i", click_url: "https://first.test" },
+          { ad_id: "second", campaign_id: "c2", title_text: "Second ad visible here",
+            icon_ref: "i", click_url: "https://second.test" },
+          { ad_id: "third", campaign_id: "c3", title_text: "Third ad visible here",
+            icon_ref: "i", click_url: "https://third.test" },
+        ],
+      }),
+    }) as unknown as Response);
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await c.fetchPortfolio("2.1.143");
+    expect(r!.ads.map((a) => a.adId)).toEqual(["first", "second", "third"]);
+    expect(r!.ad?.adId).toBe("first");
+  });
+
+  it("parses balances alongside rotation fields (W4 full shape)", async () => {
+    const fetchMock = vi.fn(async () => ({
+      ok: true, status: 200,
+      json: async () => ({
+        ttl_seconds: 60,
+        rotation_interval_seconds: 60,
+        view_threshold_seconds: 15,
+        queue_id: "qABC",
+        balances: { lifetime_usd: "12.34", today_usd: "1.50",
+                    last_updated_ms: 1700000000000 },
+        ads: [{ ad_id: "a1", campaign_id: "c1", title_text: "x".repeat(30),
+                icon_ref: "i", click_url: "https://x.test",
+                session_token: "sess1" }],
+      }),
+    }) as unknown as Response);
+    const c = new PortfolioClient("http://b", () => "tok", fetchMock as never);
+    const r = await c.fetchPortfolio("2.1.143");
+    expect(r!.balances).toEqual({
+      lifetimeUsd: "12.34", todayUsd: "1.50", lastUpdatedMs: 1700000000000,
+    });
+    expect(r!.viewThresholdMs).toBe(15_000);
+    expect(r!.ads[0].sessionToken).toBe("sess1");
+  });
+});
+
+describe("rotation index cycling", () => {
+  it("rotateNext cycles through ads in order and wraps around", () => {
+    const ads = [AD_LINEAR, AD_RAILWAY, AD_WARP];
+    let rotationIdx = 0;
+    const applied: string[] = [];
+
+    function rotateNext() {
+      if (ads.length < 2) return;
+      rotationIdx = (rotationIdx + 1) % ads.length;
+      applied.push(ads[rotationIdx].adId);
+    }
+
+    rotateNext(); // -> idx 1 (Railway)
+    rotateNext(); // -> idx 2 (Warp)
+    rotateNext(); // -> idx 0 (Linear, wrap)
+    rotateNext(); // -> idx 1 (Railway)
+
+    expect(applied).toEqual([
+      "ad-railway", "ad-warp", "ad-linear", "ad-railway",
+    ]);
+  });
+
+  it("rotateNext is a no-op with a single ad", () => {
+    const ads = [AD_LINEAR];
+    let rotationIdx = 0;
+    let called = false;
+
+    function rotateNext() {
+      if (ads.length < 2) return;
+      rotationIdx = (rotationIdx + 1) % ads.length;
+      called = true;
+    }
+
+    rotateNext();
+    expect(called).toBe(false);
+    expect(rotationIdx).toBe(0);
+  });
+
+  it("refreshPortfolio resets rotation index to 0", () => {
+    let rotationIdx = 2;
+    const newAds = [AD_WARP, AD_LINEAR];
+
+    // Simulates the refresh logic
+    rotationIdx = 0;
+    const firstAd = newAds[rotationIdx];
+
+    expect(rotationIdx).toBe(0);
+    expect(firstAd.adId).toBe("ad-warp");
+  });
+});
+
+// ---------------------------------------------------------------------------
+// Real setupAdRotation wiring: sign-out clear (audit #20) and the refresh
+// epoch that discards stale in-flight portfolio responses (audit #32).
+// ---------------------------------------------------------------------------
+function mkResp(ads: PatchAd[]): PortfolioResponse {
+  return { ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
+    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null };
+}
+
+function mkRotationDeps(opts: {
+  token?: () => string | null;
+  fetchPortfolio?: () => Promise<PortfolioResponse | null>;
+  fetchDemoPortfolio?: () => Promise<PortfolioResponse | null>;
+  initialAd?: PatchAd;
+} = {}) {
+  const timers: NodeJS.Timeout[] = [];
+  const adRef = { current: (opts.initialAd ?? null) as PatchAd | null };
+  const activeAdRef = { current: (opts.initialAd ?? null) as PatchAd | null };
+  const applyPatch = vi.fn(() => ({ ok: true }));
+  const sessionSet = vi.fn();
+  const deps = {
+    adapter: { applyPatch, isPatched: () => true,
+               preflight: () => ({ compatible: true }), restore: () => {} },
+    portfolio: { fetchPortfolio: opts.fetchPortfolio ?? (async () => null),
+                 fetchDemoPortfolio: opts.fetchDemoPortfolio ?? (async () => null) },
+    auth: { accessToken: opts.token ?? (() => "tok"), clientId: () => "cid" },
+    debugCtl: { setPortfolioAd: vi.fn() },
+    session: { set: sessionSet },
+    ccVersion: "2.1.167",
+    port: 12345,
+    patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
+    activeAdRef,
+    corrRef: { current: "corr" },
+    adRef,
+    impDedupe: { reset: vi.fn() },
+    reapplyCodex: null,
+    timers,
+  } as unknown as AdRotationDeps;
+  return { deps, timers, adRef, activeAdRef, applyPatch, sessionSet };
+}
+
+describe("sign-out clear (audit #20)", () => {
+  it("clear() empties the queue, nulls the shared ad refs, and disarms rotation", () => {
+    const { deps, timers, adRef, activeAdRef, sessionSet } =
+      mkRotationDeps({ initialAd: AD_LINEAR });
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_LINEAR, AD_RAILWAY]));
+      expect(handle.rotationTimer).not.toBeNull(); // 2 ads ⇒ rotation armed
+
+      handle.clear();
+
+      expect(handle.adQueue).toEqual([]);
+      expect(handle.rotationTimer).toBeNull();
+      expect(adRef.current).toBeNull();
+      expect(activeAdRef.current).toBeNull();
+      expect(sessionSet).toHaveBeenCalledWith({ hasAd: false });
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+
+  it("rotation never re-patches CC after clear() (the sign-out re-patch bug)", () => {
+    vi.useFakeTimers();
+    const { deps, timers, applyPatch } = mkRotationDeps({
+      initialAd: AD_LINEAR, token: () => null });
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_LINEAR, AD_RAILWAY]));
+      handle.clear();
+      applyPatch.mockClear();
+      // Pre-fix the still-armed 120s rotation timer applied the leftover REAL
+      // ad — re-patching CC right after doSignOut had restored it.
+      vi.advanceTimersByTime(10 * 120_000);
+      expect(applyPatch).not.toHaveBeenCalled();
+    } finally {
+      timers.forEach((t) => clearInterval(t));
+      vi.useRealTimers();
+    }
+  });
+
+  it("clearAdRotationOnSignOut() reaches the live rotation (module hook)", () => {
+    const { deps, timers, adRef } = mkRotationDeps({ initialAd: AD_LINEAR });
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
+      clearAdRotationOnSignOut();
+      expect(handle.adQueue).toEqual([]);
+      expect(adRef.current).toBeNull();
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+
+  it("clear() discards an in-flight refresh — a late response can't resurrect ads", async () => {
+    let resolveReal!: (r: PortfolioResponse | null) => void;
+    const pending = new Promise<PortfolioResponse | null>((res) => { resolveReal = res; });
+    const { deps, timers, adRef } = mkRotationDeps({
+      initialAd: AD_LINEAR, token: () => "tok", fetchPortfolio: () => pending });
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
+      const inflight = handle.refreshNow(false); // fetch in flight…
+      handle.clear();                            // …sign-out lands mid-flight
+      resolveReal(mkResp([AD_RAILWAY]));         // stale response arrives late
+      await inflight;
+      expect(adRef.current).toBeNull();
+      expect(handle.adQueue).toEqual([]);
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+
+  it("the next (demo) portfolio apply re-populates the queue and re-arms rotation", async () => {
+    let token: string | null = "tok";
+    const { deps, timers, adRef } = mkRotationDeps({
+      initialAd: AD_LINEAR, token: () => token,
+      fetchDemoPortfolio: async () => mkResp([AD_RAILWAY, AD_WARP]) });
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
+      handle.clear();
+      token = null;                   // signed out now
+      await handle.refreshNow(false); // the 60s refresh-timer path
+      expect(adRef.current?.adId).toBe("ad-railway");
+      expect(handle.adQueue).toHaveLength(2);
+      expect(handle.rotationTimer).not.toBeNull();
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+});
+
+describe("stale in-flight refresh epoch (audit #32)", () => {
+  it("a DEMO fetch resolving after the forced sign-in swap is discarded", async () => {
+    let token: string | null = null;
+    let resolveDemo!: (r: PortfolioResponse | null) => void;
+    const demoPending = new Promise<PortfolioResponse | null>(
+      (res) => { resolveDemo = res; });
+    const { deps, timers, adRef, activeAdRef } = mkRotationDeps({
+      initialAd: AD_LINEAR, token: () => token,
+      fetchDemoPortfolio: () => demoPending,
+      fetchPortfolio: async () => mkResp([AD_WARP]) });
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_LINEAR]));
+      const inflight = handle.refreshNow(false); // signed-out demo fetch (slow)
+      token = "tok";                             // sign-in completes mid-flight
+      await handle.refreshNow(true);             // forced real-ad swap
+      expect(adRef.current?.adId).toBe("ad-warp");
+
+      resolveDemo(mkResp([AD_RAILWAY]));         // stale demo response lands
+      await inflight;
+
+      // Pre-fix the late demo response force-applied AD_RAILWAY (demo ads +
+      // demo tokens) over the just-applied real ad on a signed-in client.
+      expect(adRef.current?.adId).toBe("ad-warp");
+      expect(activeAdRef.current?.adId).toBe("ad-warp");
+      expect(handle.lastAdSetSig).toBe("ad-warp");
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+});
+
+describe("ad change detection (adChanged guard)", () => {
+  it("detects change when adId differs", () => {
+    let currentAdId = "ad-linear";
+    const next = AD_RAILWAY;
+    const adChanged = next.adId !== currentAdId;
+    expect(adChanged).toBe(true);
+  });
+
+  it("skips update when same ad re-applied", () => {
+    let currentAdId = "ad-linear";
+    const next = AD_LINEAR;
+    const adChanged = next.adId !== currentAdId;
+    expect(adChanged).toBe(false);
+  });
+
+  it("handles null current ad gracefully", () => {
+    let currentAdId: string | undefined = undefined;
+    const next = AD_LINEAR;
+    const adChanged = next.adId !== currentAdId;
+    expect(adChanged).toBe(true);
+  });
+});
diff --git a/test/selfUpdateWiring.test.ts b/test/selfUpdateWiring.test.ts
new file mode 100644
index 0000000..1b5c868
--- /dev/null
+++ b/test/selfUpdateWiring.test.ts
@@ -0,0 +1,107 @@
+// audit-2026-06-09 #38 regression: setupSelfUpdate must wire UpdateClient
+// with the timeout-wrapped fetch (timeoutFetch(120000)), not bare global
+// fetch — a black-holed manifest/VSIX connection otherwise hangs checkOnce
+// forever (and, with the #31 single-flight guard, silently wedges every
+// later 90s poll behind the stuck one). Pin: every request the updater
+// makes carries an AbortSignal.
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { createHash } from "node:crypto";
+import { setupSelfUpdate } from "../src/activation/selfUpdate";
+import { makeContext, _shown } from "./mocks/vscode";
+
+afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
+
+describe("setupSelfUpdate fetch wiring (audit #38)", () => {
+  it("manifest polls carry an abort signal (timeoutFetch, not bare fetch)", async () => {
+    const inits: (RequestInit | undefined)[] = [];
+    vi.stubGlobal("fetch", vi.fn(async (_url: unknown, init?: RequestInit) => {
+      inits.push(init);
+      // Not-newer version -> checkOnce stops after the manifest fetch.
+      return { ok: true, json: async () => ({ version: "0.0.0",
+        sha256: "x", url: "http://b/x.vsix" }) } as Response;
+    }));
+    const timers: NodeJS.Timeout[] = [];
+    const watchFileFn =
+      (() => {}) as unknown as typeof import("node:fs").watchFile;
+    try {
+      const { updater } = setupSelfUpdate(
+        makeContext() as never, "http://b", "0.1.0", undefined, 0,
+        watchFileFn, timers, 60_000);
+      expect(await updater.checkOnce()).toBe(false);
+      expect(inits).toHaveLength(1);
+      // Pre-fix: bare global fetch was called with NO init at all.
+      expect(inits[0]?.signal).toBeInstanceOf(AbortSignal);
+    } finally {
+      for (const t of timers) clearInterval(t);
+    }
+  });
+});
+
+// trey-nag-loop 2026-06-11: a user who dismissed the reload toast got the
+// SAME artifact re-downloaded, re-installed and re-toasted every ~31 min
+// (attempted-ring cooldown expiry), 20+ times over 10 h. A successful
+// install is now recorded in globalState and suppressed without any
+// cooldown; the record is cleared at activation only when the running
+// build proves the install never converged.
+describe("self-update success record (nag-loop relax)", () => {
+  const bytes = Buffer.alloc(12 * 1024, 0x42);
+  const sha = createHash("sha256").update(bytes).digest("hex");
+  const watchFileFn =
+    (() => {}) as unknown as typeof import("node:fs").watchFile;
+
+  function stubManifestFetch() {
+    vi.stubGlobal("fetch", vi.fn(async (url: unknown) =>
+      String(url).endsWith("/v1/ext/manifest")
+        ? ({ ok: true, json: async () => ({ version: "9.9.9", sha256: sha,
+            url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+          } as Response)));
+  }
+
+  it("does NOT reinstall or re-toast the same artifact after the 30-min cooldown expires", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(1_000_000_000);
+    stubManifestFetch();
+    const ctx = makeContext();
+    const timers: NodeJS.Timeout[] = [];
+    try {
+      const { updater } = setupSelfUpdate(
+        ctx as never, "http://b", "0.1.0", undefined, 0,
+        watchFileFn, timers, 60_000);
+      expect(await updater.checkOnce()).toBe(true);   // installs once
+      const toastsAfterInstall = _shown.length;
+
+      // 31 min later: attempted-ring cooldown has expired. Pre-fix this
+      // re-downloaded + re-installed + re-toasted the identical artifact.
+      vi.setSystemTime(1_000_000_000 + 31 * 60 * 1000);
+      expect(await updater.checkOnce()).toBe(false);
+      expect(_shown.length).toBe(toastsAfterInstall); // no new toast
+    } finally {
+      for (const t of timers) clearInterval(t);
+    }
+  });
+
+  it("clears a not-converged record at activation so the install retries once per reload", async () => {
+    vi.useFakeTimers();
+    vi.setSystemTime(2_000_000_000);
+    stubManifestFetch();
+    const ctx = makeContext();
+    const timers: NodeJS.Timeout[] = [];
+    try {
+      const a = setupSelfUpdate(ctx as never, "http://b", "0.1.0", undefined, 0,
+        watchFileFn, timers, 60_000);
+      expect(await a.updater.checkOnce()).toBe(true);  // install 9.9.9, record it
+
+      // Window reloads but is STILL running 0.1.0 -> the install never
+      // converged. Activation must clear the record (past the attempted
+      // cooldown, so the retry isn't fenced by the ring either).
+      vi.setSystemTime(2_000_000_000 + 31 * 60 * 1000);
+      const b = setupSelfUpdate(ctx as never, "http://b", "0.1.0", undefined, 0,
+        watchFileFn, timers, 60_000);
+      expect(await b.updater.checkOnce()).toBe(true);  // one fresh attempt
+    } finally {
+      for (const t of timers) clearInterval(t);
+    }
+  });
+});
diff --git a/test/servingGate.test.ts b/test/servingGate.test.ts
new file mode 100644
index 0000000..3e472dd
--- /dev/null
+++ b/test/servingGate.test.ts
@@ -0,0 +1,600 @@
+/** Wave 2 serving-gate matrix (audit 2026-06-09 findings #3/#4/#6/#9/#14/#19).
+ *
+ *  One systemic bug: multiple independent timers wrote ad patches without
+ *  consulting the global gates, so a killed or user-disabled install
+ *  oscillated restore→re-patch forever. This suite parameterizes over the
+ *  full mode matrix
+ *
+ *      {confirmed-kill, offline-unsure, healthy}
+ *    × {K_ON on / off (deliberate disable)}
+ *    × {crash-canary suspended / not}
+ *
+ *  for every writer — debug reassert tick, rotation tick, the 60s rotation
+ *  refresh, the cliSync tick, bootCanary's boot-path writers, the webview
+ *  reassert tick, cycleReassert, and the live loopback /ad route — and pins:
+ *    • writes happen ONLY in (healthy, enabled, not-suspended);
+ *    • restore happens ONLY on confirmed kill / deliberate disable;
+ *    • offline-unsure FREEZES (no restore, no write);
+ *    • /ad stops serving on confirmed kill / disable (webviews drop the
+ *      overlay within one poll) but keeps serving through the offline freeze;
+ *    • showActive never paints the green earning bar while gated;
+ *    • the crash canary suspends the whole session until a manual re-enable.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync }
+  from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
+
+// ── Hermetic module mocks (the cliSync leg must never touch the real
+//    ~/.claude/settings.json, and the modes sentinels must read "on") ──────
+const { cliApply, cliRestore } = vi.hoisted(() => ({
+  cliApply: vi.fn(() => ({ ok: true })),
+  cliRestore: vi.fn(() => ({ ok: true, restored: true })),
+}));
+vi.mock("../src/adapters/claude-cli/adapter", () => ({
+  ClaudeCliStatuslineAdapter: class {
+    spinnerVerbsSupported = true;
+    constructor(_p: string) { /* hermetic stand-in */ }
+    preflight() { return { ok: true, compatible: true, version: "cli" }; }
+    applyPatch() { return cliApply(); }
+    restore() { return cliRestore(); }
+  },
+}));
+vi.mock("../src/adapters/claude-cli/cliVersion", () => ({
+  // Never resolves: the detection re-sync path stays out of these tests.
+  detectClaudeCliSpinnerSupport: () => new Promise(() => { /* pending */ }),
+}));
+vi.mock("../src/adapters/claude-cli/cliAd", () => ({
+  writeCliAdCache: () => {},
+  cliSessionActive: () => false,
+  shouldCountCliImpression: () => false,
+  shouldCountSpinnerImpression: () => false,
+  FRESH_MS: 600_000,
+}));
+vi.mock("../src/modes", () => ({
+  webviewMode: () => "on",
+  cliMode: () => "on",
+  bannerOverride: () => "server",
+  setWebviewMode: () => {},
+  setCliMode: () => {},
+  setBannerOverride: () => {},
+}));
+
+import { resetServingGate, wireServingGateEnabled, setKillPosture,
+  suspendServing, clearServingSuspension, servingSuspended,
+  canPatch, canServeAds, servingVerdict } from "../src/servingGate";
+import { setupAdRotation, type AdRotationDeps }
+  from "../src/activation/adRotation";
+import { setupCliSync } from "../src/activation/cliSync";
+import { setupWebviewInjection } from "../src/activation/webviewInjection";
+import { setupBootCanary } from "../src/activation/bootCanary";
+import { setupEarningsRefresh } from "../src/activation/earningsRefresh";
+import { DebugController } from "../src/debug";
+import { createActivationContext, type ActivationContext }
+  from "../src/activation/context";
+import { SessionState } from "../src/sessionState";
+import { EarningsClient } from "../src/earnings/client";
+import type { AuthClient } from "../src/auth/client";
+import { makeContext } from "./mocks/vscode";
+import type { PatchAd, PortfolioResponse } from "../src/portfolio/client";
+
+// ── The mode matrix ────────────────────────────────────────────────────────
+interface Mode {
+  kill: "clear" | "confirmed" | "offline";
+  kOn: boolean;
+  suspended: boolean;
+}
+const MODES: Mode[] = [];
+for (const kill of ["clear", "confirmed", "offline"] as const)
+  for (const kOn of [true, false])
+    for (const suspended of [false, true])
+      MODES.push({ kill, kOn, suspended });
+
+/** Writes happen ONLY here. */
+const writeAllowed = (m: Mode): boolean =>
+  m.kill === "clear" && m.kOn && !m.suspended;
+/** Restore happens ONLY on confirmed kill / deliberate disable. */
+const restoreExpected = (m: Mode): boolean =>
+  m.kill === "confirmed" || !m.kOn;
+/** /ad serving stops ONLY on confirmed kill / disable (freeze keeps it). */
+const serveAllowed = (m: Mode): boolean =>
+  m.kill !== "confirmed" && m.kOn;
+
+function applyMode(m: Mode): void {
+  setKillPosture(m.kill);
+  wireServingGateEnabled(() => m.kOn);
+  if (m.suspended) suspendServing(); else clearServingSuspension();
+}
+
+beforeEach(() => { resetServingGate(); });
+
+// ── Shared fixtures ────────────────────────────────────────────────────────
+const AD_A: PatchAd = {
+  adId: "ad-a", campaignId: "c-a", adText: "Linear -- plan, build, ship",
+  iconRef: "icon.a", iconUrl: "", clickUrl: "https://a.test",
+  bannerEnabled: false, sessionToken: "tok-a",
+};
+const AD_B: PatchAd = {
+  adId: "ad-b", campaignId: "c-b", adText: "Railway -- deploy in seconds",
+  iconRef: "icon.b", iconUrl: "", clickUrl: "https://b.test",
+  bannerEnabled: false, sessionToken: "tok-b",
+};
+function mkResp(ads: PatchAd[]): PortfolioResponse {
+  return { ad: ads[0] ?? null, ads, queueId: "q", ttlMs: 60_000,
+    rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null };
+}
+
+const CANARY = join(homedir(), ".vibe-ads", "boot.canary");
+const clearCanary = (): void => {
+  try { rmSync(CANARY, { force: true }); } catch { /* best-effort */ }
+};
+
+const mkCcAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  isPatched: vi.fn(() => false),       // always "drifted" so reasserts fire
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+// ── Pure gate semantics ────────────────────────────────────────────────────
+describe("servingGate verdicts", () => {
+  it.each(MODES)("verdict matrix kill=$kill kOn=$kOn susp=$suspended", (m) => {
+    applyMode(m);
+    expect(canPatch()).toBe(writeAllowed(m));
+    expect(servingVerdict() === "restore").toBe(restoreExpected(m));
+    expect(canServeAds()).toBe(serveAllowed(m));
+  });
+
+  it("resetServingGate returns to healthy defaults", () => {
+    applyMode({ kill: "confirmed", kOn: false, suspended: true });
+    resetServingGate();
+    expect(canPatch()).toBe(true);
+    expect(servingVerdict()).toBe("write");
+    expect(servingSuspended()).toBe(false);
+  });
+});
+
+// ── Writer: rotation 60s refresh apply ────────────────────────────────────
+function mkRotation(fetchResp: PortfolioResponse | null) {
+  const timers: NodeJS.Timeout[] = [];
+  const applyPatch = vi.fn(() => ({ ok: true }));
+  const adRef = { current: AD_A as PatchAd | null };
+  const deps = {
+    adapter: { applyPatch, isPatched: () => true,
+               preflight: () => ({ compatible: true }), restore: () => {} },
+    portfolio: { fetchPortfolio: async () => fetchResp,
+                 fetchDemoPortfolio: async () => fetchResp },
+    auth: { accessToken: () => "tok", clientId: () => "cid" },
+    debugCtl: { setPortfolioAd: vi.fn() },
+    session: { set: vi.fn() },
+    ccVersion: "2.1.167",
+    port: 12345,
+    patchParams: { adText: "", iconRef: "", iconUrl: "", clickUrl: "" },
+    activeAdRef: { current: AD_A },
+    corrRef: { current: "corr" },
+    adRef,
+    impDedupe: { reset: vi.fn() },
+    reapplyCodex: null,
+    timers,
+  } as unknown as AdRotationDeps;
+  return { deps, timers, applyPatch };
+}
+
+describe("matrix: rotation 60s refresh apply", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    const { deps, timers, applyPatch } = mkRotation(mkResp([AD_B]));
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_A]));
+      applyPatch.mockClear();
+      applyMode(m);
+      await handle.refreshNow(true);   // forced refresh = the 60s apply path
+      expect(applyPatch.mock.calls.length > 0,
+        "refresh apply writes only when healthy+enabled+not-suspended")
+        .toBe(writeAllowed(m));
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+
+  it("a gated refresh does NOT latch the ad-set sig: recovery re-applies an unchanged set", async () => {
+    const { deps, timers, applyPatch } = mkRotation(mkResp([AD_B]));
+    try {
+      const handle = setupAdRotation(deps, mkResp([AD_A]));
+      applyPatch.mockClear();
+      applyMode({ kill: "confirmed", kOn: true, suspended: false });
+      await handle.refreshNow(true);     // gated: nothing may be written…
+      expect(applyPatch).not.toHaveBeenCalled();
+      applyMode({ kill: "clear", kOn: true, suspended: false });
+      await handle.refreshNow(false);    // …recovery, same AD_B set, unforced
+      expect(applyPatch,
+        "the first healthy refresh must re-apply even an unchanged ad set")
+        .toHaveBeenCalled();
+    } finally { timers.forEach((t) => clearInterval(t)); }
+  });
+});
+
+// ── Writer: rotation tick ──────────────────────────────────────────────────
+describe("matrix: rotation tick", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", (m) => {
+    vi.useFakeTimers();
+    try {
+      const { deps, timers, applyPatch } = mkRotation(null);
+      const handle = setupAdRotation(deps, mkResp([AD_A, AD_B]));
+      expect(handle.rotationTimer).not.toBeNull();
+      applyPatch.mockClear();
+      applyMode(m);
+      vi.advanceTimersByTime(120_000);   // one rotation interval
+      expect(applyPatch.mock.calls.length > 0,
+        "rotation tick re-patches only when the gate says write")
+        .toBe(writeAllowed(m));
+      timers.forEach((t) => clearInterval(t));
+    } finally { vi.useRealTimers(); }
+  });
+});
+
+// ── Writer: cliSync tick ───────────────────────────────────────────────────
+describe("matrix: cliSync tick", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", (m) => {
+    cliApply.mockClear();
+    cliRestore.mockClear();
+    const actx = createActivationContext();
+    try {
+      applyMode(m);
+      setupCliSync({
+        actx, ctx: makeContext() as never,
+        adapter: { restore: vi.fn() } as never,
+        auth: { accessToken: () => "tok" } as never,
+        metrics: { send: vi.fn() } as never,
+        debugCtl: { setReassert: vi.fn(), setReassertCodex: vi.fn() } as never,
+        ccVersion: "2.1.167",
+        adRef: { current: AD_A },
+        killedRef: { current: m.kill !== "clear" },
+        reapplyCodex: null,
+      });
+      // setupCliSync runs the first sync synchronously — the 60s tick body.
+      expect(cliApply.mock.calls.length > 0,
+        "settings.json write only when healthy+enabled+not-suspended")
+        .toBe(writeAllowed(m));
+      expect(cliRestore.mock.calls.length > 0,
+        "restore ONLY on confirmed kill / deliberate disable — offline FREEZES")
+        .toBe(restoreExpected(m));
+    } finally { actx.timers.forEach((t) => clearInterval(t)); }
+  });
+});
+
+// ── Writer: debug 60s reassert tick ────────────────────────────────────────
+describe("matrix: debug reassert tick", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    const adapter = mkCcAdapter();
+    const d = new DebugController(
+      adapter as never, makeContext() as never, () => {});
+    try {
+      await d.setOn(true);             // opted in; debug loopback minted
+      adapter.applyPatch.mockClear();
+      applyMode(m);
+      await d.reassertTick();          // patch "drifted" (isPatched=false)
+      expect(adapter.applyPatch.mock.calls.length > 0,
+        "the 60s debug reassert must never fight a kill/freeze/disable")
+        .toBe(writeAllowed(m));
+    } finally { await d.dispose(); }
+  });
+});
+
+// ── Writer: bootCanary boot-path (auto-enable / reapplyIfOn / cyclePatch) ──
+describe("matrix: bootCanary boot-path writers", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    clearCanary();
+    const adapter = mkCcAdapter();
+    const ctx = makeContext();
+    await ctx.globalState.update("kickbacks.debug.on", true); // K_ON persisted
+    const d = new DebugController(adapter as never, ctx as never, () => {});
+    try {
+      applyMode(m);                    // e.g. the persisted-kill boot gate
+      await setupBootCanary(adapter as never, d, ctx as never);
+      expect(adapter.applyPatch.mock.calls.length > 0,
+        "boot-path patching only when the gate is clear at boot")
+        .toBe(writeAllowed(m));
+    } finally { await d.dispose(); clearCanary(); }
+  });
+});
+
+// ── bootCanary either-target auto-enable (codex-only machines) ────────────
+// The clean-boot auto-enable used to gate on the CLAUDE preflight alone, so
+// a Codex-only machine never persisted K_ON and never served. The widened
+// 4th arg folds the codex preflight in; DebugController.apply() already
+// treats a codex-only patch as success (wave-2A-F07).
+describe("bootCanary either-target auto-enable (codex-only)", () => {
+  const mkIncompatCc = () => ({
+    name: "claude-code" as const,
+    preflight: () => ({ ok: true, compatible: false, version: null,
+      reason: "target not found" }),
+    version: () => null,
+    isPatched: vi.fn(() => false),
+    applyPatch: vi.fn(() => ({ ok: false, reason: "target not found" })),
+    restore: vi.fn(() => ({ ok: true, restored: false })),
+  });
+  const mkCodex = () => ({
+    name: "codex" as const,
+    preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
+    version: () => "26.513.21555",
+    isPatched: vi.fn(() => false),
+    applyPatch: vi.fn(() => ({ ok: true })),
+    restore: vi.fn(() => ({ ok: true, restored: true })),
+  });
+
+  it("anyTargetCompatible=true: codex-only clean boot persists K_ON via the"
+    + " codex-folded apply", async () => {
+    clearCanary();
+    applyMode({ kill: "clear", kOn: true, suspended: false });
+    const adapter = mkIncompatCc();
+    const codex = mkCodex();
+    const ctx = makeContext();
+    const d = new DebugController(adapter as never, ctx as never, () => {});
+    d.setCodexAdapter(codex as never);
+    try {
+      await setupBootCanary(adapter as never, d, ctx as never, true);
+      expect(ctx.globalState.get("kickbacks.debug.on"),
+        "K_ON must persist when the codex leg patches").toBe(true);
+      expect(codex.applyPatch).toHaveBeenCalled();
+    } finally { await d.dispose(); clearCanary(); }
+  });
+
+  it("flag omitted: legacy claude-only gate — codex-only boot does NOT"
+    + " auto-enable", async () => {
+    clearCanary();
+    applyMode({ kill: "clear", kOn: true, suspended: false });
+    const adapter = mkIncompatCc();
+    const codex = mkCodex();
+    const ctx = makeContext();
+    const d = new DebugController(adapter as never, ctx as never, () => {});
+    d.setCodexAdapter(codex as never);
+    try {
+      await setupBootCanary(adapter as never, d, ctx as never);
+      expect(ctx.globalState.get("kickbacks.debug.on")).toBeUndefined();
+      expect(codex.applyPatch).not.toHaveBeenCalled();
+    } finally { await d.dispose(); clearCanary(); }
+  });
+});
+
+// ── Webview-injection writers + the live loopback /ad route ───────────────
+async function mkWebview() {
+  const actx = createActivationContext();
+  const adapter = mkCcAdapter();
+  const killedRef = { current: false };
+  const adRef = { current: AD_A as PatchAd | null };
+  const metricsSend = vi.fn();
+  const deps = {
+    ctx: makeContext(), actx, adapter,
+    auth: { accessToken: () => "tok", clientId: () => "cid" },
+    debugCtl: { setPortfolioAd: () => {} },
+    session: new SessionState(),
+    portfolio: { fetchPortfolio: async () => null,
+                 fetchDemoPortfolio: async () => null },
+    metrics: { send: metricsSend },
+    logTail: { current: () => ({}), activityAgeMs: () => null },
+    testHooks: { handleTestRoute: async () => ({ status: 404, body: {} }) },
+    statusBar: { set: () => {} },
+    ccVersion: "2.1.143",
+    killed: false, killedRef, adRef,
+    portfolioResp: mkResp([AD_A]),
+    viewThresholdMs: 3_000,
+    statusBarShowActive: async () => {},
+    scheduleEarningsRefresh: () => {},
+    desyncState: { lastApplyAt: 0, lastBlockStartAt: 0 },
+  };
+  const r = await setupWebviewInjection(deps as never);
+  return { r, adapter, actx, killedRef, metricsSend };
+}
+
+async function teardownWebview(actx: ActivationContext): Promise<void> {
+  for (const t of actx.timers) clearInterval(t as NodeJS.Timeout);
+  actx.timers.length = 0;
+  if (actx.loopback) { await actx.loopback.stop(); actx.loopback = null; }
+}
+
+describe("matrix: webview cycleReassert", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    const { r, adapter, actx, killedRef } = await mkWebview();
+    try {
+      expect(r.cycleReassert).not.toBeNull();
+      adapter.applyPatch.mockClear();
+      adapter.restore.mockClear();
+      applyMode(m);
+      killedRef.current = m.kill !== "clear";  // what checkKill would set
+      r.cycleReassert!();
+      expect(adapter.applyPatch.mock.calls.length > 0,
+        "cycle re-apply only when the gate says write")
+        .toBe(writeAllowed(m));
+      // The cycle's restore must not fire when gated either — a gated cycle
+      // stripping the patch without re-applying would be its own bug.
+      expect(adapter.restore.mock.calls.length > 0)
+        .toBe(writeAllowed(m));
+    } finally { await teardownWebview(actx); }
+  });
+});
+
+describe("matrix: webview 60s reassert tick", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    vi.useFakeTimers();
+    try {
+      const { adapter, actx, killedRef } = await mkWebview();
+      adapter.applyPatch.mockClear();
+      applyMode(m);
+      killedRef.current = m.kill !== "clear";
+      vi.advanceTimersByTime(60_000);
+      expect(adapter.applyPatch.mock.calls.length > 0,
+        "the 60s production reassert must never fight a kill/freeze/disable")
+        .toBe(writeAllowed(m));
+      vi.useRealTimers();
+      await teardownWebview(actx);
+    } finally { vi.useRealTimers(); }
+  });
+});
+
+describe("matrix: loopback /ad serving", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    const { r, actx, killedRef } = await mkWebview();
+    try {
+      expect(r.lbInfo).not.toBeNull();
+      applyMode(m);
+      killedRef.current = m.kill !== "clear";
+      const res = await (await fetch(`${r.lbInfo!.base}/ad`)).json();
+      if (serveAllowed(m)) {
+        // Offline-unsure FREEZES: the running webview keeps its current ad.
+        expect(res.adText).toBe(AD_A.adText);
+      } else {
+        // Confirmed kill / disable: the same empty payload as no-inventory —
+        // new polls adopt nothing. NOTE an already-shown overlay ignores the
+        // empty payload (pollAd only adopts NEW ads); its billing is cut by
+        // the gated onEvent/onClick forwarding, pinned in the next describe.
+        expect(res).toEqual({});
+      }
+    } finally { await teardownWebview(actx); }
+  });
+});
+
+// ── The billing leg of audit #3: a live overlay outlives the /ad gate ─────
+// pollAd ignores the empty /ad payload, so an already-running webview keeps
+// POSTing view/click events straight through a confirmed kill or disable.
+// The extension is the billing authority — the loopback onEvent/onClick
+// forwarding must drop them itself.
+describe("matrix: loopback event/click billing forwarding", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    const { r, actx, killedRef, metricsSend } = await mkWebview();
+    try {
+      expect(r.lbInfo).not.toBeNull();
+      applyMode(m);
+      killedRef.current = m.kill !== "clear";
+      await fetch(`${r.lbInfo!.base}/view_tick?surface=overlay&visible_ms=5000`);
+      await fetch(
+        `${r.lbInfo!.base}/click?ct=overlay&surface=overlay&visible_ms=20000`);
+      expect(metricsSend.mock.calls.length > 0,
+        "billable forwarding only while the gate serves ads")
+        .toBe(serveAllowed(m));
+      if (serveAllowed(m)) {
+        expect(metricsSend.mock.calls.map((c) => c[0]))
+          .toEqual(expect.arrayContaining(["view_tick", "click"]));
+      }
+    } finally { await teardownWebview(actx); }
+  });
+});
+
+// ── UI: showActive never paints the green earning bar while gated ─────────
+function mkShowActive() {
+  let tok: string | null = null;
+  const statusBar = { set: vi.fn() };
+  const session = new SessionState();
+  const auth = { accessToken: () => tok } as unknown as AuthClient;
+  const f = (async () => ({
+    ok: true,
+    json: async () => ({ lifetime_usd: "1.20", today_usd: "0.04" }),
+  })) as unknown as typeof fetch;
+  const client = new EarningsClient("http://x", () => tok, f);
+  const { showActive } = setupEarningsRefresh(
+    auth, client, session, statusBar, "2.1.143", makeContext() as never);
+  return { statusBar, showActive,
+           setToken: (t: string | null) => { tok = t; } };
+}
+
+describe("matrix: showActive paint (audit #4)", () => {
+  it.each(MODES)("kill=$kill kOn=$kOn susp=$suspended", async (m) => {
+    const { statusBar, showActive, setToken } = mkShowActive();
+    setToken("tok");                  // signed in with healthy earnings
+    applyMode(m);
+    statusBar.set.mockClear();
+    await showActive();
+    const kinds = statusBar.set.mock.calls.map(
+      (c) => (c[0] as { kind: string }).kind);
+    if (writeAllowed(m)) {
+      expect(kinds).toContain("active");
+    } else {
+      expect(kinds, "never green while killed/offline/disabled/suspended")
+        .not.toContain("active");
+      const expected = m.kill === "confirmed" ? "killed"
+        : m.kill === "offline" ? "offline" : "debug";
+      expect(kinds).toContain(expected);
+    }
+  });
+});
+
+// ── Crash canary (audit #14): session-wide suspension, manual lift ────────
+describe("crash-canary suspension (audit #14)", () => {
+  it("a fresh crash canary suspends the WHOLE session's automatic writers;"
+    + " only an explicit setOn(true) lifts it", async () => {
+    const adapter = mkCcAdapter();
+    const ctx = makeContext();
+    await ctx.globalState.update("kickbacks.debug.on", true);
+    const d = new DebugController(adapter as never, ctx as never, () => {});
+    try {
+      // Write the canary IMMEDIATELY before the call (no await in between):
+      // a pending settle-unlink timer from an earlier test must not be able
+      // to race the freshly-written file away.
+      mkdirSync(join(homedir(), ".vibe-ads"), { recursive: true });
+      writeFileSync(CANARY, String(Date.now()));
+      await setupBootCanary(adapter as never, d, ctx as never);
+      // The canary path patched nothing…
+      expect(adapter.applyPatch).not.toHaveBeenCalled();
+      // …and the suspension now gates the PRODUCTION activation path too
+      // (pre-fix only setupBootCanary's own calls skipped, and the normal
+      // path re-patched seconds after the "skipping automatic patch" toast).
+      expect(servingSuspended()).toBe(true);
+      expect(canPatch()).toBe(false);
+      // The 60s debug reassert is suspended as well.
+      await d.reassertTick();
+      expect(adapter.applyPatch).not.toHaveBeenCalled();
+      // Manual re-enable — the toast's "click the status bar to manually
+      // re-enable" — lifts the suspension and patches again.
+      await d.setOn(true);
+      expect(servingSuspended()).toBe(false);
+      expect(canPatch()).toBe(true);
+      expect(adapter.applyPatch).toHaveBeenCalled();
+    } finally { await d.dispose(); clearCanary(); }
+  });
+});
+
+// ── Settle-unlink token guard: the 5s canary-clear timer may only delete the
+//    canary it OWNS. The file lives in the shared ~/.vibe-ads, so a second
+//    VS Code window (or a parallel test worker) can re-write it between our
+//    write and our settle — an unguarded unlink stripped THAT activation's
+//    crash protection (and was the source of this suite's cross-test flake).
+describe("bootCanary settle-unlink token guard", () => {
+  // setupBootCanary's writer legs are exercised by the matrix above; here a
+  // stub DebugController keeps these tests to the canary file mechanics only
+  // (on()=true skips auto-enable, so no loopback is minted under fake timers).
+  const stubDebugCtl = () => ({
+    on: () => true,
+    shouldAutoEnableOnSignIn: () => false,
+    setOn: vi.fn(async () => {}),
+    reapplyIfOn: vi.fn(async () => {}),
+    cyclePatch: () => ({ ok: true, reason: "stub" }),
+  });
+
+  it("clears its own canary once the settle window elapses", async () => {
+    vi.useFakeTimers();
+    clearCanary();
+    try {
+      await setupBootCanary(
+        mkCcAdapter() as never, stubDebugCtl() as never, makeContext() as never);
+      expect(existsSync(CANARY)).toBe(true);
+      vi.advanceTimersByTime(6000);
+      expect(existsSync(CANARY), "own canary clears after settle").toBe(false);
+    } finally { vi.useRealTimers(); clearCanary(); }
+  });
+
+  it("leaves a canary re-written by another activation alone", async () => {
+    vi.useFakeTimers();
+    clearCanary();
+    try {
+      await setupBootCanary(
+        mkCcAdapter() as never, stubDebugCtl() as never, makeContext() as never);
+      // A second window / parallel worker re-writes the shared file before
+      // our settle timer fires.
+      writeFileSync(CANARY, "someone-elses-fresh-canary");
+      vi.advanceTimersByTime(6000);
+      expect(existsSync(CANARY),
+        "a foreign canary must survive our settle timer").toBe(true);
+      expect(readFileSync(CANARY, "utf8")).toBe("someone-elses-fresh-canary");
+    } finally { vi.useRealTimers(); clearCanary(); }
+  });
+});
diff --git a/test/servingRetry.test.ts b/test/servingRetry.test.ts
new file mode 100644
index 0000000..859a489
--- /dev/null
+++ b/test/servingRetry.test.ts
@@ -0,0 +1,159 @@
+// Serving bring-up retry (audit #5) + the Wave-2 auditor carry-over.
+//
+// Pre-fix, a backend flap at activation (documented cold-start 502/503),
+// momentarily-empty inventory, or a fail-safed kill probe made
+// setupWebviewInjection return {lbInfo:null, cycleReassert:null,
+// refreshPortfolioNow:null} and NOTHING ever retried — serving was dead
+// until a window reload. The carry-over: a persisted-kill boot skipped
+// serving, and when checkKill later cleared the posture, showActive painted
+// green while nothing could serve. extension.ts now retries the bring-up
+// (portfolio fetch + webview injection) on a bounded-exponential-backoff
+// loop that skips — but stays alive — while the serving gate is not "write".
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { rmSync } from "node:fs";
+
+// Hermetic ~/.claude/settings.json — activate() wires the claude-cli
+// statusline adapter (cliSync + the audit-#22 early-return path) at the
+// developer's REAL settings file; mock it so retries never touch disk.
+vi.mock("../src/adapters/claude-cli/adapter", () => ({
+  resolveStatuslineAsset: () => "",
+  ClaudeCliStatuslineAdapter: class {
+    name = "claude-cli-statusline";
+    spinnerVerbsSupported = true;
+    version() { return "cli"; }
+    preflight() { return { ok: true, compatible: true, version: "cli" }; }
+    applyPatch() { return { ok: true }; }
+    restore() { return { ok: true, restored: false }; }
+  },
+}));
+// No real `claude --version` spawn per activation.
+vi.mock("../src/adapters/claude-cli/cliVersion", () => ({
+  detectClaudeCliSpinnerSupport:
+    async () => ({ ok: true, version: "2.1.150", outdated: false }),
+}));
+
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { makeContext, secrets, commands } from "./mocks/vscode";
+import { setKillPosture } from "../src/servingGate";
+
+// The production webview apply is the one that carries the portfolio ad's
+// text — the boot-canary debug cycle patches with the DEFAULT_TEXT
+// placeholder (setPortfolioAd is wired after bootCanary), so filtering on
+// this string isolates exactly the bring-up under test.
+const AD_TEXT = "RETRY-AD …";
+
+const mkAdapter = () => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+  version: () => "2.1.143",
+  isPatched: () => false,
+  applyPatch: vi.fn((_p: unknown) => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+// Guarantee the clean-boot canary branch (a recent canary from a parallel
+// test worker would otherwise suspend serving and gate the retry loop).
+const clearBootCanary = (): void => {
+  try { rmSync(join(homedir(), ".vibe-ads", "boot.canary"), { force: true }); }
+  catch { /* best-effort */ }
+};
+
+// Controllable backend: `state.hasAd` decides whether ANY portfolio fetch
+// (authed or demo) returns inventory; the killswitch answers killed:false.
+function stubFetch(state: { hasAd: boolean }) {
+  const f = vi.fn(async (input: unknown) => {
+    const url = typeof input === "string" ? input : String(input);
+    if (url.includes("/v1/killswitch")) {
+      return { ok: true, status: 200,
+        json: async () => ({ killed: false }) } as Response;
+    }
+    if (url.includes("/v1/portfolio")) {
+      return { ok: true, status: 200, json: async () => ({
+        ttl_seconds: 30, view_threshold_seconds: 15,
+        ads: state.hasAd ? [{ ad_id: "ad-retry", campaign_id: "camp-retry",
+          title_text: AD_TEXT, icon_ref: "i",
+          click_url: "https://example.com" }] : [],
+      }) } as Response;
+    }
+    return { ok: true, status: 204, json: async () => ({}) } as Response;
+  });
+  vi.stubGlobal("fetch", f);
+  return f;
+}
+
+const prodApplies = (adapter: ReturnType<typeof mkAdapter>): number =>
+  adapter.applyPatch.mock.calls.filter(
+    (c) => (c[0] as { adText?: string } | undefined)?.adText === AD_TEXT,
+  ).length;
+
+beforeEach(() => {
+  secrets.clear();
+  commands._handlers.clear();
+  commands._executed.length = 0;
+  __wireForTest({});
+  clearBootCanary();
+});
+
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe("serving bring-up retry (audit #5)", { timeout: 30_000 }, () => {
+
+  it("empty inventory at activation: the retry loop brings serving up once"
+    + " ads appear (pre-fix: dead until window reload)", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    const state = { hasAd: false };
+    stubFetch(state);
+    __wireForTest({ adapter, statusBar, servingRetryBaseMs: 25 });
+    const ctx = makeContext();
+    try {
+      await activate(ctx as never);
+      expect(prodApplies(adapter),
+        "no production apply while inventory is empty").toBe(0);
+      // Inventory appears (backend recovered / first block funded).
+      state.hasAd = true;
+      await vi.waitFor(() => {
+        expect(prodApplies(adapter),
+          "the retry loop must bring serving up").toBeGreaterThan(0);
+      }, { timeout: 15_000, interval: 20 });
+      // The loop stopped permanently on success: no further bring-up
+      // re-applies within several more would-be retry ticks.
+      const n = prodApplies(adapter);
+      await new Promise((r) => setTimeout(r, 400));
+      expect(prodApplies(adapter), "the loop must stop on success").toBe(n);
+    } finally { await deactivate(); }
+  });
+
+  it("Wave-2 carry-over: a kill at boot skips serving; the gated retry loop"
+    + " stays alive and brings serving up IN-SESSION once the posture"
+    + " clears (pre-fix: green status bar, nothing serving)", async () => {
+    const adapter = mkAdapter();
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    const state = { hasAd: true };
+    stubFetch(state);
+    __wireForTest({ adapter, statusBar, killed: true, servingRetryBaseMs: 25 });
+    const ctx = makeContext();
+    await ctx.secrets.store("kickbacks.access", "AT-RETRY");
+    try {
+      await activate(ctx as never);
+      // Several gated retry ticks elapse — killed-confirmed must SKIP the
+      // attempt (no production apply) without ending the loop.
+      await new Promise((r) => setTimeout(r, 150));
+      expect(prodApplies(adapter),
+        "killed boot must not bring up serving").toBe(0);
+      // Recovery: a live 200 killed:false from checkKill clears the posture
+      // and the killed flag — simulate exactly that transition (the 30s
+      // checkKill interval is too slow for a unit test).
+      __wireForTest({ adapter, statusBar, killed: false,
+        servingRetryBaseMs: 25 });
+      setKillPosture("clear");
+      await vi.waitFor(() => {
+        expect(prodApplies(adapter),
+          "recovery must bring serving up without a reload")
+          .toBeGreaterThan(0);
+      }, { timeout: 15_000, interval: 20 });
+    } finally { await deactivate(); }
+  });
+});
diff --git a/test/setup.ts b/test/setup.ts
new file mode 100644
index 0000000..4724a2d
--- /dev/null
+++ b/test/setup.ts
@@ -0,0 +1,24 @@
+// Global vitest setup. The one job here is keeping every test file hermetic
+// against the developer's real ~/.vibe-ads/debug.log.
+//
+// Background: ../src/log.dlog() appends a line to ~/.vibe-ads/debug.log
+// whenever debugEnabled() returns true (sentinel file, env var, or
+// config.debugMode). Any test that drives activate(), DebugController, or
+// the Claude Code adapter therefore writes into the dev machine's *real*
+// log, interleaving "build dev" / "9.9.9" preflight noise with the user's
+// live extension events. That noise was misread once as an extension-host
+// restart loop. Mocking the log module process-wide stops that at the
+// source without each test having to remember to opt in.
+import { vi } from "vitest";
+
+vi.mock("../src/log", () => ({
+  debugEnabled: () => false,
+  dlog: () => {},
+  dlogRaw: () => {},
+  debugIconDataUri: () => "",
+  codexEnabled: () => false,
+  codexDisabled: () => false,
+  codexCliEnabled: () => false,
+  testHooksEnabled: () => true,
+  LOG_PATH: "/tmp/test-log",
+}));
diff --git a/test/smoke.test.ts b/test/smoke.test.ts
new file mode 100644
index 0000000..2f003ae
--- /dev/null
+++ b/test/smoke.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import type { TargetAdapter } from "../src/adapters/types";
+import { activate, deactivate, __wireForTest } from "../src/extension";
+import { makeContext, secrets, commands } from "./mocks/vscode";
+
+describe("scaffold", () => {
+  it("TargetAdapter type is importable and shaped", () => {
+    const a: Pick<TargetAdapter, "name"> = { name: "claude-code" };
+    expect(a.name).toBe("claude-code");
+  });
+});
+
+describe("manifest ⇄ runtime parity for kickbacks.test.* hooks", () => {
+  beforeEach(() => {
+    secrets.clear();
+    commands._handlers.clear();
+    commands._executed.length = 0;
+    __wireForTest({});
+  });
+  afterEach(() => { vi.unstubAllGlobals(); });
+
+  it("every kickbacks.test.* command declared in package.json is registered"
+    + " at activation when the gate is on — catches manifest/runtime drift",
+    async () => {
+    const pkg = JSON.parse(readFileSync(
+      join(__dirname, "..", "package.json"), "utf8")) as {
+        contributes: { commands: { command: string }[] } };
+    const declared = pkg.contributes.commands
+      .map((c) => c.command)
+      .filter((c) => c.startsWith("kickbacks.test."))
+      .sort();
+    expect(declared.length).toBeGreaterThan(0); // sanity
+
+    const adapter = {
+      name: "claude-code" as const,
+      preflight: () => ({ ok: true, compatible: true, version: "2.1.143" }),
+      version: () => "2.1.143",
+      applyPatch: vi.fn(() => ({ ok: true })),
+      restore: vi.fn(() => ({ ok: true, restored: true })),
+    };
+    const statusBar = { set: vi.fn(), dispose: vi.fn() };
+    __wireForTest({ adapter, statusBar });
+    vi.stubGlobal("fetch", vi.fn(async () =>
+      ({ ok: true, status: 200, json: async () => ({}) } as Response)));
+    const ctx = makeContext();
+    try {
+      await activate(ctx as never);
+      const registered = [...commands._handlers.keys()]
+        .filter((c) => c.startsWith("kickbacks.test."))
+        .sort();
+      // Every declared id has a runtime handler — no orphan menu entries.
+      for (const id of declared) expect(registered).toContain(id);
+      // And every runtime handler is declared — no shadow commands hidden
+      // from the manifest.
+      for (const id of registered) expect(declared).toContain(id);
+    } finally { await deactivate(); }
+  });
+});
diff --git a/test/statusBarAd.test.ts b/test/statusBarAd.test.ts
new file mode 100644
index 0000000..25921b5
--- /dev/null
+++ b/test/statusBarAd.test.ts
@@ -0,0 +1,514 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { setupStatusBarAd, type StatusBarAdDeps } from "../src/activation/statusBarAd";
+import type { Activity } from "../src/activity/logTail";
+
+function makeDeps(overrides: Partial<StatusBarAdDeps> = {}): StatusBarAdDeps & {
+  statusBar: { set: ReturnType<typeof vi.fn>; lastState: unknown };
+  metrics: { send: ReturnType<typeof vi.fn> };
+  showActive: ReturnType<typeof vi.fn>;
+  logTail: {
+    current: ReturnType<typeof vi.fn>;
+    activityAgeMs: ReturnType<typeof vi.fn>;
+  };
+} {
+  const statusBar = { set: vi.fn(), lastState: null as unknown };
+  statusBar.set.mockImplementation((s: unknown) => { statusBar.lastState = s; });
+  const metrics = { send: vi.fn() };
+  const showActive = vi.fn().mockResolvedValue(undefined);
+  const logTail = {
+    current: vi.fn().mockReturnValue(null),
+    activityAgeMs: vi.fn().mockReturnValue(null),
+  };
+  return {
+    logTail: logTail as any,
+    metrics: metrics as any,
+    statusBar,
+    adRef: { current: { adId: "ad1", campaignId: "c1",
+      adText: "Try Acme Widgets", iconRef: "", iconUrl: "",
+      clickUrl: "https://acme.com", bannerEnabled: false,
+      sessionToken: "tok1" } },
+    killedRef: { current: false },
+    ccVersion: "2.1.143",
+    showActive,
+    timers: [],
+    barState: { adShowing: false },
+    ...overrides,
+  } as any;
+}
+
+function thinking(): Activity {
+  return { tool: "Edit", elapsedMs: 1000, ts: Date.now(), done: false };
+}
+function idle(): Activity {
+  return { tool: "Edit", elapsedMs: 5000, ts: Date.now(), done: true };
+}
+
+describe("setupStatusBarAd", () => {
+  beforeEach(() => { vi.useFakeTimers(); });
+  afterEach(() => { vi.useRealTimers(); });
+
+  it("does nothing when logTail returns null", () => {
+    const d = makeDeps();
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("shows ad text when the transcript is fresh but not parseable yet", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(null);
+    d.logTail.activityAgeMs.mockReturnValue(500);
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);
+    expect(d.statusBar.set).toHaveBeenCalledWith(
+      expect.objectContaining({ kind: "ad", adText: "Try Acme Widgets" }));
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_rendered",
+      expect.objectContaining({ surface: "statusbar" }));
+  });
+
+  it("ignores stale transcript writes when the tail cannot be parsed", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(null);
+    d.logTail.activityAgeMs.mockReturnValue(5000);
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+  });
+
+  it("does nothing when done === true (idle)", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(idle());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+  });
+
+  it("shows ad text on first thinking detection", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);
+    expect(d.statusBar.set).toHaveBeenCalledWith(
+      expect.objectContaining({ kind: "ad", adText: "Try Acme Widgets" }));
+  });
+
+  it("fires impression_rendered with surface statusbar", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_rendered",
+      expect.objectContaining({
+        adId: "ad1", campaignId: "c1", surface: "statusbar",
+      }));
+  });
+
+  it("does not re-fire impression_rendered while still thinking", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(5000);
+    const renderedCalls = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_rendered");
+    expect(renderedCalls).toHaveLength(1);
+  });
+
+  it("fires view_tick every 5 seconds while showing", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(11_000);
+    const tickCalls = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(tickCalls.length).toBeGreaterThanOrEqual(2);
+    expect(tickCalls[0][1]).toMatchObject({ surface: "statusbar" });
+  });
+
+  it("fires impression_viewable when thinking ends", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    d.logTail.current.mockReturnValue(idle());
+    vi.advanceTimersByTime(1000);
+    const viewableCalls = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_viewable");
+    expect(viewableCalls).toHaveLength(1);
+    expect(viewableCalls[0][1]).toMatchObject({ surface: "statusbar" });
+    expect(viewableCalls[0][1].visibleMs).toBeGreaterThan(0);
+  });
+
+  it("calls showActive after 6-second hold when thinking ends", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(2000);
+    d.logTail.current.mockReturnValue(idle());
+    vi.advanceTimersByTime(1000);
+    expect(d.showActive).not.toHaveBeenCalled();
+    vi.advanceTimersByTime(6000);
+    expect(d.showActive).toHaveBeenCalledTimes(1);
+  });
+
+  it("does NOT show a (demo) ad when signed out — keeps the Sign-in label", () => {
+    // Demo mode: a signed-out user has a demo ad in hand (from the demo
+    // portfolio), but the status bar must NOT render it — the lower status-bar
+    // text stays the red "Kickbacks: Sign in" call-to-action while signed out.
+    // The demo ad still renders in-window (the overlay surface); only this
+    // status-bar surface gates on sign-in. No impression metrics fire either,
+    // since nothing is shown here.
+    const d = makeDeps();
+    d.adRef.current = { ...d.adRef.current!, demo: true, sessionToken: "" };
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("does not show ad when killed", () => {
+    const d = makeDeps();
+    d.killedRef.current = true;
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+  });
+
+  it("does not show ad when no ad available", () => {
+    const d = makeDeps();
+    d.adRef.current = null;
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+  });
+
+  it("re-shows ad on next thinking burst after idle", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(2000);
+    d.logTail.current.mockReturnValue(idle());
+    vi.advanceTimersByTime(8000);
+    d.metrics.send.mockClear();
+    d.logTail.current.mockReturnValue(thinking());
+    vi.advanceTimersByTime(1000);
+    expect(d.statusBar.set).toHaveBeenCalledWith(
+      expect.objectContaining({ kind: "ad" }));
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_rendered",
+      expect.objectContaining({ surface: "statusbar" }));
+  });
+
+  // H4: arbiter flag is set while the ad owns the bar, cleared when it ends.
+  it("sets barState.adShowing while the ad holds the bar and clears it on hide", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);
+    expect(d.barState.adShowing).toBe(true);
+    d.logTail.current.mockReturnValue(idle());
+    vi.advanceTimersByTime(1000);
+    expect(d.barState.adShowing).toBe(false);
+  });
+
+  // H4: a clobber by another setter (e.g. the 30s earnings refresh) self-heals
+  // — the next poll re-asserts kind:"ad" so a firing view_tick always matches a
+  // visible ad.
+  it("re-asserts the ad each tick while thinking (self-heals clobbers)", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);            // first show
+    d.statusBar.set.mockClear();
+    vi.advanceTimersByTime(1000);            // next tick re-asserts
+    expect(d.statusBar.set).toHaveBeenCalledWith(
+      expect.objectContaining({ kind: "ad", adText: "Try Acme Widgets" }));
+  });
+
+  // M3 / H4: kill flipping ON mid-display must END the show — view_tick must
+  // STOP and impression_viewable must fire, not keep emitting for a killed ad.
+  it("ends the show (stops view_tick) when killed mid-display", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(6000);            // showing + at least one view_tick
+    d.killedRef.current = true;              // kill switch trips mid-burst
+    vi.advanceTimersByTime(1000);            // next poll sees killed
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_viewable",
+      expect.objectContaining({ surface: "statusbar" }));
+    expect(d.barState.adShowing).toBe(false);
+    const ticksBefore = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    vi.advanceTimersByTime(15_000);          // ad still "thinking" but killed
+    const ticksAfter = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    expect(ticksAfter).toBe(ticksBefore);    // no ghost view_tick after kill
+  });
+
+  // M3: when killed mid-display, the kill setter owns the bar — statusBarAd
+  // must NOT call showActive() (which would paint over the "killed" state).
+  it("does not call showActive when killed mid-display", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    d.killedRef.current = true;
+    vi.advanceTimersByTime(2000);
+    expect(d.showActive).not.toHaveBeenCalled();
+  });
+
+  // Audit #1: the 60s portfolio refresh adopts fresh session tokens by
+  // REPLACING the ad object in adRef (300s server TTL). Every billable
+  // emission must carry the CURRENT token for the shown adId — a frozen
+  // snapshot token 403s on any show outliving the TTL.
+  it("adopts a fresh session token from adRef mid-show (view_tick + viewable)", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);            // show starts, snapshots tok1
+    // 60s refresh: same ad, fresh token, NEW object (adRotation token-adopt).
+    d.adRef.current = { ...d.adRef.current!, sessionToken: "tok2" };
+    vi.advanceTimersByTime(5000);            // next view_tick fires
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks.length).toBeGreaterThanOrEqual(1);
+    expect(ticks[ticks.length - 1][1]).toMatchObject(
+      { adId: "ad1", sessionToken: "tok2" });
+    d.logTail.current.mockReturnValue(idle());
+    vi.advanceTimersByTime(1000);            // endShow
+    const viewable = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_viewable");
+    expect(viewable).toHaveLength(1);
+    expect(viewable[0][1]).toMatchObject(
+      { adId: "ad1", sessionToken: "tok2" });
+  });
+
+  // Audit #1 guard: token adoption must never cross ad identities. When the
+  // rotation swaps adRef to a DIFFERENT ad mid-show, the show keeps billing
+  // (and displaying) the ad it actually shows — old id, old token, old text.
+  it("does not adopt a different ad's token mid-show (rotation swap)", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(1000);
+    d.adRef.current = { ...d.adRef.current!, adId: "ad2",
+      sessionToken: "tok9", adText: "Other Ad" };
+    vi.advanceTimersByTime(5000);
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks[ticks.length - 1][1]).toMatchObject(
+      { adId: "ad1", sessionToken: "tok1" });
+    expect(d.statusBar.lastState).toMatchObject(
+      { kind: "ad", adText: "Try Acme Widgets" });
+  });
+
+  // Audit #29: a paint suppressed by the needs-reload lock (StatusBar.set
+  // returns false) must not start billing — no impression_rendered, no
+  // view_tick, and the arbiter flag stays false.
+  it("never bills when the bar suppresses the ad paint (reloadLock)", () => {
+    const d = makeDeps();
+    d.statusBar.set.mockImplementation((s: unknown) => {
+      d.statusBar.lastState = s; return false; });
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+    expect(d.barState.adShowing).toBe(false);
+  });
+
+  // Audit #29: if the lock engages MID-show, the next 1s re-assert paint is
+  // suppressed — the show must end (impression_viewable with the accrued
+  // visible time) and view_tick must stop; no new show starts while locked.
+  it("ends the show when the re-assert paint is suppressed mid-show", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(6000);            // showing + ≥1 view_tick
+    d.statusBar.set.mockImplementation((s: unknown) => {
+      d.statusBar.lastState = s; return false; });
+    vi.advanceTimersByTime(1000);            // next poll: repaint suppressed
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_viewable",
+      expect.objectContaining({ surface: "statusbar" }));
+    expect(d.barState.adShowing).toBe(false);
+    const ticksBefore = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    const renderedBefore = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_rendered").length;
+    vi.advanceTimersByTime(15_000);          // still thinking, still locked
+    const ticksAfter = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    const renderedAfter = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_rendered").length;
+    expect(ticksAfter).toBe(ticksBefore);    // billing stopped with the show
+    expect(renderedAfter).toBe(renderedBefore); // no new show while locked
+  });
+
+  // The Steven fix: a TUI-only user has NO panel transcript (logTail null,
+  // by design since audit #24) — terminal activity via cliTail must engage
+  // the bar, but ONLY while this VS Code window is focused.
+  it("shows + bills on terminal (cliTail) activity when the window is focused", () => {
+    const d = makeDeps({
+      cliTail: { current: vi.fn().mockReturnValue(thinking()),
+        activityAgeMs: vi.fn().mockReturnValue(500) },
+      windowFocused: () => true,
+    } as any);
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(6000);
+    expect(d.statusBar.set).toHaveBeenCalledWith(
+      expect.objectContaining({ kind: "ad", adText: "Try Acme Widgets" }));
+    const ticks = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick");
+    expect(ticks.length).toBeGreaterThanOrEqual(1);
+    expect(ticks[0][1]).toMatchObject({ surface: "statusbar" });
+  });
+
+  it("ignores terminal activity when the window is NOT focused", () => {
+    const d = makeDeps({
+      cliTail: { current: vi.fn().mockReturnValue(thinking()),
+        activityAgeMs: vi.fn().mockReturnValue(500) },
+      windowFocused: () => false,
+    } as any);
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(6000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+    expect(d.metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("ignores terminal activity when no cliTail is wired (old call sites)", () => {
+    const d = makeDeps({ windowFocused: () => true } as any);
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);
+    expect(d.statusBar.set).not.toHaveBeenCalled();
+  });
+
+  it("ends a cli-driven show when the terminal turn completes", () => {
+    const cliCurrent = vi.fn().mockReturnValue(thinking());
+    const d = makeDeps({
+      cliTail: { current: cliCurrent,
+        activityAgeMs: vi.fn().mockReturnValue(500) },
+      windowFocused: () => true,
+    } as any);
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(2000);
+    cliCurrent.mockReturnValue(idle());
+    vi.advanceTimersByTime(1000);
+    const viewable = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_viewable");
+    expect(viewable).toHaveLength(1);
+    expect(d.barState.adShowing).toBe(false);
+  });
+
+  // The show time-box (2026-06-10 "the ad never disappears" report): agentic
+  // sessions keep `thinking` true for hours, so without a cap the earnings
+  // display never surfaced. A continuous show must end at AD_SHOW_MAX_MS,
+  // paint the balance immediately, rest AD_REST_MS, then may re-show.
+  it("time-boxes a continuous show: ends at the cap and paints earnings", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(61_000);          // past AD_SHOW_MAX_MS
+    const viewable = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_viewable");
+    expect(viewable).toHaveLength(1);        // show ended despite thinking
+    expect(d.showActive).toHaveBeenCalled(); // balance painted immediately
+    expect(d.barState.adShowing).toBe(false);
+  });
+
+  it("stops view_tick during the rest window even while still thinking", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(61_000);          // cap hit, rest begins
+    const ticksAtCap = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    vi.advanceTimersByTime(15_000);          // inside the 20s rest
+    const ticksInRest = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    expect(ticksInRest).toBe(ticksAtCap);
+  });
+
+  it("re-shows the ad after the rest window when still thinking", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(61_000);          // cap → rest
+    d.metrics.send.mockClear();
+    vi.advanceTimersByTime(25_000);          // rest (20s) elapsed
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_rendered",
+      expect.objectContaining({ surface: "statusbar" }));
+    expect(d.barState.adShowing).toBe(true);
+  });
+
+  it("a show shorter than the cap keeps the existing idle-revert behavior", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(10_000);          // well under the cap
+    d.logTail.current.mockReturnValue(idle());
+    vi.advanceTimersByTime(1000);
+    expect(d.metrics.send).toHaveBeenCalledWith("impression_viewable",
+      expect.objectContaining({ surface: "statusbar" }));
+    expect(d.showActive).not.toHaveBeenCalled();  // 6s hold first
+    vi.advanceTimersByTime(6000);
+    expect(d.showActive).toHaveBeenCalledTimes(1);
+  });
+
+  it("suspend clamp: a sleep gap mid-show is not billed as visible time", () => {
+    // Pre-fix visibleMs was a raw wall-clock span (now - showStart): an 8h
+    // laptop suspend mid-show inflated the next view_tick and the final
+    // impression_viewable by the whole sleep. Now each timer tick accrues at
+    // most 2 poll intervals, so the gap collapses to one capped slice.
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(5000);            // 5s genuinely visible
+    // Suspend: wall clock jumps 8h with NO timer ticks, then one poll fires.
+    vi.setSystemTime(Date.now() + 8 * 3600_000);
+    vi.advanceTimersByTime(1000);
+    d.logTail.current.mockReturnValue(idle()); // end the show
+    vi.advanceTimersByTime(1000);
+    const viewable = d.metrics.send.mock.calls.find(
+      (c: unknown[]) => c[0] === "impression_viewable");
+    expect(viewable).toBeTruthy();
+    const visibleMs = (viewable![1] as { visibleMs: number }).visibleMs;
+    expect(visibleMs).toBeLessThan(20_000);  // ~7s real, never 8h
+    expect(visibleMs).toBeGreaterThanOrEqual(5000);
+  });
+
+  // The audit-#29 suppression contract: StatusBar.set() returns false when
+  // the needs-reload lock owns the bar — an ad whose paint never landed (or
+  // stopped landing) must never bill. These pin both `=== false` gates; the
+  // default mock returns undefined (a void setter counts as painted), so
+  // without an explicit false neither gate is ever exercised.
+  it("never starts a show or bills when the reload lock suppresses the paint", () => {
+    const d = makeDeps();
+    d.statusBar.set.mockReturnValue(false);  // needs-reload lock owns the bar
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(12_000);
+    expect(d.metrics.send).not.toHaveBeenCalled();
+    expect(d.barState.adShowing).toBe(false);
+  });
+
+  it("ends the show (and stops billing) when a mid-show repaint is suppressed", () => {
+    const d = makeDeps();
+    d.logTail.current.mockReturnValue(thinking());
+    setupStatusBarAd(d);
+    vi.advanceTimersByTime(3000);            // show opened, painting normally
+    expect(d.barState.adShowing).toBe(true);
+    d.statusBar.set.mockReturnValue(false);  // lock engages mid-show
+    vi.advanceTimersByTime(1000);            // next re-assert is suppressed
+    expect(d.barState.adShowing).toBe(false);
+    const viewable = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "impression_viewable");
+    expect(viewable).toHaveLength(1);        // final viewable fired on endShow
+    const ticksBefore = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    vi.advanceTimersByTime(15_000);          // suppressed restarts never bill
+    const ticksAfter = d.metrics.send.mock.calls.filter(
+      (c: unknown[]) => c[0] === "view_tick").length;
+    expect(ticksAfter).toBe(ticksBefore);
+  });
+});
diff --git a/test/statusbar.test.ts b/test/statusbar.test.ts
new file mode 100644
index 0000000..27187f2
--- /dev/null
+++ b/test/statusbar.test.ts
@@ -0,0 +1,118 @@
+import { describe, it, expect, vi } from "vitest";
+import { StatusBar } from "../src/statusbar";
+import { window, ThemeColor } from "./mocks/vscode";
+
+// NOTE: VS Code codicons render as `$(name)` in status-bar text, so the raw
+// string always contains `$(`. Earnings assertions match `$<digit>` instead.
+describe("StatusBar", () => {
+  it("renders each state; signed-in shows Kickbacks + earnings, no 'debug' word", () => {
+    const sb = new StatusBar();
+
+    sb.set({ kind: "signed-out" });
+    expect(sb.text).toMatch(/sign in/i);
+
+    sb.set({ kind: "active", version: "2.1.143", usd: "1.53" });
+    expect(sb.text).toContain("Kickbacks");
+    expect(sb.text).toMatch(/\$1\.53/);
+    expect(sb.text).not.toMatch(/active|2\.1\.143/); // version is tooltip-only
+
+    sb.set({ kind: "active", version: "2.1.143" }); // no figure yet => $0.00
+    expect(sb.text).toContain("Kickbacks");
+    expect(sb.text).toMatch(/\$0\.00 today · \$0\.00/);
+
+    // debug renders exactly like active — never the word "debug"
+    sb.set({ kind: "debug", on: true, usd: "2.00" });
+    expect(sb.text).toMatch(/\$2\.00/);
+    expect(sb.text).not.toMatch(/debug/i);
+
+    sb.set({ kind: "incompatible", version: "9.9.9" });
+    expect(sb.text).toMatch(/incompatible/i);
+    expect(sb.text).toContain("9.9.9");
+
+    sb.set({ kind: "killed" });
+    expect(sb.text).toMatch(/killed/i);
+
+    sb.set({ kind: "offline" });
+    expect(sb.text).toMatch(/offline/i);
+
+    sb.set({ kind: "ad", adText: "Try Acme Widgets — acme.com" });
+    expect(sb.text).toBe("Kickbacks.ai  |  Try Acme Widgets — acme.com");
+  });
+});
+
+describe("StatusBar S7 today+lifetime", () => {
+  it("active renders today + lifetime", () => {
+    const sb = new StatusBar();
+    sb.set({ kind: "active", version: "2.1.143", usd: "1.20", usdToday: "0.04" });
+    expect(sb.text).toMatch(/\$0\.04 today/);
+    expect(sb.text).toMatch(/\$1\.20/);
+    sb.dispose();
+  });
+  it("active with only lifetime defaults today to $0.00", () => {
+    const sb = new StatusBar();
+    sb.set({ kind: "active", version: "2.1.143", usd: "1.20" });
+    expect(sb.text).toMatch(/\$0\.00 today · \$1\.20/);
+    sb.dispose();
+  });
+  it("active with no earnings shows $0.00 (never a bare label)", () => {
+    const sb = new StatusBar();
+    sb.set({ kind: "active", version: "2.1.143" });
+    expect(sb.text).toMatch(/\$0\.00 today · \$0\.00/);
+    sb.dispose();
+  });
+  it("signed-out never shows $amount", () => {
+    const sb = new StatusBar();
+    sb.set({ kind: "signed-out" });
+    expect(sb.text).not.toMatch(/\$\d/);
+    sb.dispose();
+  });
+});
+
+describe("StatusBar needs-reload (post-install red call-to-action)", () => {
+  type Item = { text: string; command?: string; color?: string;
+                backgroundColor?: ThemeColor };
+  // Capture the underlying status-bar item so the red-background + click
+  // wiring is assertable (the field is private on StatusBar).
+  function mk(): { sb: StatusBar; item: Item } {
+    let item!: Item;
+    const orig = window.createStatusBarItem;
+    const spy = vi.spyOn(window, "createStatusBarItem").mockImplementation(
+      () => { item = orig() as Item; return item as never; });
+    const sb = new StatusBar();
+    spy.mockRestore();
+    return { sb, item };
+  }
+
+  it("renders RELOAD text on a red background with white text; click reloads the window", () => {
+    const { sb, item } = mk();
+    sb.set({ kind: "needs-reload" });
+    expect(sb.text).toMatch(/reload/i);
+    expect(sb.text).toMatch(/earn money/i);
+    expect(item.backgroundColor?.id).toBe("statusBarItem.errorBackground");
+    expect(item.color).toBe("#ffffff");
+    expect(item.command).toBe("workbench.action.reloadWindow");
+    sb.dispose();
+  });
+
+  it("is sticky for routine paints (earnings refresh, ads), but safety states paint over it", () => {
+    const { sb, item } = mk();
+    sb.set({ kind: "needs-reload" });
+    const locked = sb.text;
+    sb.set({ kind: "active", version: "2.1.143", usd: "9.99" });
+    sb.set({ kind: "ad", adText: "Try Acme" });
+    expect(sb.text).toBe(locked);
+    expect(item.backgroundColor?.id).toBe("statusBarItem.errorBackground");
+    sb.set({ kind: "killed" });
+    expect(sb.text).not.toBe(locked);
+    expect(sb.text).toMatch(/killed/i);
+    sb.dispose();
+  });
+
+  it("other states never carry a background and keep the menu click", () => {
+    const { sb, item } = mk();
+    sb.set({ kind: "active", version: "2.1.143", usd: "1.00" });
+    expect(item.backgroundColor).toBeUndefined();
+    expect(item.command).toBe("kickbacks.debugMenu");
+    sb.dispose();
+  });
+});
diff --git a/test/statusbarReloadLock.test.ts b/test/statusbarReloadLock.test.ts
new file mode 100644
index 0000000..bb59880
--- /dev/null
+++ b/test/statusbarReloadLock.test.ts
@@ -0,0 +1,64 @@
+import { describe, it, expect } from "vitest";
+import { StatusBar, type SbState } from "../src/statusbar";
+
+// Audit #29: the needs-reload lock must mask ONLY routine earning paints
+// (ad / active / debug-ON). Safety/truth states must always win — a locked
+// bar that hides "killed", "offline" or "Sign in" is lying — and a masked
+// paint must be reported (set() → false) so statusBarAd never bills an ad
+// that was never displayed.
+describe("StatusBar reloadLock (audit #29)", () => {
+  it("masks routine paints (ad / active / debug-ON) and reports false", () => {
+    const sb = new StatusBar();
+    expect(sb.set({ kind: "needs-reload" })).toBe(true);
+    const locked = sb.text;
+    expect(sb.set({ kind: "ad", adText: "Try Acme" })).toBe(false);
+    expect(sb.set({ kind: "active", version: "2.1.143", usd: "9.99" })).toBe(false);
+    expect(sb.set({ kind: "debug", on: true, usd: "1.00" })).toBe(false);
+    expect(sb.text).toBe(locked);
+    sb.dispose();
+  });
+
+  const safetyStates: [SbState, RegExp][] = [
+    [{ kind: "killed" }, /killed/i],
+    [{ kind: "offline" }, /offline/i],
+    [{ kind: "signed-out" }, /sign in/i],
+    [{ kind: "incompatible", version: "9.9.9" }, /incompatible/i],
+    [{ kind: "debug", on: false }, /off/i],
+  ];
+  for (const [state, re] of safetyStates) {
+    it(`safety/truth state "${state.kind}" always paints over the lock`, () => {
+      const sb = new StatusBar();
+      sb.set({ kind: "needs-reload" });
+      expect(sb.set(state)).toBe(true);
+      expect(sb.text).toMatch(re);
+      sb.dispose();
+    });
+  }
+
+  it("a safety paint clears the lock so recovery paints resume", () => {
+    const sb = new StatusBar();
+    sb.set({ kind: "needs-reload" });
+    sb.set({ kind: "offline" });             // safety wins + clears the lock
+    expect(sb.set({ kind: "active", version: "2.1.143", usd: "0.10" })).toBe(true);
+    expect(sb.text).toMatch(/\$0\.10/);
+    sb.dispose();
+  });
+
+  it("needs-reload can re-engage the lock after a safety clear", () => {
+    const sb = new StatusBar();
+    sb.set({ kind: "needs-reload" });
+    sb.set({ kind: "killed" });              // clears the lock
+    sb.set({ kind: "needs-reload" });        // re-engages it
+    expect(sb.set({ kind: "ad", adText: "x" })).toBe(false);
+    expect(sb.text).toMatch(/reload/i);
+    sb.dispose();
+  });
+
+  it("set() reports true for every paint while unlocked", () => {
+    const sb = new StatusBar();
+    expect(sb.set({ kind: "ad", adText: "x" })).toBe(true);
+    expect(sb.set({ kind: "active", version: "2.1.143" })).toBe(true);
+    expect(sb.set({ kind: "killed" })).toBe(true);
+    sb.dispose();
+  });
+});
diff --git a/test/testHooks.test.ts b/test/testHooks.test.ts
new file mode 100644
index 0000000..f94ff37
--- /dev/null
+++ b/test/testHooks.test.ts
@@ -0,0 +1,337 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { TestHooks, type TestHooksContext } from "../src/testHooks";
+import { MetricsClient } from "../src/metrics/client";
+import { PortfolioClient, type PatchAd } from "../src/portfolio/client";
+import { EarningsClient } from "../src/earnings/client";
+
+const AD: PatchAd = {
+  adId: "ad-1", campaignId: "c-1", adText: "Ramp - 30% faster",
+  iconRef: "ramp", iconUrl: "", clickUrl: "https://ramp.com", bannerEnabled: false,
+  sessionToken: "test-token",
+};
+
+function buildSuite(opts: { ad?: PatchAd | null;
+                            signedIn?: boolean;
+                            killed?: boolean;
+                            viewThresholdMs?: number;
+                            onBillableEvent?: () => void } = {}) {
+  const calls: { url: string; body: Record<string, unknown>;
+                 hdr: Record<string, string> }[] = [];
+  const fakeFetch = vi.fn(async (url: string, init: { body: string;
+      headers: Record<string, string> }) => {
+    calls.push({ url, body: JSON.parse(init.body), hdr: init.headers });
+    return { ok: true, status: 200 } as Response;
+  });
+  const metrics = new MetricsClient("http://b", () => "tok",
+    () => "cid", "0.1.0", fakeFetch as never);
+  const portfolio = new PortfolioClient("http://b", () => "tok",
+    fakeFetch as never);
+  const earnings = new EarningsClient("http://b", () => "tok",
+    fakeFetch as never);
+  const ctx: TestHooksContext = {
+    ad: opts.ad === undefined ? AD : opts.ad,
+    signedIn: opts.signedIn ?? true,
+    killed: opts.killed ?? false,
+    ccVersion: "2.1.143",
+    viewThresholdMs: opts.viewThresholdMs ?? 15000,
+    loopback: { port: 12345, base: "http://127.0.0.1:12345/vibe-ads/tok" },
+  };
+  const hooks = new TestHooks(metrics, portfolio, earnings, () => ctx,
+    opts.onBillableEvent ?? null);
+  return { hooks, calls, fakeFetch, ctx };
+}
+
+describe("TestHooks", () => {
+  beforeEach(() => { vi.clearAllMocks(); });
+
+  it("fires impression_rendered through MetricsClient with default surface", async () => {
+    const { hooks, calls } = buildSuite();
+    const r = await hooks.fireImpressionRendered();
+    expect(r.ok).toBe(true);
+    expect(r.sent?.event).toBe("impression_rendered");
+    expect(r.sent?.surface).toBe("overlay");
+    expect(calls).toHaveLength(1);
+    expect(calls[0].url).toBe("http://b/v1/metrics");
+    expect(calls[0].body).toMatchObject({
+      event_type: "impression_rendered",
+      ad_id: "ad-1",
+      campaign_id: "c-1",
+      claude_code_version: "2.1.143",
+      surface: "overlay",
+    });
+    expect(typeof calls[0].body.nonce).toBe("string");
+    expect(calls[0].hdr.authorization).toBe("Bearer tok");
+    expect(calls[0].hdr["X-Vibe-Corr"]).toBeTypeOf("string");
+  });
+
+  it("honors surface override and accepts adId/campaignId when no ad loaded",
+    async () => {
+      const { hooks, calls } = buildSuite({ ad: null });
+      const r = await hooks.fireClick({ surface: "banner",
+        adId: "x1", campaignId: "y1" });
+      expect(r.ok).toBe(true);
+      expect(calls[0].body).toMatchObject({
+        event_type: "click", ad_id: "x1", campaign_id: "y1", surface: "banner",
+      });
+    });
+
+  it("returns ok:false when no ad and no override is supplied", async () => {
+    const { hooks, calls } = buildSuite({ ad: null });
+    const r = await hooks.fireImpressionRendered();
+    expect(r.ok).toBe(false);
+    expect(r.reason).toMatch(/no ad/i);
+    expect(calls).toHaveLength(0);
+  });
+
+  it("fireViewThresholdMet sets viewable/viewPct/viewMs and defaults visibleMs"
+    + " to the configured threshold", async () => {
+    const { hooks, calls } = buildSuite({ viewThresholdMs: 20000 });
+    const r = await hooks.fireViewThresholdMet();
+    expect(r.ok).toBe(true);
+    expect(calls[0].body).toMatchObject({
+      event_type: "view_threshold_met",
+      viewable: true,
+      view_pct: 100,
+      visible_ms: 20000,
+      view_ms: 20000,
+      surface: "overlay",
+    });
+  });
+
+  it("fireImpressionViewable marks viewable:true / viewPct:100", async () => {
+    const { hooks, calls } = buildSuite();
+    await hooks.fireImpressionViewable({ visibleMs: 7000 });
+    expect(calls[0].body).toMatchObject({
+      event_type: "impression_viewable",
+      viewable: true,
+      view_pct: 100,
+      visible_ms: 7000,
+    });
+  });
+
+  it("records each successful send in the ring and surfaces it via getState",
+    async () => {
+      const { hooks } = buildSuite();
+      await hooks.fireImpressionRendered();
+      await hooks.fireImpressionViewable({ surface: "banner" });
+      await hooks.fireClick({ surface: "codex_overlay" });
+      const state = hooks.getState();
+      expect(state.lastEvents).toHaveLength(3);
+      expect(state.lastEvents.map((e) => e?.event)).toEqual([
+        "impression_rendered", "impression_viewable", "click",
+      ]);
+      expect(state.ad?.adId).toBe("ad-1");
+      expect(state.loopback?.port).toBe(12345);
+      hooks.clearEventLog();
+      expect(hooks.getState().lastEvents).toHaveLength(0);
+    });
+
+  it("uses caller-supplied corr verbatim when provided", async () => {
+    const { hooks, calls } = buildSuite();
+    await hooks.fireClick({ corr: "fixed.corr.abc" });
+    expect(calls[0].hdr["X-Vibe-Corr"]).toBe("fixed.corr.abc");
+  });
+
+  it("fireViewTick sends a heartbeat event with no viewable/viewPct fields"
+    + " and threads visibleMs through", async () => {
+    const { hooks, calls } = buildSuite();
+    const r = await hooks.fireViewTick({ visibleMs: 5000 });
+    expect(r.ok).toBe(true);
+    expect(r.sent?.event).toBe("view_tick");
+    expect(calls[0].body).toMatchObject({
+      event_type: "view_tick", visible_ms: 5000, surface: "overlay",
+    });
+    expect("viewable" in calls[0].body).toBe(false);
+    expect("view_pct" in calls[0].body).toBe(false);
+  });
+
+  it("fireViewTick bypasses ImpressionDedupe — three fires => three POSTs",
+    async () => {
+      const { hooks, calls } = buildSuite();
+      await hooks.fireViewTick();
+      await hooks.fireViewTick();
+      await hooks.fireViewTick();
+      expect(calls).toHaveLength(3);
+      expect(calls.every((c) => c.body.event_type === "view_tick")).toBe(true);
+    });
+
+  it("schedules earnings refresh after successful billable test-hook events",
+    async () => {
+      const onBillableEvent = vi.fn();
+      const { hooks } = buildSuite({ onBillableEvent });
+      await hooks.fireImpressionRendered();
+      await hooks.fireViewTick();
+      expect(onBillableEvent).not.toHaveBeenCalled();
+      await hooks.fireImpressionViewable();
+      await hooks.fireViewThresholdMet();
+      await hooks.fireErrorImpression();
+      await hooks.fireClick();
+      expect(onBillableEvent).toHaveBeenCalledTimes(4);
+    });
+
+  it("refreshPortfolio calls PortfolioClient.fetchPortfolio with ccVersion"
+    + " and surfaces the new ad", async () => {
+    const ad2: PatchAd = { adId: "ad-2", campaignId: "c-2",
+      adText: "New ad text", iconRef: "x", iconUrl: "", clickUrl: "https://x", bannerEnabled: false,
+      sessionToken: "test-token-2" };
+    // Override fetch on the suite to return a portfolio body with ad2.
+    const ringCalls: { url: string; init: { headers: Record<string, string> } }[] = [];
+    const portfolioBody = {
+      ttl_seconds: 30,
+      view_threshold_seconds: 25,
+      ads: [{ ad_id: ad2.adId, campaign_id: ad2.campaignId, title_text: ad2.adText,
+        icon_ref: ad2.iconRef, click_url: ad2.clickUrl, banner_enabled: false }],
+    };
+    const fakeFetch = vi.fn(async (url: string, init: { headers: Record<string, string> }) => {
+      ringCalls.push({ url, init });
+      if (url.includes("/v1/portfolio")) {
+        return {
+          ok: true, status: 200,
+          json: async () => portfolioBody,
+        } as unknown as Response;
+      }
+      return { ok: true, status: 200 } as Response;
+    });
+    const { MetricsClient: M } = await import("../src/metrics/client");
+    const { PortfolioClient: P } = await import("../src/portfolio/client");
+    const { EarningsClient: E } = await import("../src/earnings/client");
+    const metrics = new M("http://b", () => "tok",
+      () => "cid", "0.1.0", fakeFetch as never);
+    const portfolio = new P("http://b", () => "tok", fakeFetch as never);
+    const earnings = new E("http://b", () => "tok", fakeFetch as never);
+    const ctx: TestHooksContext = { ad: AD, signedIn: true, killed: false,
+      ccVersion: "2.1.143", viewThresholdMs: 15000, loopback: null };
+    const hooks = new TestHooks(metrics, portfolio, earnings, () => ctx);
+    const got = await hooks.refreshPortfolio();
+    expect(got?.adId).toBe("ad-2");
+    expect(ringCalls[0].url).toContain("/v1/portfolio?claude_code_version=2.1.143");
+    expect(ringCalls[0].init.headers.authorization).toBe("Bearer tok");
+  });
+
+  it("refreshEarnings returns whatever EarningsClient.fetch returns",
+    async () => {
+      const fakeFetch = vi.fn(async (url: string) => {
+        if (url.includes("/v1/earnings")) {
+          return {
+            ok: true, status: 200,
+            json: async () => ({ lifetime_usd: "12.34", today_usd: "1.50" }),
+          } as unknown as Response;
+        }
+        return { ok: true, status: 200 } as Response;
+      });
+      const { MetricsClient: M } = await import("../src/metrics/client");
+      const { PortfolioClient: P } = await import("../src/portfolio/client");
+      const { EarningsClient: E } = await import("../src/earnings/client");
+      const hooks = new TestHooks(
+        new M("http://b", () => "tok", () => "cid", "0.1.0", fakeFetch as never),
+        new P("http://b", () => "tok", fakeFetch as never),
+        new E("http://b", () => "tok", fakeFetch as never),
+        () => ({ ad: AD, signedIn: true, killed: false, ccVersion: "v",
+          viewThresholdMs: 15000, loopback: null }));
+      const earnings = await hooks.refreshEarnings();
+      expect(earnings).toEqual({ lifetimeUsd: "12.34", todayUsd: "1.50" });
+    });
+
+  it("clearEventLog wipes the ring; subsequent fires record again", async () => {
+    const { hooks } = buildSuite();
+    await hooks.fireImpressionRendered();
+    await hooks.fireImpressionViewable();
+    await hooks.fireClick();
+    expect(hooks.getState().lastEvents).toHaveLength(3);
+    hooks.clearEventLog();
+    expect(hooks.getState().lastEvents).toHaveLength(0);
+    await hooks.fireViewTick();
+    const ring = hooks.getState().lastEvents;
+    expect(ring).toHaveLength(1);
+    expect(ring[0]?.event).toBe("view_tick");
+  });
+
+  it("registerCommands wires every kickbacks.test.* id exactly once and"
+    + " each handler invokes the corresponding TestHooks method", async () => {
+    const { hooks } = buildSuite();
+    // Hand-rolled minimal mock so the spy assertions stay focused on this
+    // contract; reuses the real vscode mock's dispatching pattern.
+    const handlers = new Map<string, (...a: unknown[]) => unknown>();
+    const subs: { dispose(): void }[] = [];
+    const fakeCtx = { subscriptions: subs } as unknown as
+      Parameters<typeof hooks.registerCommands>[0];
+    const fakeVscode = await import("./mocks/vscode");
+    // Reset shared handler map (it's module-scoped on the mock).
+    fakeVscode.commands._handlers.clear();
+    fakeVscode.commands._executed.length = 0;
+    // Spy on the mock's registerCommand so we capture insertion order.
+    const ids: string[] = [];
+    const origReg = fakeVscode.commands.registerCommand.bind(fakeVscode.commands);
+    fakeVscode.commands.registerCommand = function (id, h) {
+      ids.push(id);
+      handlers.set(id, h);
+      return origReg(id, h);
+    };
+    try {
+      hooks.registerCommands(fakeCtx);
+      const expected = [
+        "kickbacks.test.fireImpressionRendered",
+        "kickbacks.test.fireImpressionViewable",
+        "kickbacks.test.fireViewTick",
+        "kickbacks.test.fireViewThresholdMet",
+        "kickbacks.test.fireErrorImpression",
+        "kickbacks.test.fireClick",
+        "kickbacks.test.refreshPortfolio",
+        "kickbacks.test.refreshEarnings",
+        "kickbacks.test.getState",
+        "kickbacks.test.clearEventLog",
+      ];
+      expect(ids).toEqual(expected);
+      // Each id is registered exactly once.
+      const counts = new Map<string, number>();
+      for (const id of ids) counts.set(id, (counts.get(id) || 0) + 1);
+      expect([...counts.values()].every((n) => n === 1)).toBe(true);
+      // ExtensionContext gets a disposable per command.
+      expect(subs.length).toBe(expected.length);
+      // Invoking a fire command actually drives the hook (one POST queued).
+      const r = await fakeVscode.commands.executeCommand(
+        "kickbacks.test.fireClick", { surface: "banner" }) as { ok: boolean };
+      expect(r.ok).toBe(true);
+      expect(hooks.getState().lastEvents.some(
+        (e) => e?.event === "click" && e.surface === "banner")).toBe(true);
+      // getState is reachable via the dispatcher and returns a snapshot.
+      const snap = await fakeVscode.commands.executeCommand(
+        "kickbacks.test.getState") as { enabled: boolean };
+      expect(snap.enabled).toBe(true);
+    } finally {
+      fakeVscode.commands.registerCommand = origReg;
+    }
+  });
+});
+
+// Separate suite that disables the gate via a per-suite mock.
+describe("TestHooks gated off", () => {
+  beforeEach(() => { vi.resetModules(); });
+  it("returns ok:false and skips the network when test hooks are disabled",
+    async () => {
+      vi.doMock("../src/log", () => ({
+        debugEnabled: () => false, dlog: () => {}, dlogRaw: () => {},
+        codexEnabled: () => false, testHooksEnabled: () => false,
+        LOG_PATH: "/tmp/test-log",
+      }));
+      const { TestHooks: GatedHooks } = await import("../src/testHooks");
+      const { MetricsClient: M } = await import("../src/metrics/client");
+      const { PortfolioClient: P } = await import("../src/portfolio/client");
+      const { EarningsClient: E } = await import("../src/earnings/client");
+      const calls: unknown[] = [];
+      const f = vi.fn(async () => {
+        calls.push("hit"); return { ok: true, status: 200 } as Response;
+      });
+      const hooks = new GatedHooks(
+        new M("http://b", () => "tok", () => "cid", "0.1.0", f as never),
+        new P("http://b", () => "tok", f as never),
+        new E("http://b", () => "tok", f as never),
+        () => ({ ad: AD, signedIn: true, killed: false,
+                 ccVersion: "v", viewThresholdMs: 15000, loopback: null }));
+      const r = await hooks.fireImpressionRendered();
+      expect(r.ok).toBe(false);
+      expect(r.reason).toMatch(/disabled/);
+      expect(calls).toHaveLength(0);
+      vi.doUnmock("../src/log");
+    });
+});
diff --git a/test/untaggedTickIntegration.test.ts b/test/untaggedTickIntegration.test.ts
new file mode 100644
index 0000000..635d024
--- /dev/null
+++ b/test/untaggedTickIntegration.test.ts
@@ -0,0 +1,108 @@
+/** Integration pin for the 2026-06-11 launch-day zero-earnings fix.
+ *
+ * The unit layers are each tested elsewhere (locateLog.test.ts pins the
+ * untagged fallback, cliTick.test.ts pins the dwell loop against a mocked
+ * tail) — but the incident lived in the SEAM: locateClaudeCliLog refused
+ * untagged transcripts, so the real LogTail fed cliTick `null` forever and
+ * a TUI user on an untagged CC build saw ads all day with zero view_ticks.
+ * The Docker e2e matrix can't cover this either: its CC builds tag their
+ * transcripts, so it only ever exercises the entrypoint:"cli" path.
+ *
+ * This test wires the REAL chain — locateClaudeCliLog → LogTail →
+ * setupCliTick — over a temp HOME holding an UNTAGGED active transcript and
+ * asserts billable ticks flow.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const h = vi.hoisted(() => ({ home: "" }));
+vi.mock("node:os", async (importOriginal) => {
+  const os = await importOriginal<typeof import("node:os")>();
+  return { ...os, homedir: () => h.home };
+});
+
+import { locateClaudeCliLog } from "../src/locate";
+import { LogTail } from "../src/activity/logTail";
+import { setupCliTick } from "../src/activation/cliTick";
+
+function writeTranscript(lines: object[], name = "s.jsonl"): string {
+  const d = join(h.home, ".claude", "projects", "p1");
+  mkdirSync(d, { recursive: true });
+  const f = join(d, name);
+  writeFileSync(f, lines.map((o) => JSON.stringify(o)).join("\n") + "\n");
+  return f;
+}
+
+// An UNTAGGED mid-turn transcript: no `entrypoint` anywhere, newest assistant
+// line still tool-running (stop_reason "tool_use" ⇒ done=false).
+const UNTAGGED_ACTIVE_TURN = [
+  { type: "user", message: { role: "user", content: "do the thing" } },
+  { type: "assistant", message: { role: "assistant",
+    stop_reason: "tool_use",
+    content: [{ type: "tool_use", name: "Edit" }] } },
+];
+
+function makeDeps(metrics: { send: ReturnType<typeof vi.fn> }) {
+  return {
+    cliTail: new LogTail(locateClaudeCliLog),
+    metrics: metrics as never,
+    adRef: { current: { adId: "ad1", campaignId: "c1",
+      adText: "Try Acme Widgets", iconRef: "", iconUrl: "",
+      clickUrl: "https://acme.com", bannerEnabled: false,
+      sessionToken: "tok1" } },
+    killedRef: { current: false },
+    signedIn: () => true,
+    surfaceApplied: () => true,
+    ccVersion: "2.1.143",
+    timers: [] as NodeJS.Timeout[],
+    cliModeFn: () => "on" as const,
+    canPatchFn: () => true,
+  };
+}
+
+describe("untagged-transcript billing integration (launch-day cohort fix)", () => {
+  beforeEach(() => {
+    h.home = mkdtempSync(join(tmpdir(), "vibe-ads-untagged-"));
+    delete process.env.KICKBACKS_CLI_LOG;
+    vi.useFakeTimers();
+  });
+  afterEach(() => { vi.useRealTimers(); });
+
+  it("an active UNTAGGED transcript drives statusline view_ticks end to end", () => {
+    writeTranscript(UNTAGGED_ACTIVE_TURN);
+    const metrics = { send: vi.fn() };
+    const d = makeDeps(metrics);
+    setupCliTick(d as never);
+    vi.advanceTimersByTime(11_000);
+    d.timers.forEach((t) => clearInterval(t));
+    const ticks = metrics.send.mock.calls.filter((c) => c[0] === "view_tick");
+    expect(ticks.length).toBeGreaterThanOrEqual(2);
+    expect(ticks[0][1]).toMatchObject({
+      adId: "ad1", surface: "statusline", sessionToken: "tok1" });
+  });
+
+  it("an active SDK-tagged transcript does NOT bill (no statusline rendered)", () => {
+    writeTranscript([{ ...UNTAGGED_ACTIVE_TURN[0], entrypoint: "sdk-ts" },
+                     UNTAGGED_ACTIVE_TURN[1]]);
+    const metrics = { send: vi.fn() };
+    const d = makeDeps(metrics);
+    setupCliTick(d as never);
+    vi.advanceTimersByTime(11_000);
+    d.timers.forEach((t) => clearInterval(t));
+    expect(metrics.send).not.toHaveBeenCalled();
+  });
+
+  it("a cli-tagged transcript still bills (no regression on the tagged path)", () => {
+    writeTranscript([{ ...UNTAGGED_ACTIVE_TURN[0], entrypoint: "cli" },
+                     UNTAGGED_ACTIVE_TURN[1]]);
+    const metrics = { send: vi.fn() };
+    const d = makeDeps(metrics);
+    setupCliTick(d as never);
+    vi.advanceTimersByTime(11_000);
+    d.timers.forEach((t) => clearInterval(t));
+    const ticks = metrics.send.mock.calls.filter((c) => c[0] === "view_tick");
+    expect(ticks.length).toBeGreaterThanOrEqual(2);
+  });
+});
diff --git a/test/update-cooldown.test.ts b/test/update-cooldown.test.ts
new file mode 100644
index 0000000..4f2742a
--- /dev/null
+++ b/test/update-cooldown.test.ts
@@ -0,0 +1,159 @@
+/** Regression test for wave-2H-F04 -- cooldown-bounded update guard.
+ *
+ * The 30-min UPD_COOLDOWN_MS guard in extension.ts wraps UpdateClient's
+ * AttemptGuard.attempted/markAttempted: a given version is install-tried
+ * AT MOST once per 30 minutes. Pre-fix update.test.ts only tested with a
+ * permanent boolean -- the test passed whether the guard was permanent OR
+ * cooldown-bounded. This test uses a fake clock + the real cooldown
+ * formula to pin the time-based semantics.
+ *
+ * Also covers wave-2A-F05 transient cooldown: a sha-mismatch / size-cap
+ * pre-install failure is throttled by a separate 15-min transient slot,
+ * not the permanent 30-min one.
+ */
+import { describe, it, expect, vi } from "vitest";
+import { createHash } from "node:crypto";
+import { UpdateClient } from "../src/update/client";
+
+const COOLDOWN_MS = 30 * 60 * 1000;
+const TRANSIENT_COOLDOWN_MS = 15 * 60 * 1000;
+const bytes = Buffer.alloc(12 * 1024, 0x42);
+const sha = createHash("sha256").update(bytes).digest("hex");
+
+// Wave-2P-F01: mirrors the extension.ts composite-key wiring exactly so this
+// fixture exercises the same flap-suppression logic prod uses. `updKey`
+// degrades to bare-version when sha is absent (back-compat with old guards).
+// Ring-buffer (capacity matches extension.ts): tracks the last N (v,sha)
+// attempts so an A→B→A→B manifest ping-pong can't bypass the cooldown by
+// clobbering the prior slot.
+const RING_CAP = 16;
+const updKey = (v: string, sha?: string) => sha ? `${v}@${sha.slice(0, 16)}` : v;
+function makeGuard(now: () => number) {
+  const attempted: { k: string; ts: number }[] = [];
+  const transient: { k: string; ts: number }[] = [];
+  const seen = (ring: { k: string; ts: number }[], k: string, cd: number) =>
+    ring.some((e) => e.k === k && now() - e.ts < cd);
+  const mark = (ring: { k: string; ts: number }[], k: string) => {
+    for (let i = ring.length - 1; i >= 0; i--) if (ring[i].k === k) ring.splice(i, 1);
+    ring.push({ k, ts: now() });
+    while (ring.length > RING_CAP) ring.shift();
+  };
+  return {
+    attempted: (v: string, sha?: string) => seen(attempted, updKey(v, sha), COOLDOWN_MS),
+    markAttempted: (v: string, sha?: string) => mark(attempted, updKey(v, sha)),
+    transientFailed: (v: string, sha?: string) =>
+      seen(transient, updKey(v, sha), TRANSIENT_COOLDOWN_MS),
+    markTransientFailed: (v: string, sha?: string) => mark(transient, updKey(v, sha)),
+  };
+}
+
+function makeFetch(version: string, vsixBytes: Buffer, vsixSha: string) {
+  return vi.fn(async (url: string) =>
+    url.endsWith("/manifest")
+      ? ({ ok: true, json: async () => ({ version, sha256: vsixSha,
+          url: "http://b/x.vsix" }) } as Response)
+      : ({ ok: true, arrayBuffer: async () =>
+          vsixBytes.buffer.slice(vsixBytes.byteOffset,
+                                 vsixBytes.byteOffset + vsixBytes.length) } as Response));
+}
+
+describe("wave-2H-F04 cooldown-bounded update guard", () => {
+  it("permanent slot: a 2nd attempt within 30min is suppressed", async () => {
+    let t = 1_000_000;
+    const now = () => t;
+    const guard = makeGuard(now);
+    const installed: Buffer[] = [];
+    const f = makeFetch("0.2.0", bytes, sha);
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); }, guard);
+
+    expect(await c.checkOnce()).toBe(true);   // installs, marks attempted
+    expect(installed.length).toBe(1);
+
+    // 29 minutes later -> still within cooldown
+    t += 29 * 60 * 1000;
+    expect(await c.checkOnce()).toBe(false);
+    expect(installed.length).toBe(1);
+
+    // 31 minutes from FIRST attempt -> cooldown elapsed; re-attempted
+    t = 1_000_000 + 31 * 60 * 1000;
+    expect(await c.checkOnce()).toBe(true);
+    expect(installed.length).toBe(2);
+  });
+
+  it("wave-2P-F01: same version + DIFFERENT sha installs twice (flap is the artifact, not the label)", async () => {
+    // The bug this test pins: prod 0.3.54 had two builds (different
+    // BUILD_TS, identical semver). Pre-fix, guard.attempted("0.3.54") was
+    // true after the first install, so the second build never installed —
+    // BUT the OPPOSITE problem also occurred elsewhere: when a manifest
+    // genuinely served a *different* artifact under the same version
+    // (e.g. signed rebuild), the version-keyed cooldown was correct to
+    // skip it. The composite key correctly distinguishes these cases:
+    // - Same version + same sha within cooldown -> skip (no flap).
+    // - Same version + different sha            -> install (new artifact).
+    let t = 1_000_000;
+    const guard = makeGuard(() => t);
+    const installed: Buffer[] = [];
+
+    // Two distinct VSIX blobs, same semver "0.3.54".
+    const bytesA = Buffer.alloc(12 * 1024, 0x41);
+    const shaA = createHash("sha256").update(bytesA).digest("hex");
+    const bytesB = Buffer.alloc(12 * 1024, 0x42);
+    const shaB = createHash("sha256").update(bytesB).digest("hex");
+
+    let serve: { bytes: Buffer; sha: string } = { bytes: bytesA, sha: shaA };
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.3.54",
+            sha256: serve.sha, url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            serve.bytes.buffer.slice(serve.bytes.byteOffset,
+              serve.bytes.byteOffset + serve.bytes.length) } as Response));
+
+    const c = new UpdateClient("http://b", "0.3.53", f as never,
+      async (b) => { installed.push(Buffer.from(b)); }, guard);
+
+    // Round 1: serve artifact A. Installs + marks (0.3.54, shaA).
+    expect(await c.checkOnce()).toBe(true);
+    expect(installed.length).toBe(1);
+
+    // Round 2 (immediately): manifest now flips to artifact B with the SAME
+    // version. Pre-fix this was suppressed (the version-keyed cooldown said
+    // "already attempted"). Post-fix: composite key differs -> install.
+    serve = { bytes: bytesB, sha: shaB };
+    expect(await c.checkOnce()).toBe(true);
+    expect(installed.length).toBe(2);
+
+    // Round 3 (immediately): manifest flips BACK to artifact A. shaA was
+    // marked in round 1; same composite key now within cooldown -> skip.
+    // This is the actual flap-suppression: the loop must NOT keep ping-pong-
+    // installing A and B every poll forever.
+    serve = { bytes: bytesA, sha: shaA };
+    expect(await c.checkOnce()).toBe(false);
+    expect(installed.length).toBe(2);
+  });
+
+  it("wave-2A-F05: sha mismatch goes into the transient slot, not permanent", async () => {
+    let t = 5_000_000;
+    const guard = makeGuard(() => t);
+    const installed: Buffer[] = [];
+    // Manifest sha intentionally doesn't match the bytes -> transient fail.
+    const f = makeFetch("0.2.0", bytes, "deadbeef00");
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); }, guard);
+
+    expect(await c.checkOnce()).toBe(false);
+    expect(installed.length).toBe(0);
+
+    // 5 min later: still inside the 15-min transient cooldown -> skipped
+    t += 5 * 60 * 1000;
+    expect(await c.checkOnce()).toBe(false);
+
+    // 16 min after first failure: transient cooldown elapsed, retry
+    t = 5_000_000 + 16 * 60 * 1000;
+    expect(await c.checkOnce()).toBe(false); // sha still mismatches -> retry-then-transient-fail again
+    // The permanent attempted slot was NEVER set -- a sha-mismatch never
+    // burns the install-attempt budget.
+    expect(guard.attempted("0.2.0")).toBe(false);
+  });
+});
diff --git a/test/update.test.ts b/test/update.test.ts
new file mode 100644
index 0000000..2eb8f4e
--- /dev/null
+++ b/test/update.test.ts
@@ -0,0 +1,245 @@
+import { describe, it, expect, vi } from "vitest";
+import { createHash } from "node:crypto";
+import { UpdateClient, _vsixUrlAllowed } from "../src/update/client";
+
+// wave-2A-F01 introduces a 10 KiB minimum-size sanity for the VSIX bytes
+// (rejects empty / garbage / CDN-stub downloads). Use a 12 KiB filler.
+const bytes = Buffer.alloc(12 * 1024, 0x42); // 12288 bytes of "B"
+const sha = createHash("sha256").update(bytes).digest("hex");
+
+describe("UpdateClient", () => {
+  it("installs when manifest version is newer and sha256 matches", async () => {
+    const installed: Buffer[] = [];
+    const f = vi.fn(async (url: string) => {
+      if (url.endsWith("/v1/ext/manifest"))
+        return { ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+          url: "http://b/v1/ext/vibe-ads.vsix" }) } as Response;
+      return { ok: true, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length) } as Response;
+    });
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (buf) => { installed.push(Buffer.from(buf)); });
+    expect(await c.checkOnce()).toBe(true);
+    expect(installed).toHaveLength(1);
+  });
+  it("aborts on sha256 mismatch (no install)", async () => {
+    const installed: Buffer[] = [];
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: "deadbeef",
+            url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length) } as Response));
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); });
+    expect(await c.checkOnce()).toBe(false);
+    expect(installed).toHaveLength(0);
+  });
+  // trey-nag-loop 2026-06-11: the attempted slot is cooldown-bounded so a
+  // FAILED install can retry — but that let a SUCCESSFUL install re-run
+  // every time the cooldown expired (re-download + re-install + re-toast
+  // every ~31 min until the user reloaded). A success record suppresses the
+  // artifact independent of any cooldown.
+  it("success record: an installed artifact is never re-attempted, even when attempted() no longer fences", async () => {
+    const installed: Buffer[] = [];
+    let slot: string | undefined;
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+            url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+          } as Response));
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); },
+      { // attempted() always false = the 30-min cooldown has expired
+        attempted: () => false, markAttempted: () => {},
+        installed: (v, s) => slot === `${v}@${s}`,
+        markInstalled: (v, s) => { slot = `${v}@${s}`; } });
+    expect(await c.checkOnce()).toBe(true);    // installs, records success
+    expect(installed).toHaveLength(1);
+    expect(await c.checkOnce()).toBe(false);   // suppressed by success record
+    expect(installed).toHaveLength(1);         // pre-fix: re-installed here
+  });
+
+  it("success record: a THROWING install is NOT recorded (cooldown retry stays possible)", async () => {
+    const markInstalled = vi.fn();
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+            url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+          } as Response));
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async () => { throw new Error("installExtension failed"); },
+      { attempted: () => false, markAttempted: () => {},
+        installed: () => false, markInstalled });
+    expect(await c.checkOnce()).toBe(false);
+    expect(markInstalled).not.toHaveBeenCalled();
+  });
+
+  it("attempts a given version AT MOST ONCE (restart-loop guard)", async () => {
+    const installed: Buffer[] = [];
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+            url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+          } as Response));
+    let mark: string | undefined;
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); },
+      { attempted: (v) => v === mark, markAttempted: (v) => { mark = v; } });
+    expect(await c.checkOnce()).toBe(true);   // first: installs, marks 0.2.0
+    expect(installed).toHaveLength(1);
+    expect(await c.checkOnce()).toBe(false);  // second: already attempted -> skip
+    expect(installed).toHaveLength(1);        // NO re-install -> no restart loop
+  });
+
+  // audit-2026-06-09 #31: the attempt fence is only written AFTER the
+  // download, so without a single-flight guard an overlapping 90s poll
+  // (slow VSIX download) double-downloads and double-installs the same
+  // artifact. The overlapping call must return false WITHOUT fetching.
+  it("single-flight: an overlapping checkOnce neither downloads nor installs", async () => {
+    let release!: () => void;
+    const gate = new Promise<void>((r) => { release = r; });
+    const installed: Buffer[] = [];
+    let vsixFetches = 0;
+    const f = vi.fn(async (url: string) => {
+      if (url.endsWith("/manifest"))
+        return { ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+          url: "http://b/x.vsix" }) } as Response;
+      vsixFetches++;
+      await gate;                              // download outlives the next poll
+      return { ok: true, arrayBuffer: async () =>
+        bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+      } as Response;
+    });
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); });
+    const first = c.checkOnce();
+    // Let the first call pass the manifest fetch and park on the download.
+    await new Promise((r) => setTimeout(r, 10));
+    const second = c.checkOnce();              // the overlapping 90s tick
+    await new Promise((r) => setTimeout(r, 10));
+    // Pre-fix the second poll started its own VSIX download here.
+    expect(vsixFetches).toBe(1);
+    release();
+    expect(await second).toBe(false);
+    expect(await first).toBe(true);
+    expect(installed).toHaveLength(1);         // exactly one install
+  });
+
+  it("no-op when manifest version is not newer", async () => {
+    const c = new UpdateClient("http://b", "0.2.0",
+      (async () => ({ ok: true, json: async () =>
+        ({ version: "0.2.0", sha256: "x", url: "y" }) })) as never,
+      async () => { throw new Error("should not install"); });
+    expect(await c.checkOnce()).toBe(false);
+  });
+
+  // wave-2A-F01 regression: VSIX size sanity + signature flag
+  it("aborts when VSIX bytes are below the minimum-size sanity (10 KiB)", async () => {
+    // 1 KiB filler — well under the 10 KiB floor. sha matches the manifest
+    // so the sha-mismatch path can't be what blocks; only the size check.
+    const tinyBytes = Buffer.alloc(1024, 0x55);
+    const tinySha = createHash("sha256").update(tinyBytes).digest("hex");
+    const installed: Buffer[] = [];
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: tinySha,
+            url: "http://b/x.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            tinyBytes.buffer.slice(tinyBytes.byteOffset,
+                                   tinyBytes.byteOffset + tinyBytes.length) } as Response));
+    const c = new UpdateClient("http://b", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); });
+    expect(await c.checkOnce()).toBe(false);
+    expect(installed).toHaveLength(0);
+  });
+
+  it("with VIBE_ADS_REQUIRE_MANIFEST_SIG=1 + no embedded pubkey -> abort", async () => {
+    const original = process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG;
+    process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG = "1";
+    try {
+      const installed: Buffer[] = [];
+      const f = vi.fn(async (url: string) =>
+        url.endsWith("/manifest")
+          ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+              url: "http://b/x.vsix" }) } as Response)
+          : ({ ok: true, arrayBuffer: async () =>
+              bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+            } as Response));
+      const c = new UpdateClient("http://b", "0.1.0", f as never,
+        async (b) => { installed.push(Buffer.from(b)); });
+      // No __MANIFEST_PUBKEY_PEM__ define and the flag is on -> refuse.
+      expect(await c.checkOnce()).toBe(false);
+      expect(installed).toHaveLength(0);
+    } finally {
+      if (original === undefined) delete process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG;
+      else process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG = original;
+    }
+  });
+
+  // wave-2A-F01 layer 3: VSIX download-origin pin (supply-chain).
+  it("blocks a VSIX url whose origin is off the published bucket (no install)", async () => {
+    const installed: Buffer[] = [];
+    // Manifest points the download at an attacker host but carries a VALID
+    // sha for those bytes (the sha pin alone cannot save us here).
+    const f = vi.fn(async (url: string) =>
+      url.endsWith("/manifest")
+        ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+            url: "https://evil.example.com/payload.vsix" }) } as Response)
+        : ({ ok: true, arrayBuffer: async () =>
+            bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+          } as Response));
+    // base is the GCS-style prod host so same-origin does NOT rescue evil.com.
+    const c = new UpdateClient(
+      "https://kickbacks-public-x.a.run.app", "0.1.0", f as never,
+      async (b) => { installed.push(Buffer.from(b)); });
+    expect(await c.checkOnce()).toBe(false);
+    expect(installed).toHaveLength(0);
+    // The blocked path must NOT have fetched the payload (only the manifest).
+    expect(f).toHaveBeenCalledTimes(1);
+  });
+
+  it("_vsixUrlAllowed: prod bucket allowed, dev/self-host allowed, others blocked", () => {
+    const base = "https://kickbacks-public-x.a.run.app";
+    // Production published bucket (both URL styles).
+    expect(_vsixUrlAllowed(
+      "https://kickbacks-vsix.storage.googleapis.com/kickbacks-0.3.99.vsix", base)).toBe(true);
+    expect(_vsixUrlAllowed(
+      "https://storage.googleapis.com/kickbacks-vsix/kickbacks-0.3.99.vsix", base)).toBe(true);
+    // Another bucket on the shared GCS host is NOT allowed.
+    expect(_vsixUrlAllowed(
+      "https://storage.googleapis.com/some-other-bucket/x.vsix", base)).toBe(false);
+    // Dev self-host: same origin as the manifest base, or loopback.
+    expect(_vsixUrlAllowed("http://b/x.vsix", "http://b")).toBe(true);
+    expect(_vsixUrlAllowed("http://127.0.0.1:6080/x.vsix", base)).toBe(true);
+    // Attacker host + non-https are rejected.
+    expect(_vsixUrlAllowed("https://evil.example.com/x.vsix", base)).toBe(false);
+    expect(_vsixUrlAllowed("http://kickbacks-vsix.storage.googleapis.com/x.vsix", base)).toBe(false);
+    expect(_vsixUrlAllowed("not a url", base)).toBe(false);
+  });
+
+  it("with flag OFF and no signature -> install proceeds (backward-compat)", async () => {
+    const original = process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG;
+    delete process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG;
+    try {
+      const installed: Buffer[] = [];
+      const f = vi.fn(async (url: string) =>
+        url.endsWith("/manifest")
+          ? ({ ok: true, json: async () => ({ version: "0.2.0", sha256: sha,
+              url: "http://b/x.vsix" }) } as Response)
+          : ({ ok: true, arrayBuffer: async () =>
+              bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length)
+            } as Response));
+      const c = new UpdateClient("http://b", "0.1.0", f as never,
+        async (b) => { installed.push(Buffer.from(b)); });
+      expect(await c.checkOnce()).toBe(true);
+      expect(installed).toHaveLength(1);
+    } finally {
+      if (original !== undefined) process.env.VIBE_ADS_REQUIRE_MANIFEST_SIG = original;
+    }
+  });
+});
diff --git a/test/vault.test.ts b/test/vault.test.ts
new file mode 100644
index 0000000..72c5f5a
--- /dev/null
+++ b/test/vault.test.ts
@@ -0,0 +1,126 @@
+import { describe, it, expect, vi } from "vitest";
+import { createVault, type Run, type RunResult } from "../src/auth/vault";
+
+const ok = (stdout = ""): RunResult => ({ code: 0, stdout, stderr: "" });
+const fail = (stderr = "err"): RunResult => ({ code: 1, stdout: "", stderr });
+
+describe("SecretVault — OS-native at-rest with a universal plaintext floor", () => {
+  it("darwin: seals into the Keychain (no secret in the file) and round-trips", async () => {
+    const store = new Map<string, string>();
+    const run: Run = vi.fn(async (cmd, args, opts) => {
+      expect(cmd).toBe("security");
+      if (args[0] === "add-generic-password") {
+        // Account is the -a value; secret is the -w value.
+        const acct = args[args.indexOf("-a") + 1];
+        const secret = args[args.indexOf("-w") + 1];
+        store.set(acct, secret);
+        return ok();
+      }
+      if (args[0] === "find-generic-password")
+        return ok((store.get(args[args.indexOf("-a") + 1]) ?? "") + "\n");
+      if (args[0] === "delete-generic-password") {
+        store.delete(args[args.indexOf("-a") + 1]);
+        return ok();
+      }
+      return fail();
+    });
+    const v = createVault("darwin", run);
+    expect(v.scheme()).toBe("keychain");
+    const env = await v.seal("cid-1", "RT-secret");
+    expect(env.startsWith("keychain:1:")).toBe(true);
+    expect(env).not.toContain("RT-secret");          // secret NOT in the envelope
+    expect(await v.open(env)).toBe("RT-secret");
+    await v.clear(env);
+    expect(await v.open(env)).toBeNull();             // gone => honest signed-out
+  });
+
+  it("win32: DPAPI-encrypts the blob into the file and round-trips", async () => {
+    // Model ProtectedData as reversible base64 wrapping; secret arrives via env,
+    // never argv.
+    // Model ProtectedData as base64 (reversible, non-identity — so the
+    // "no plaintext in the envelope" invariant is actually testable).
+    const run: Run = vi.fn(async (cmd, _args, opts) => {
+      expect(/powershell|pwsh/i.test(cmd)).toBe(true);
+      const script = _args.join(" ");
+      const inp = opts?.env?.VIBE_ADS_SECRET ?? "";
+      // PS_UNPROTECT runs FromBase64String first, so order matters: a value
+      // that is valid base64 is treated as ciphertext to decrypt.
+      if (/Unprotect/.test(script)) {
+        try { return ok(Buffer.from(inp, "base64").toString("utf8") + "\n"); }
+        catch { return fail(); }
+      }
+      if (/Protect/.test(script))
+        return ok(Buffer.from(inp, "utf8").toString("base64") + "\n");
+      return fail();
+    });
+    const v = createVault("win32", run);
+    expect(v.scheme()).toBe("dpapi");
+    const env = await v.seal("cid-2", "RT-win");
+    expect(env.startsWith("dpapi:1:")).toBe(true);
+    expect(env).not.toContain("RT-win");              // ciphertext only
+    expect(await v.open(env)).toBe("RT-win");
+  });
+
+  it("linux: secret-tool via stdin (no argv leak); round-trips", async () => {
+    const store = new Map<string, string>();
+    const run: Run = vi.fn(async (cmd, args, opts) => {
+      expect(cmd).toBe("secret-tool");
+      const acct = args[args.indexOf("account") + 1];
+      if (args[0] === "store") {
+        expect(opts?.input).toBe("RT-lin");           // secret on stdin, not argv
+        store.set(acct, opts!.input!);
+        return ok();
+      }
+      if (args[0] === "lookup")
+        return store.has(acct) ? ok(store.get(acct)!) : fail();
+      if (args[0] === "clear") { store.delete(acct); return ok(); }
+      return fail();
+    });
+    const v = createVault("linux", run);
+    const env = await v.seal("cid-3", "RT-lin");
+    expect(env.startsWith("libsecret:1:")).toBe(true);
+    expect(env).not.toContain("RT-lin");
+    expect(await v.open(env)).toBe("RT-lin");
+  });
+
+  it("linux without a Secret Service: falls straight through to the plaintext floor", async () => {
+    const run: Run = vi.fn(async () => fail("No such service"));
+    const v = createVault("linux", run);
+    const env = await v.seal("cid-4", "RT-floor");
+    expect(env).toBe("plain:1:RT-floor");
+    expect(await v.open(env)).toBe("RT-floor");        // floor still works
+  });
+
+  it("any OS tool throwing never escapes: seal degrades to plain", async () => {
+    const run: Run = vi.fn(async () => { throw new Error("ENOENT"); });
+    for (const p of ["darwin", "win32", "linux"] as const) {
+      const v = createVault(p, run);
+      const env = await v.seal("cid", "S");
+      expect(env).toBe("plain:1:S");
+      expect(await v.open(env)).toBe("S");
+    }
+  });
+
+  it("opening an OS envelope whose entry was wiped externally => null, not throw", async () => {
+    const run: Run = vi.fn(async (_c, args) =>
+      args[0] === "find-generic-password" || args[0] === "lookup"
+        ? fail() : ok());
+    expect(await createVault("darwin", run).open("keychain:1:gone")).toBeNull();
+    expect(await createVault("linux", run).open("libsecret:1:gone")).toBeNull();
+  });
+
+  it("plain envelopes need no exec at all (works with a throwing runner)", async () => {
+    const run: Run = vi.fn(async () => { throw new Error("must not be called"); });
+    const v = createVault("linux", run);
+    expect(await v.open("plain:1:abc")).toBe("abc");
+    await v.clear("plain:1:abc");                      // no-op, no exec
+    expect(run).not.toHaveBeenCalled();
+  });
+
+  it("unknown/garbage envelope => null (forward/back-compat safe)", async () => {
+    const v = createVault("linux", vi.fn(async () => ok()));
+    expect(await v.open("")).toBeNull();
+    expect(await v.open("bogus")).toBeNull();
+    expect(await v.open("future:9:xyz")).toBeNull();
+  });
+});
diff --git a/test/viewTimer.test.ts b/test/viewTimer.test.ts
new file mode 100644
index 0000000..9c6b813
--- /dev/null
+++ b/test/viewTimer.test.ts
@@ -0,0 +1,234 @@
+import { describe, it, expect, vi } from "vitest";
+import { ViewTimer, type ThresholdEvent, type TickEvent,
+  type ErrorImpressionEvent } from "../src/viewTracking/timer";
+
+function clock(): { now: () => number; advance: (ms: number) => void } {
+  let t = 0;
+  return { now: () => t, advance: (ms) => { t += ms; } };
+}
+
+// NOTE (audit 2026-06-09 #23 / wave-1 EXT-03, verified 2026-06-10): the
+// ViewTimer class under test is NOT shipped — no production code imports it
+// (the live timers are block.asset.js `_vt`, codex/block.asset.js, and
+// statusBarAd.ts's inline interval). The no-op hide()/pause() and unbounded
+// catch-up semantics pinned below are therefore DEAD-CODE contracts kept for
+// the reference implementation only; the shipped surfaces END sessions on
+// hide and clamp suspend/wake gaps — see test/cc-viewtimer.test.ts for the
+// production contract. Port that clamp before ever wiring this class up.
+describe("ViewTimer (W3)", () => {
+  // Tests focused on the natural threshold-met path disable the
+  // error_impression safety net (`maxSessionMs: 0`). With the default cap of
+  // 5s and threshold of 15s, mutual exclusion (added 2026-05-21 to fix the
+  // Codex-flagged double-bill) would make error_impression fire first and
+  // suppress threshold_met. The "fires onErrorImpression exactly once" test
+  // below covers the safety-net case independently.
+  it("fires onThresholdMet exactly once after 15s of visibility", () => {
+    const c = clock();
+    const met = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
+      now: c.now, onThresholdMet: met });
+    vt.show("ad-A", "overlay", "sess-1");
+    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(1);
+    const [evt] = met.mock.calls[0] as [ThresholdEvent];
+    expect(evt.adId).toBe("ad-A");
+    expect(evt.surface).toBe("overlay");
+    expect(evt.sessionNonce).toBe("sess-1");
+    expect(evt.thresholdMs).toBe(15_000);
+
+    // Continuing past the threshold must NOT re-fire.
+    for (let i = 0; i < 30; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(1);
+  });
+
+  it("does not fire threshold below the configured time", () => {
+    const c = clock();
+    const met = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
+      now: c.now, onThresholdMet: met });
+    vt.show("ad-A", "overlay", "s");
+    for (let i = 0; i < 14; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).not.toHaveBeenCalled();
+  });
+
+  it("hide() is a no-op under the absolute-epoch baseline", () => {
+    // Post-refactor (2026-05-22): elapsed time = now() - sessionStartedAt,
+    // not an accumulator. hide() no longer pauses accumulation because the
+    // 250ms poll cadence can't be relied on to re-anchor accurately under
+    // throttling. A hidden ad still progresses toward billing; mutual
+    // exclusion + server-side gates bound the impact.
+    const c = clock();
+    const met = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
+      now: c.now, onThresholdMet: met });
+    vt.show("ad-A", "overlay", "s");
+    for (let i = 0; i < 5; i++) { c.advance(1_000); vt.poll(); }
+    vt.hide("ad-A", "overlay");
+    // Elapsed clock keeps running through "hidden"; threshold fires at 15s.
+    for (let i = 0; i < 10; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(1);
+  });
+
+  it("pause() and resume() are no-ops under the absolute-epoch baseline", () => {
+    // Same rationale as hide(): the baseline is sticky; pause() no longer
+    // gates accumulation. Kept as public API so callers don't need rewriting.
+    const c = clock();
+    const met = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
+      now: c.now, onThresholdMet: met });
+    vt.show("ad-A", "overlay", "s");
+    vt.pause();
+    for (let i = 0; i < 15; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(1);   // fires anyway at 15s elapsed
+    vt.resume();   // no-op, no crash
+  });
+
+  it("tracks overlay and banner independently for the same ad", () => {
+    const c = clock();
+    const met = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
+      now: c.now, onThresholdMet: met });
+    vt.show("ad-A", "overlay", "s1");
+    vt.show("ad-A", "banner", "s1");
+    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(2);
+    const surfaces = met.mock.calls.map((c) => (c[0] as ThresholdEvent).surface);
+    expect(surfaces.sort()).toEqual(["banner", "overlay"]);
+  });
+
+  it("emits view_tick at every tickMs of accumulated visible time", () => {
+    const c = clock();
+    const tick = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 60_000, tickMs: 5_000, now: c.now,
+      onTick: tick });
+    vt.show("ad-A", "overlay", "s");
+    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
+    // Visible 16s, ticks at 5/10/15 → 3 ticks.
+    expect(tick).toHaveBeenCalledTimes(3);
+    const visibleAt = tick.mock.calls.map((c) => (c[0] as TickEvent).visibleMs);
+    expect(visibleAt).toEqual([5_000, 10_000, 15_000]);
+  });
+
+  it("fires onErrorImpression at every maxSessionMs boundary (5s cadence)", () => {
+    // New repeated-billing contract (2026-05-22): error_impression is
+    // not one-shot — it fires at every multiple of maxSessionMs (5/10/
+    // 15/20/...) so a stuck session keeps generating billable events.
+    // The backend cooldown gate decides which credit.
+    const c = clock();
+    const met = vi.fn();
+    const errImp = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 5_000,
+      now: c.now, onThresholdMet: met, onErrorImpression: errImp });
+    vt.show("ad-A", "overlay", "s1");
+    for (let i = 0; i < 4; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).not.toHaveBeenCalled();   // not yet at 5s
+    c.advance(1_000); vt.poll();             // 5s → fire #1
+    expect(errImp).toHaveBeenCalledTimes(1);
+    const [evt] = errImp.mock.calls[0] as [ErrorImpressionEvent];
+    expect(evt.adId).toBe("ad-A");
+    expect(evt.surface).toBe("overlay");
+    expect(evt.sessionNonce).toBe("s1");
+    expect(evt.maxSessionMs).toBe(5_000);
+    expect(evt.visibleMs).toBeGreaterThanOrEqual(5_000);
+
+    // 10s → fire #2; 15s → fire #3; ... etc.
+    for (let i = 0; i < 5; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).toHaveBeenCalledTimes(2);
+    for (let i = 0; i < 5; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).toHaveBeenCalledTimes(3);
+    for (let i = 0; i < 25; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).toHaveBeenCalledTimes(8);   // 5/10/15/20/25/30/35/40s
+
+    // threshold_met is suppressed for the whole stuck session — any
+    // error_impression having fired blocks it permanently.
+    expect(met).not.toHaveBeenCalled();
+  });
+
+  it("does NOT burst-fire on a throttled poll (one fire per call)", () => {
+    // If poll() skips many boundaries (e.g. the tab was throttled for
+    // 30s and only one poll fires), we MUST NOT spam N fires in one
+    // tick — that would double-bill on a recovery from throttling.
+    // The contract is "fire one, advance counter by one, fire the rest
+    // on subsequent polls."
+    const c = clock();
+    const errImp = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 5_000,
+      now: c.now, onErrorImpression: errImp });
+    vt.show("ad-A", "overlay", "s1");
+    c.advance(30_000);   // 30s elapsed in one jump
+    vt.poll();           // single poll
+    expect(errImp).toHaveBeenCalledTimes(1);  // only one fire, not 6
+    vt.poll();
+    expect(errImp).toHaveBeenCalledTimes(2);
+    vt.poll();
+    expect(errImp).toHaveBeenCalledTimes(3);
+  });
+
+  it("threshold_met and error_impression mutex still holds with repeated firing", () => {
+    // Codex adversarial review (2026-05-21) plus the every-5s refactor
+    // (2026-05-22): the two billing paths must not BOTH bill a session.
+    //
+    // threshold-first config (threshold < cap): threshold_met fires at 3s,
+    // error_impression at 5s is suppressed (thresholdMet flag latches it).
+    const c1 = clock();
+    const met1 = vi.fn();
+    const err1 = vi.fn();
+    const vt1 = new ViewTimer({ thresholdMs: 3_000, maxSessionMs: 5_000,
+      now: c1.now, onThresholdMet: met1, onErrorImpression: err1 });
+    vt1.show("ad-A", "overlay", "s1");
+    for (let i = 0; i < 30; i++) { c1.advance(1_000); vt1.poll(); }
+    expect(met1).toHaveBeenCalledTimes(1);
+    expect(err1).not.toHaveBeenCalled();
+
+    // error-first config (cap < threshold; current ship default of 5s/15s):
+    // error_impression fires at 5/10/15/20s (4 events in 20s), threshold_met
+    // is suppressed for the whole session.
+    const c2 = clock();
+    const met2 = vi.fn();
+    const err2 = vi.fn();
+    const vt2 = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 5_000,
+      now: c2.now, onThresholdMet: met2, onErrorImpression: err2 });
+    vt2.show("ad-B", "overlay", "s2");
+    for (let i = 0; i < 20; i++) { c2.advance(1_000); vt2.poll(); }
+    expect(err2).toHaveBeenCalledTimes(4);   // 5/10/15/20s
+    expect(met2).not.toHaveBeenCalled();
+  });
+
+  it("error_impression resets with a new sessionNonce", () => {
+    const c = clock();
+    const errImp = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 5_000,
+      now: c.now, onErrorImpression: errImp });
+    vt.show("ad-A", "overlay", "s1");
+    for (let i = 0; i < 6; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).toHaveBeenCalledTimes(1);
+    vt.show("ad-A", "overlay", "s2");   // fresh session
+    for (let i = 0; i < 6; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).toHaveBeenCalledTimes(2);
+  });
+
+  it("maxSessionMs: 0 disables the error_impression safety net", () => {
+    const c = clock();
+    const errImp = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 60_000, maxSessionMs: 0,
+      now: c.now, onErrorImpression: errImp });
+    vt.show("ad-A", "overlay", "s");
+    for (let i = 0; i < 60; i++) { c.advance(1_000); vt.poll(); }
+    expect(errImp).not.toHaveBeenCalled();
+  });
+
+  it("new sessionNonce resets accumulation for the same (ad, surface)", () => {
+    const c = clock();
+    const met = vi.fn();
+    const vt = new ViewTimer({ thresholdMs: 15_000, maxSessionMs: 0,
+      now: c.now, onThresholdMet: met });
+    vt.show("ad-A", "overlay", "s1");
+    for (let i = 0; i < 16; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(1);
+    vt.show("ad-A", "overlay", "s2");   // fresh session
+    for (let i = 0; i < 14; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(1);
+    for (let i = 0; i < 2; i++) { c.advance(1_000); vt.poll(); }
+    expect(met).toHaveBeenCalledTimes(2);
+  });
+});
diff --git a/test/webviewInjection-dualtarget.test.ts b/test/webviewInjection-dualtarget.test.ts
new file mode 100644
index 0000000..27e5933
--- /dev/null
+++ b/test/webviewInjection-dualtarget.test.ts
@@ -0,0 +1,188 @@
+/** S9 dual-target webview injection: the codex-only serving path and the
+ *  either-target honest-status contract.
+ *
+ *  Pins (against the claude-only-primary regression a public-mirror PR
+ *  exposed — kickbacks.ai#1):
+ *    • claudeCompatible:false skips the doomed Claude write entirely, applies
+ *      Codex IMMEDIATELY (no 10s "unconfirmed" window), paints active — and
+ *      NEVER arms desyncState.lastApplyAt (the claude-webview-cache watchdog
+ *      has no claude apply to heal; lastApplyAt===0 keeps it passive).
+ *    • A transient Claude apply miss while a Codex block is live must not
+ *      relabel the bar "incompatible" (any-target-patched union).
+ *    • The claude-compatible path is byte-identical to before (lastApplyAt
+ *      armed by the claude apply only).
+ *    • cycleReassert on a codex-only boot never restores/re-writes Claude.
+ */
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("../src/modes", () => ({
+  webviewMode: () => "on",
+  cliMode: () => "on",
+  bannerOverride: () => "server",
+  setWebviewMode: () => {},
+  setCliMode: () => {},
+  setBannerOverride: () => {},
+}));
+
+import { resetServingGate } from "../src/servingGate";
+import { setupWebviewInjection } from "../src/activation/webviewInjection";
+import { createActivationContext, type ActivationContext }
+  from "../src/activation/context";
+import { SessionState } from "../src/sessionState";
+import { makeContext } from "./mocks/vscode";
+import type { PatchAd } from "../src/portfolio/client";
+
+beforeEach(() => { resetServingGate(); });
+
+const AD: PatchAd = {
+  adId: "ad-1", campaignId: "c-1", adText: "Linear -- plan, build, ship",
+  iconRef: "icon.1", iconUrl: "", clickUrl: "https://a.test",
+  bannerEnabled: false, sessionToken: "tok-1",
+};
+
+const mkCc = (o: { compatible?: boolean; applyOk?: boolean;
+                   isPatched?: boolean } = {}) => ({
+  name: "claude-code" as const,
+  preflight: () => ({ ok: true, compatible: o.compatible ?? true,
+    version: "2.1.143" }),
+  version: () => "2.1.143",
+  isPatched: vi.fn(() => o.isPatched ?? false),
+  applyPatch: vi.fn(() => (o.applyOk ?? true)
+    ? { ok: true } : { ok: false, reason: "apply miss" }),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+const mkCodex = (o: { isPatched?: boolean } = {}) => ({
+  name: "codex" as const,
+  preflight: () => ({ ok: true, compatible: true, version: "26.513.21555" }),
+  version: () => "26.513.21555",
+  isPatched: vi.fn(() => o.isPatched ?? false),
+  applyPatch: vi.fn(() => ({ ok: true })),
+  restore: vi.fn(() => ({ ok: true, restored: true })),
+});
+
+async function mkWebview(opts: {
+  adapter: ReturnType<typeof mkCc>;
+  codexAdapter?: ReturnType<typeof mkCodex> | null;
+  claudeCompatible?: boolean;
+}) {
+  const actx = createActivationContext();
+  actx.codexAdapter = (opts.codexAdapter ?? null) as never;
+  const desyncState = { lastApplyAt: 0, lastBlockStartAt: 0 };
+  const statusBarSet = vi.fn();
+  const showActive = vi.fn(async () => {});
+  const deps = {
+    ctx: makeContext(), actx, adapter: opts.adapter,
+    auth: { accessToken: () => "tok", clientId: () => "cid" },
+    debugCtl: { setPortfolioAd: () => {} },
+    session: new SessionState(),
+    portfolio: { fetchPortfolio: async () => null,
+                 fetchDemoPortfolio: async () => null },
+    metrics: { send: vi.fn() },
+    logTail: { current: () => ({}), activityAgeMs: () => null },
+    testHooks: { handleTestRoute: async () => ({ status: 404, body: {} }) },
+    statusBar: { set: statusBarSet },
+    ccVersion: opts.claudeCompatible === false
+      ? "codex/26.513.21555" : "2.1.143",
+    killed: false, killedRef: { current: false },
+    adRef: { current: AD as PatchAd | null },
+    portfolioResp: { ad: AD, ads: [AD], queueId: "q", ttlMs: 60_000,
+      rotationIntervalMs: 120_000, viewThresholdMs: 3_000, balances: null },
+    viewThresholdMs: 3_000,
+    statusBarShowActive: showActive,
+    scheduleEarningsRefresh: () => {},
+    desyncState,
+    ...(opts.claudeCompatible === undefined
+      ? {} : { claudeCompatible: opts.claudeCompatible }),
+  };
+  const r = await setupWebviewInjection(deps as never);
+  return { r, actx, desyncState, statusBarSet, showActive };
+}
+
+async function teardown(actx: ActivationContext): Promise<void> {
+  for (const t of actx.timers) clearInterval(t as NodeJS.Timeout);
+  actx.timers.length = 0;
+  if (actx.loopback) { await actx.loopback.stop(); actx.loopback = null; }
+}
+
+describe("codex-only serving (claudeCompatible: false)", () => {
+  it("skips the Claude write, applies Codex immediately, paints active,"
+    + " never arms the desync watchdog", async () => {
+    const adapter = mkCc({ compatible: false });
+    const codex = mkCodex();
+    const { r, actx, desyncState, statusBarSet, showActive } =
+      await mkWebview({ adapter, codexAdapter: codex, claudeCompatible: false });
+    try {
+      expect(r.lbInfo).not.toBeNull();
+      // No doomed Claude write — not even one attempt.
+      expect(adapter.applyPatch).not.toHaveBeenCalled();
+      // Codex applied SYNCHRONOUSLY at setup (not deferred to the 10s pass).
+      expect(codex.applyPatch).toHaveBeenCalled();
+      // Honest label: active, never "incompatible".
+      expect(showActive).toHaveBeenCalled();
+      expect(statusBarSet).not.toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "incompatible" }));
+      // The claude-webview desync watchdog stays passive: lastApplyAt===0
+      // reads as "no-apply" in reassert.ts::desyncDecision. A codex apply
+      // must never arm it (the mirror PR's exact mistake).
+      expect(desyncState.lastApplyAt).toBe(0);
+    } finally { await teardown(actx); }
+  });
+
+  it("cycleReassert never restores/re-writes Claude on a codex-only boot", async () => {
+    const adapter = mkCc({ compatible: false });
+    const codex = mkCodex();
+    const { r, actx, desyncState } =
+      await mkWebview({ adapter, codexAdapter: codex, claudeCompatible: false });
+    try {
+      adapter.applyPatch.mockClear();
+      adapter.restore.mockClear();
+      codex.applyPatch.mockClear();
+      r.cycleReassert!();
+      expect(adapter.restore).not.toHaveBeenCalled();
+      expect(adapter.applyPatch).not.toHaveBeenCalled();
+      expect(codex.applyPatch).toHaveBeenCalled();
+      expect(desyncState.lastApplyAt).toBe(0);
+    } finally { await teardown(actx); }
+  });
+});
+
+describe("either-target honest status", () => {
+  it("a transient Claude apply miss with a LIVE codex block does not relabel"
+    + " the bar incompatible", async () => {
+    const adapter = mkCc({ applyOk: false, isPatched: false });
+    const codex = mkCodex({ isPatched: true });
+    const { actx, statusBarSet, showActive } =
+      await mkWebview({ adapter, codexAdapter: codex, claudeCompatible: true });
+    try {
+      expect(adapter.applyPatch).toHaveBeenCalled();        // tried (compatible)
+      expect(statusBarSet).not.toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "incompatible" }));
+      expect(showActive).toHaveBeenCalled();                // deferred-to
+    } finally { await teardown(actx); }
+  });
+
+  it("a Claude apply miss with NO patched target still labels incompatible"
+    + " (no false-positive active)", async () => {
+    const adapter = mkCc({ applyOk: false, isPatched: false });
+    const { actx, statusBarSet } =
+      await mkWebview({ adapter, codexAdapter: null, claudeCompatible: true });
+    try {
+      expect(statusBarSet).toHaveBeenCalledWith(
+        expect.objectContaining({ kind: "incompatible" }));
+    } finally { await teardown(actx); }
+  });
+
+  it("claude-compatible path unchanged: the claude apply arms lastApplyAt"
+    + " (codex never does)", async () => {
+    const adapter = mkCc();
+    const codex = mkCodex();
+    const before = Date.now();
+    const { actx, desyncState } =
+      await mkWebview({ adapter, codexAdapter: codex });   // flag omitted
+    try {
+      expect(adapter.applyPatch).toHaveBeenCalled();
+      expect(desyncState.lastApplyAt).toBeGreaterThanOrEqual(before);
+    } finally { await teardown(actx); }
+  });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..dfe77eb
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "ES2021",
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "outDir": "dist",
+    "rootDir": ".",
+    "types": ["node", "vscode"]
+  },
+  "include": ["src", "test"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..d67f5b3
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+import { resolve } from "node:path";
+export default defineConfig({
+  test: { environment: "node", include: ["test/**/*.test.ts"],
+          setupFiles: ["test/setup.ts"] },
+  resolve: { alias: { vscode: resolve(__dirname, "test/mocks/vscode.ts") } },
+});

(oldest)  ·  back to Kickbacks Ai Upstream  ·  (newest)