← back to Kickbacks Ai Upstream

src/adapters/claude-cli/cliAd.ts

115 lines

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