← back to Kickbacks Ai Upstream
src/activation/cliTick.ts
154 lines
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));
}