← back to Kickbacks Ai Upstream
src/activation/statusBarAd.ts
263 lines
import type { LogTail } from "../activity/logTail";
import type { MetricsClient } from "../metrics/client";
import type { PatchAd } from "../portfolio/client";
import type { SbState } from "../statusbar";
import { canPatch } from "../servingGate";
import { dlog } from "../log";
const AD_DISPLAY_HOLD_MS = 6_000;
const POLL_INTERVAL_MS = 1_000;
const VIEW_TICK_INTERVAL_MS = 5_000;
const FRESH_ACTIVITY_MS = 4_000;
// Time-boxed show cycle: agentic sessions keep `thinking` true for HOURS
// (every tool call rewrites the transcript), so "ad while thinking" used to
// degenerate into "ad forever" and the earnings display never surfaced
// (2026-06-10 report: "the ad never disappears"). A show now runs at most
// AD_SHOW_MAX_MS continuously, then the bar rests on the earnings/balance
// display for AD_REST_MS before the ad may return. Billing stops with each
// show (endShow), so the cap also bounds continuous statusbar dwell.
const AD_SHOW_MAX_MS = 60_000;
const AD_REST_MS = 20_000;
export interface StatusBarAdDeps {
logTail: LogTail;
metrics: MetricsClient;
// `set` returns false when the paint was suppressed (the needs-reload lock
// owns the bar) — a suppressed ad must never bill. A void-returning setter
// counts as painted.
statusBar: { set: (s: SbState) => boolean | void };
adRef: { current: PatchAd | null };
killedRef: { current: boolean };
ccVersion: string;
showActive: () => Promise<void>;
timers: NodeJS.Timeout[];
// Shared arbiter flag: set true while the ad owns the status-bar item so the
// periodic earnings refresh (showActive) won't paint over it. The poll also
// re-asserts the ad each tick as a backstop against other setters
// (kill/offline/incompatible) clobbering it mid-display.
barState: { adShowing: boolean };
// TERMINAL-activity signal (the Steven fix): a second LogTail pinned to the
// newest entrypoint:"cli" transcript (locateClaudeCliLog). A TUI-only user
// never produces panel activity — locateClaudeCodeLog returns "" for them
// by design (audit #24, desync-watchdog safety) — so without this signal
// the statusbar ad never engages and their billing silently dies. The CLI
// path is gated on `windowFocused` (below): a focused VS Code window with a
// live terminal turn means the bar is actually on screen.
cliTail?: Pick<LogTail, "current" | "activityAgeMs">;
// True when this VS Code window has OS focus (vscode.window.state.focused).
// Only consulted for the CLI-activity path — a turn running in an EXTERNAL
// terminal while VS Code is backgrounded must not bill a bar nobody sees.
// Absent (older call sites / tests) ⇒ the CLI path stays off entirely.
windowFocused?: () => boolean;
}
export function setupStatusBarAd(deps: StatusBarAdDeps): void {
const {
logTail, metrics, statusBar, adRef, killedRef,
ccVersion, showActive, timers, barState,
cliTail, windowFocused,
} = deps;
let showing = false;
let corr = "";
let revertTimer: NodeJS.Timeout | null = null;
let viewTickTimer: NodeJS.Timeout | null = null;
let shownAd: PatchAd | null = null;
let showStartedAt = 0;
let restUntil = 0;
// Visible-time accrual with a suspend clamp (the audit-#23 contract, same
// as the CC block): visibleMs used to be a raw wall-clock span
// (now - showStart), so a laptop suspend mid-show billed the whole sleep
// gap as visible time in the next view_tick / final impression_viewable.
// Accrue per timer tick instead, capping any single gap at 2 poll
// intervals — timer coalescing across a suspend then contributes at most
// one capped slice.
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;
};
// The per-show viewTick/revert timers must be tracked in `timers` so
// deactivate() (which clears actx.timers) stops them — otherwise a
// disable/uninstall mid-display leaves a viewTick interval firing
// metrics.send against a stale closure. We also splice on clear so the
// shared array doesn't grow one stale handle per show.
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);
};
const stopViewTicks = (): void => {
if (viewTickTimer) {
clearInterval(viewTickTimer); untrack(viewTickTimer); viewTickTimer = null;
}
};
const clearRevert = (): void => {
if (revertTimer) {
clearTimeout(revertTimer); untrack(revertTimer); revertTimer = null;
}
};
// The 60s portfolio refresh adopts fresh session tokens by REPLACING the
// ad objects in adRef (server tokens carry a 300s TTL; adRotation's
// token-adopt branch builds new objects), so the `shownAd` snapshot keeps
// its aged token — on a show outliving the TTL every later billable event
// would 403. Re-read the live ad at each billable emission and, when it is
// still the SAME ad, adopt its token. Never changes which ad is displayed —
// token freshness only (audit #1).
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();
const visibleMs = accruedVisibleMs;
stopViewTicks();
freshenToken();
metrics.send("impression_viewable", {
adId: shownAd.adId, campaignId: shownAd.campaignId,
ccVersion, corr, surface: "statusbar",
visibleMs, sessionToken: shownAd.sessionToken,
});
dlog("ext", "statusbar.ad.hide", { adId: shownAd.adId, visibleMs, corr });
showing = false;
shownAd = null;
barState.adShowing = false;
};
const poll = (): void => {
try {
// The 1s poll is the accrual clock while a show is live — each tick
// contributes at most VISIBLE_GAP_CAP_MS, so a suspend gap collapses
// to one capped slice instead of the whole sleep.
if (showing) accrueVisible();
const activity = logTail.current();
const activityAgeMs = logTail.activityAgeMs();
const freshTranscript = activityAgeMs !== null
&& activityAgeMs <= FRESH_ACTIVITY_MS;
const panelThinking = activity ? !activity.done : freshTranscript;
// Terminal turns count too — but only while THIS window is focused
// (integrated-terminal TUI usage: the bar is visibly on screen). The
// panel path is checked first so the common case pays no extra stat.
let cliThinking = false;
if (!panelThinking && cliTail && windowFocused?.()) {
const cliAct = cliTail.current();
const cliAge = cliTail.activityAgeMs();
const cliFresh = cliAge !== null && cliAge <= FRESH_ACTIVITY_MS;
cliThinking = cliAct ? !cliAct.done : cliFresh;
}
const thinking = panelThinking || cliThinking;
const ad = adRef.current;
// Eligible to show the ad: Claude is thinking, we have a REAL (user-
// crediting) ad, and not killed. Demo ads (signed-out preview) are
// intentionally EXCLUDED from the status bar — the lower status-bar text
// must stay the red "Kickbacks: Sign in" prompt while signed out so the
// bar reads as a call-to-action, not as serving inventory. Demo ads still
// render in-window (the overlay surface); only this status-bar surface
// gates on sign-in. Kill / no-ad / demo are NOT just "skip" — if we're
// mid-display they must END the show (stop view_tick), else the timer
// keeps emitting view metrics for a gone ad. canPatch() folds in the
// serving gate so a deliberate "Disable Kickbacks" (or a crash-canary
// suspension) also ends the show and stops billing (wave 2, audit #4).
const eligible = thinking && !!ad && !ad.demo && !killedRef.current
&& canPatch() && Date.now() >= restUntil;
// Show time-box: a continuous show hits its cap mid-thinking → end it
// (billing stops, impression_viewable fires with the real visible
// time), paint the earnings display IMMEDIATELY (that's the point —
// the balance must get screen time), and rest before re-showing.
if (showing && Date.now() - showStartedAt >= AD_SHOW_MAX_MS) {
endShow();
restUntil = Date.now() + AD_REST_MS;
clearRevert();
if (!killedRef.current) void showActive();
return;
}
if (eligible) {
clearRevert();
if (!showing) {
// Paint FIRST: a false return means the needs-reload lock owns the
// bar and the ad was never displayed — don't start the show and
// never bill (no impression_rendered, no view_tick). Audit #29.
if (statusBar.set({ kind: "ad", adText: ad!.adText }) === false) {
return;
}
showing = true;
accruedVisibleMs = 0;
lastAccrualMs = Date.now();
showStartedAt = Date.now();
shownAd = ad;
barState.adShowing = true;
corr = "statusbar." + ad!.adId + "." +
Math.random().toString(36).slice(2, 8);
dlog("ext", "statusbar.ad.show", { adId: ad!.adId, corr });
metrics.send("impression_rendered", {
adId: ad!.adId, campaignId: ad!.campaignId,
ccVersion, corr, surface: "statusbar",
});
viewTickTimer = track(setInterval(() => {
if (!shownAd) return;
accrueVisible();
freshenToken();
metrics.send("view_tick", {
adId: shownAd.adId, campaignId: shownAd.campaignId,
ccVersion, corr, surface: "statusbar",
visibleMs: accruedVisibleMs,
sessionToken: shownAd.sessionToken,
});
}, VIEW_TICK_INTERVAL_MS));
} else {
// Re-assert each tick: self-heals any clobber by another setter
// (the 30s earnings refresh, offline/incompatible) so whenever a
// view_tick fires, a visible ad was repainted within the last ~1s.
// A suppressed repaint (needs-reload lock engaged mid-show) means
// the ad is no longer visible — end the show so billing stops.
if (statusBar.set({ kind: "ad", adText: shownAd!.adText }) === false) {
endShow();
}
}
} else if (showing) {
// No longer eligible while mid-display. Always end the show so
// view_tick stops and impression_viewable fires with the real
// visible duration.
endShow();
// Schedule the 6s revert ONLY on a clean thinking→idle transition.
// showActive() itself branches on sign-in (it paints the signed-out
// label when there's no token), so demo and real both revert cleanly.
// When kill interrupted the show, that setter already owns the bar —
// don't paint over it.
if (!thinking && !killedRef.current && !revertTimer) {
revertTimer = track(setTimeout(() => {
untrack(revertTimer);
revertTimer = null;
void showActive();
}, AD_DISPLAY_HOLD_MS));
}
}
} catch { /* prime directive: never break activation */ }
};
timers.push(setInterval(poll, POLL_INTERVAL_MS));
}