← back to Kickbacks Ai Upstream
src/activation/cliSync.ts
225 lines
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 */ } } };
}