← back to Kickbacks Ai Upstream
src/extension.ts
881 lines
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;
}