← back to Kickbacks Ai Upstream

src/debug.ts

650 lines

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; }
  }
}