← back to Kickbacks Ai Upstream
src/adapters/claude-code/block.asset.js
1269 lines
/* VIBE-ADS-START */
(function () {
"use strict";
var TIER = __VIBE_ADS_TIER__;
var AD = __VIBE_ADS_AD__;
var ICON_REF = __VIBE_ADS_ICON__;
var ICON_URL = __VIBE_ADS_ICON_URL__;
var PORT = __VIBE_ADS_PORT__;
var LBTOKEN = __VIBE_ADS_LBTOKEN__;
var CLICKTOKEN = __VIBE_ADS_CLICKTOKEN__;
// Advertiser landing URL, rendered as the anchor's REAL href. Claude Code's
// webview ships `default-src 'none'` with no connect-src, so an in-page
// fetch/beacon to the loopback is CSP-blocked and postMessage only reaches
// CC's own extension (not ours). A genuine http(s) href is the one click-out
// that survives: the VS Code webview host itself opens it externally. The
// loopback /click ping below is now ONLY the (best-effort) billing metric.
var CLICKURL = __VIBE_ADS_CLICKURL__;
// When true the injected block also renders the ad in the usage-limit banner
// (mirror of the spinner ad; spec §3). False => no DOM scanning at all.
var BANNER_ON = __VIBE_ADS_BANNER_ON__;
// Correlation id (patch-time minted). Carried on the /click ping and every
// relayed dlog line so the merged debug stream is greppable end-to-end.
var CORR = __VIBE_ADS_CORR__;
var AD_ID = CORR.substring(0, CORR.lastIndexOf("."));
// Resolved by the extension via vscode.env.asExternalUri so the webview can
// reach the loopback on VS Code Remote/Server (raw 127.0.0.1 there is the
// CLIENT, not the extension host). Falls back to local 127.0.0.1.
var BASE = __VIBE_ADS_BASE__ || ("http://127.0.0.1:" + PORT + "/vibe-ads/" + LBTOKEN);
function fmtElapsed(ms) { return (ms / 1000).toFixed(1) + "s"; }
// 0..5 dots (6 frames). Advance is slowed via the render-loop cadence.
function ellipsis(frame) {
return ["", " .", " ..", " ...", " ....", " ....."][frame % 6];
}
function esc(s) {
return String(s).replace(/[&<>"]/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
});
}
// --- Usage-banner ad: banner locator -----------------------------------
// The orange weekly/usage-limit banner ("You've used 71% of your weekly
// limit · resets in 4d · View usage") is server-data-rendered, so there is
// no static array literal to Tier-0 swap — the only lever is a DOM rewrite
// from this injected block, exactly like the spinner. This predicate is the
// locator anchor: it must be specific enough that NOTHING else in the chat
// webview ever matches (rewriting unrelated chrome would be a prime-directive
// violation). Require BOTH a usage-percentage clause AND a reset clause —
// either one alone is too generic (docs/prose can say "resets in …").
function looksLikeUsageBanner(text) {
var t = String(text || "");
if (!t) return false;
var hasLimit = /\b\d{1,3}%\s+of your\b[^]*\b(?:weekly|usage)\s+limit\b/i.test(t);
var hasReset = /\bresets?\s+in\s+\d/i.test(t);
return hasLimit && hasReset;
}
var FAVICON_FALLBACK =
'<svg width="13" height="13" viewBox="0 0 13 13" aria-hidden="true" ' +
'style="vertical-align:middle;border-radius:3px;flex:0 0 auto">' +
'<rect width="13" height="13" rx="3" fill="#188a45"/>' +
'<text x="6.5" y="9.6" font-size="9" font-family="monospace" ' +
'font-weight="700" text-anchor="middle" fill="#fff">K</text></svg>';
// The ad-icon <img>. Tagged data-va-icon="1" so the capture-phase error
// listener (see init) can swap it for the inline 'K' SVG if the image fails
// to load — covers a blocked/404 icon. NB the swap MUST be wired
// programmatically: CC's webview CSP is script-src 'nonce-...' with no
// 'unsafe-inline', so an inline onerror="" attribute would itself be blocked.
function faviconImg(url) {
return '<img src="' + esc(url) + '" width="13" height="13" ' +
'data-va-icon="1" aria-hidden="true" ' +
'style="vertical-align:middle;border-radius:3px;' +
'flex:0 0 auto;display:block;object-fit:contain" />';
}
var FAVICON = ICON_URL ? faviconImg(ICON_URL) : FAVICON_FALLBACK;
// The block renders ONLY the ad, and ONLY while a turn is active. There is
// no done-state and no Continue CTA (removed): when Claude Code is idle the
// block does nothing and leaves the last rendered frame in place (the render
// loop simply stops clobbering it).
function buildAdHtml(tier, s) {
var ad = esc(s.ad), dots = esc(s.dots || "");
// Real navigable href (the VS Code host opens http(s) externally on click,
// bypassing the webview CSP). `target=_blank rel=noopener` is belt-and-
// suspenders; falls back to "#" only when no URL was supplied (tests).
var href = s.href ? esc(s.href) : "#";
// text-decoration:underline => the ad reads as the clickable hyperlink it
// is (was `none`, looked like inert label text).
var A1 = '<a href="' + href + '" target="_blank" ' +
'rel="noopener noreferrer" data-vibe-ads-ad="1" style="color:';
var FG = "var(--vscode-foreground,currentColor)";
var DIM = "var(--vscode-descriptionForeground,currentColor)";
// Animated dots live in a FIXED-WIDTH slot so their changing length
// can't reflow the left block and shove the right-hand timer around.
// Monospace-ish + width reserved for the longest.
var DOTS = '<span data-va-dots="1" style="display:inline-block;width:3ch;'
+ 'text-align:left;white-space:pre">' + dots + "</span>";
var anchor = A1 + FG + ';text-decoration:underline">' + ad + DOTS + "</a>";
if (tier <= 1) return anchor;
var fav = (tier >= 3) ? FAVICON : "";
var left = '<span style="display:flex;align-items:center;gap:7px;color:' +
FG + ';min-width:0">' + fav + A1 + FG +
';text-decoration:underline;overflow:hidden;text-overflow:ellipsis">' +
ad + DOTS + "</a></span>";
// Right segment PINNED to the right edge (margin-left:auto) and rendered
// in a tabular monospace so digit-count changes (0.9s→1.2s) don't jitter.
var rt = esc(s.elapsed || "");
var right = '<span data-va-elapsed="1" style="font-size:11px;color:' + DIM +
';flex:0 0 auto;margin-left:auto;padding-left:24px;' +
'font-family:var(--vscode-editor-font-family,ui-monospace,monospace);' +
'font-variant-numeric:tabular-nums">' + rt + "</span>";
// Left-justified favicon+ad (hugs the left edge); the timer is pinned
// right via margin-left:auto. box-sizing:border-box so the padding
// insets without 100% width overflowing the row.
return '<span style="display:flex;align-items:center;width:100%;' +
'box-sizing:border-box;padding:0 32px;' +
'justify-content:flex-start;white-space:nowrap">' + left + right +
"</span>";
}
// Usage-banner ad: a single-line clickable anchor mirroring the spinner
// creative. data-vibe-ads-ad="1" reuses the existing capture-phase click
// listener (no new click plumbing). Pure; unit-tested via module.exports.
function buildBannerHtml(ad, clickUrl) {
var href = /^https?:\/\//i.test(clickUrl || "") ? esc(clickUrl) : "#";
var FG = "var(--vscode-foreground,currentColor)";
// Wrap favicon+anchor in our own left-justified inline-flex so CC's
// banner container styling can't fling the text to the far right away
// from the logo; underline marks it as the clickable link it is.
return '<span style="display:inline-flex;align-items:center;gap:6px;' +
'justify-content:flex-start">' + FAVICON +
'<a href="' + href + '" target="_blank" rel="noopener noreferrer" ' +
'data-vibe-ads-ad="1" style="color:' + FG +
';text-decoration:underline">' + esc(ad) + "</a></span>";
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { fmtElapsed: fmtElapsed, ellipsis: ellipsis, esc: esc,
buildAdHtml: buildAdHtml, looksLikeUsageBanner: looksLikeUsageBanner,
buildBannerHtml: buildBannerHtml };
return;
}
try {
var st = { simStart: Date.now(), frame: 0, sentRender: false,
wasVisible: false };
// (Verb is now located by CC's `spinnerRow_` container class, telemetry-
// confirmed — NOT a glyph heuristic. The old glyph-prefix scan matched
// Monaco editor / **markdown** spans and clobbered the user's document,
// a prime-directive violation; class-scoping makes that impossible.)
var realAct = null;
// Cached refs to the volatile children of the overlay's anchor/right
// span. paint() updates only their textContent on the hot path so the
// anchor element itself is NEVER detached between mousedown/mouseup —
// the previous innerHTML-every-tick rewrite (~12×/sec) was racing the
// user's click and silently dropping it. Cleared on dropOverlay; refilled
// on a structural rebuild.
var _chromeSig = "", _dotsEl = null, _elapsedEl = null;
// Verb-pinned clobber model. The ad is written into Claude Code's OWN
// verb element; an observer replaces freshly-mounted verb nodes pre-paint
// (no flicker). A node is identified as the verb PURELY by current
// glyph-led content (NOT by an attribute) — an attribute marker poisoned
// the node forever once CC re-rendered its verb back into the same
// element in place, which is why the ad vanished mid-thinking and never
// returned. GRACE_MS bridges the (sometimes >1s) gaps between CC's
// intra-turn verb re-renders; only after a real GRACE of no verb at all
// do we clear (matches the long-proven loop). `verb.dom` dlog captures
// the real CC verb DOM (identity reuse / gap timings / structure) so the
// pin can be tightened from evidence next session.
var lastNode = null, lastSeenMs = 0;
// Idle debounce: how long the spinner may be stale before we drop the
// overlay. Empirically (e2e DOM dumps, CC 2.1.143): while thinking CC
// re-renders spinnerRow every animation frame so its textContent CHANGES
// continuously; at turn end CC empties spinnerRow to `<div></div>` but
// leaves the `.spinnerRow_` node mounted, and can leave a STALE prior node
// frozen mid-glyph. Glyph PRESENCE is therefore NOT a liveness signal — a
// frozen stale node kept rowActive() true forever, so paint() kept
// refreshing lastSeenMs and the ad animated at idle indefinitely (the
// row-03 regression, and why _spinnerActive never cleared so the banner
// ad never un-hid). The true signal is content FRESHNESS: active ==
// glyph-led AND the spinner content changed within GRACE_MS.
var GRACE_MS = 1500;
// Freshness: signature of the live spinner content + when it last
// changed. A frozen/stale node's signature never changes => idle after
// GRACE_MS even though a glyph char is still present.
var lastSig = null, lastSigMs = 0;
var _foundLogged = false, _shown = false;
var _verbSeq = 0, _lastVerbEl = null, _lastVerbMs = 0;
var DEBUG = __VIBE_ADS_DEBUG__;
var _seq = 0, _dlogFails = 0;
// Relay a timestamped lifecycle line to the loopback (→ server-side
// ~/.vibe-ads/debug.log) so a headless agent can diagnose without a
// screen. Gated; never disturbs the spinner. The first line arriving at
// all is itself the proof the loopback is reachable on Remote.
function dlog(evt, data) {
if (!DEBUG) return;
try {
var o = { n: ++_seq, evt: evt, corr: CORR };
if (data) for (var k in data) o[k] = data[k];
fetch(BASE + "/log", { method: "POST", keepalive: true,
headers: { "content-type": "application/json" },
body: JSON.stringify(o) }).catch(function () { _dlogFails++; });
} catch (e) { _dlogFails++; }
}
function ping(kind) {
// Click pings have to race the VS Code webview host's external-
// navigation on the same tick: the moment the anchor's href fires,
// the document may be torn down. fetch+keepalive often makes it
// out, but sendBeacon was designed for exactly this lifecycle
// ("send this beacon during unload, browser, please") and is the
// most reliable path across Electron versions. Try sendBeacon
// first; fall back to fetch+keepalive if unavailable or rejected.
try {
var url = BASE + "/" + kind;
var sent = false;
try {
if (navigator && typeof navigator.sendBeacon === "function") {
sent = navigator.sendBeacon(url, new Blob([],
{ type: "application/x-www-form-urlencoded" }));
if (sent) dlog("ping.ok", { kind: kind, via: "beacon" });
}
} catch (e) { sent = false; }
if (sent) return;
fetch(url, { method: "POST", keepalive: true })
.then(function () { dlog("ping.ok", { kind: kind, via: "fetch" }); })
.catch(function () { dlog("ping.fail", { kind: kind, via: "fetch" }); });
} catch (e) { /* metrics best-effort; never affect the spinner */ }
}
function newEventUuid() {
try {
if (typeof crypto !== "undefined"
&& typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
} catch (e) { /* fall through */ }
try {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,
function (c) {
var r = Math.random() * 16 | 0;
var v = c === "x" ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
} catch (e) { return "evt-" + Date.now() + "-" + Math.random(); }
}
// ---- W3 view-time accumulator (absolute-epoch baseline) -------------
// An ad session's elapsed time is computed as `Date.now() -
// sessionStartedAt` on every 250 ms poll. No accumulator, no
// pause-on-hide. Rationale: the prior accumulator-with-pause model
// dropped time whenever setInterval skipped a tick (throttled tab,
// CPU pressure, the webview backgrounded) and produced "stuck session
// never bills" log signatures. Absolute wall clock is immune to poll
// cadence drift, at the cost of counting time while the webview is
// hidden — bounded in practice by the threshold-met/error_impression
// mutex (one bill per session) plus the server-side cooldown gate.
// Threshold is server-overridable via /v1/portfolio.view_threshold_seconds
// (baked into the block as __VIBE_ADS_VIEW_THRESHOLD_MS__; falls back
// to 15_000 ms when the server didn't specify). Pure best-effort:
// any throw is swallowed (prime directive).
var THRESHOLD_MS = (typeof __VIBE_ADS_VIEW_THRESHOLD_MS__ === "number"
&& __VIBE_ADS_VIEW_THRESHOLD_MS__ > 0)
? __VIBE_ADS_VIEW_THRESHOLD_MS__ : 15000;
var TICK_MS = 5000;
// MAX_SESSION_MS billing cap: fire `error_impression` at EVERY
// multiple of this elapsed mark. Default 5 s, so a 30 s stuck
// session fires 6 events. The backend cooldown gate decides which
// of those actually credit; with the matching 5 s cooldown default
// every fire credits. threshold_met is suppressed once any
// error_impression has fired this session (preserves the codex-
// rescue "one billing path per session" mutex while allowing the
// billing path itself to repeat).
var MAX_SESSION_MS = 5000;
var SESSION_NONCE = (function () {
try {
return (Math.random().toString(36).slice(2)
+ Math.random().toString(36).slice(2)).slice(0, 16);
} catch (e) { return "s" + Date.now(); }
})();
var _vt = Object.create(null); // key "surface:adId" -> session record
function vtKey(adId, surface) { return surface + ":" + adId; }
function viewShow(adId, surface) {
if (!adId) return;
var k = vtKey(adId, surface);
var s = _vt[k];
if (!s) {
// sessionStartedAt is set ONCE on session creation and is sticky
// for the lifetime of (adId, surface, sessionNonce). Subsequent
// viewShow() calls with the same nonce do NOT restart it.
// errorImpressionCount tracks how many error_impressions have
// fired so far; the next one fires at (count+1)*MAX_SESSION_MS.
_vt[k] = { adId: adId, surface: surface,
sessionNonce: SESSION_NONCE,
sessionStartedAt: Date.now(),
lastTickMs: 0, thresholdMet: false,
errorImpressionCount: 0,
// PERSIST-AT-IDLE billing pause. While `paused` no events emit
// and elapsed is frozen at `pausedAt`; the next active turn
// resumes by advancing sessionStartedAt past the idle gap so the
// idle time is NEVER billed. (overlay surface only — see
// viewPause/dockOverlay.)
paused: false, pausedAt: 0 };
return;
}
// Resume a paused (idle-frozen) session: shift the absolute baseline
// forward by the idle gap so the time the ad merely sat on screen is
// not counted, then un-pause. lastTickMs/errorImpressionCount are
// unchanged so emission cadence continues exactly where it left off.
if (s.paused) {
s.sessionStartedAt += Math.max(0, Date.now() - (s.pausedAt || Date.now()));
s.paused = false;
s.pausedAt = 0;
}
// No further anchor/baseline mutation — the absolute-epoch model uses
// a single sticky sessionStartedAt (adjusted only across idle pauses).
}
// Suspend billing for an ad+surface without dropping its session, so the
// overlay can persist on screen at idle while emitting NO view-time
// events (user request: do not induce billing during idle periods).
function viewPause(adId, surface) {
if (!adId) return;
var s = _vt[vtKey(adId, surface)];
if (s && !s.paused) { s.paused = true; s.pausedAt = Date.now(); }
}
// END a view session outright (ported from the codex phantom-billing
// fix — codex/block.asset.js viewEnd). Deleting the record stops the
// 250ms accumulator the moment the ad leaves the screen; a later
// viewShow() opens a FRESH session (new baseline, counters reset).
function viewEnd(adId, surface) {
try { delete _vt[vtKey(adId, surface)]; } catch (e) { /* best-effort */ }
}
function viewHide(adId, surface) {
// Was a documented no-op, which left every hide call site —
// dropOverlay, banner-hidden-during-turn, banner-gone — with an
// immortal session emitting view_tick/error_impression every 5s
// off-screen (the exact codex phantom-billing class). Hide now ENDS
// the session. PERSIST-AT-IDLE is unaffected: dockOverlay uses
// viewPause(), which keeps the session for the thaw to resume.
viewEnd(adId, surface);
}
function viewMaybeEmit(s) {
// Paused (idle-frozen) sessions emit nothing: no view_tick,
// threshold_met, or error_impression while the ad merely persists.
if (s.paused) return;
var elapsed = Math.max(0, Date.now() - s.sessionStartedAt);
// Tick at every TICK_MS boundary of elapsed time.
var tickFired = false;
while (elapsed - s.lastTickMs >= TICK_MS) {
s.lastTickMs += TICK_MS;
tickFired = true;
var tickEventUuid = newEventUuid();
var q = "?surface=" + encodeURIComponent(s.surface)
+ "&ad=" + encodeURIComponent(s.adId)
+ "&visible_ms=" + s.lastTickMs
+ "&session=" + encodeURIComponent(s.sessionNonce)
+ "&event_uuid=" + encodeURIComponent(tickEventUuid);
ping("view_tick" + q);
}
// When view_tick fires, advance errorImpressionCount past the same
// boundary so error_impression can't re-fire it on the next poll
// (tickFired resets to false each call).
if (tickFired && MAX_SESSION_MS > 0) {
var syncCount = Math.floor(s.lastTickMs / MAX_SESSION_MS);
if (syncCount > s.errorImpressionCount) {
s.errorImpressionCount = syncCount;
}
}
// Mutual exclusion: threshold_met fires once per session AND only
// when no error_impression has fired yet this session. With the
// default cap=5s / threshold=15s, error_impression always fires
// first → threshold_met effectively never fires. Kept for the
// disabled-cap config (MAX_SESSION_MS=0).
if (!s.thresholdMet && s.errorImpressionCount === 0
&& elapsed >= THRESHOLD_MS) {
s.thresholdMet = true;
var thresholdEventUuid = newEventUuid();
var q2 = "?surface=" + encodeURIComponent(s.surface)
+ "&ad=" + encodeURIComponent(s.adId)
+ "&visible_ms=" + elapsed
+ "&threshold_ms=" + THRESHOLD_MS
+ "&session=" + encodeURIComponent(s.sessionNonce)
+ "&event_uuid=" + encodeURIComponent(thresholdEventUuid);
dlog("view.threshold_met", { adId: s.adId, surface: s.surface,
visibleMs: elapsed, eventUuid: thresholdEventUuid });
ping("view_threshold_met" + q2);
}
// error_impression is the fallback for view_tick — only fire when
// view_tick did NOT fire this poll. When both hit the same 5s
// boundary they race the backend cooldown gate and double-bill.
var nextFireAt = (s.errorImpressionCount + 1) * MAX_SESSION_MS;
if (!tickFired && !s.thresholdMet && elapsed >= nextFireAt) {
s.errorImpressionCount += 1;
var errorEventUuid = newEventUuid();
var q3 = "?surface=" + encodeURIComponent(s.surface)
+ "&ad=" + encodeURIComponent(s.adId)
+ "&visible_ms=" + elapsed
+ "&max_session_ms=" + MAX_SESSION_MS
+ "&fire=" + s.errorImpressionCount
+ "&session=" + encodeURIComponent(s.sessionNonce)
+ "&event_uuid=" + encodeURIComponent(errorEventUuid);
dlog("view.error_impression", { adId: s.adId, surface: s.surface,
visibleMs: elapsed, fire: s.errorImpressionCount,
eventUuid: errorEventUuid });
ping("error_impression" + q3);
}
}
// Snapshot of the elapsed session time for an ad+surface RIGHT NOW.
// Read at click time so the loopback can apply the click-threshold
// floor: clicks before the ad has been on-screen long enough are
// logged but not billed (anti-misclick / anti-bot). Returns 0 when
// the surface was never shown.
function viewVisibleMsNow(adId, surface) {
try {
var s = _vt[vtKey(adId, surface)];
if (!s) return 0;
// While paused, elapsed is frozen at pausedAt (idle time excluded).
var end = (s.paused && s.pausedAt) ? s.pausedAt : Date.now();
return Math.max(0, end - s.sessionStartedAt);
} catch (e) { return 0; }
}
// Suspend/wake clamp: elapsed is absolute wall-clock, so a laptop
// suspend (or a long-frozen webview) would otherwise be billed as
// visible time AND replayed as a synchronous view_tick burst on wake
// (one tick per 5s of sleep — ~5760 POSTs after an 8h sleep). viewTick
// tracks when it last polled; a gap of more than two tick intervals
// means nothing was actually on screen, so every live (unpaused)
// session's baseline is shifted forward past the gap, keeping at most
// one TICK_MS of it billable — the catch-up loop then fires at most
// ONE tick per wake. Paused sessions are skipped: their resume path
// already excludes the whole pause window via pausedAt.
var SUSPEND_GAP_MS = TICK_MS * 2;
var _vtLastPollMs = 0;
function viewTick() {
try {
var now = Date.now();
var gap = _vtLastPollMs > 0 ? (now - _vtLastPollMs) : 0;
_vtLastPollMs = now;
if (gap > SUSPEND_GAP_MS) {
var excess = gap - TICK_MS;
for (var pk in _vt) {
var ps = _vt[pk];
if (!ps.paused) {
// Math.min: never push a baseline into the future (a session
// created post-wake, before this poll, must start at 0).
ps.sessionStartedAt =
Math.min(now, ps.sessionStartedAt + excess);
}
}
}
for (var k in _vt) viewMaybeEmit(_vt[k]);
} catch (e) { /* prime directive */ }
}
setInterval(viewTick, 250);
// visibilitychange flush removed in the absolute-epoch baseline: a
// hidden webview keeps counting. Worst case bound = one bill per ad
// per cooldown window (mutex + server-side gate). See top-of-section
// comment for the full rationale.
dlog("block.start", { base: BASE, tier: TIER, href: location.href });
var _actLogged = false;
function pollActivity() {
try {
fetch(BASE + "/activity").then(function (r) { return r.json(); })
.then(function (j) {
realAct = j;
if (!_actLogged) { _actLogged = true;
dlog("activity.first", { tool: j && j.tool, done: j && j.done }); }
}).catch(function () {
if (!_actLogged) { _actLogged = true; dlog("activity.fail"); }
});
} catch (e) { /* ignore */ }
}
// Telemetry-CONFIRMED: CC's "thinking" verb is a container whose class is
// `spinnerRow_<hash>` (inside `messagesContainer_<hash> stickyMode_…`),
// and CC animates its glyph in a CHILD of that row. We target THAT exact
// element by its stable `spinnerRow_` class prefix — NOT a glyph/markdown
// heuristic (which clobbered the Monaco editor: prime-directive
// violation) and NOT by destroying its innerHTML (which killed CC's
// animated child, froze our liveness signal, and made the ad vanish
// after GRACE and never return). The hash suffix can change across CC
// builds; matching the prefix substring is resilient, and if CC renames
// it entirely the ad simply doesn't show (safe — prime directive over
// visibility; the verb.dom telemetry will reveal the new class).
// Prefer a spinnerRow with NON-EMPTY content (the live one). At turn end
// CC empties the current spinnerRow to `<div></div>` (textContent "") but
// keeps the `.spinnerRow_` node — returning that emptied node made the
// idle test rely on rowActive() alone. An emptied/whitespace-only row is
// treated as "no spinner" (=> idle path); only a node with real content
// is a candidate. Still strictly class-scoped (cannot match editor /
// markdown — prime directive preserved).
// Audit #28: among those already-matched candidates prefer the LAST
// non-empty row in document order. The transcript APPENDS, so the live
// animating row is always the latest; CC can leave a STALE prior row
// frozen mid-glyph (see GRACE_MS note above) while keeping it mounted —
// returning the FIRST non-empty row let that dead row shadow the live
// one mounted below it, so the freshness signature never changed and
// the ad was suppressed for the whole turn. The selector and the
// observation scope are UNCHANGED (prime directive: never widen
// detection); only the choice AMONG matched rows differs. Known corner:
// when the live row is emptied at turn end while a stale row remains,
// the stale row briefly becomes "the" row again — its frozen signature
// fails the freshness gate within GRACE_MS, so the normal idle dock
// follows, same as before.
function findSpinner() {
var els = document.querySelectorAll('[class*="spinnerRow_"]');
var last = null;
for (var i = 0; i < els.length; i++) {
if (els[i].nodeType !== 1) continue;
if ((els[i].textContent || "").trim() !== "") last = els[i];
}
return last;
}
// Liveness gate: CC keeps spinnerRow MOUNTED after the turn (it only
// vanished before because our mutation made React tear it out — we no
// longer mutate). So "row present" is not idle-vs-active. CC animates a
// sparkle glyph (✢✶✻✽) while thinking and stops it at turn end. Reading
// the row's textContent is read-only / React-safe; this only ever
// inspects the element already pinned by the spinnerRow_ class, so it
// can never match editor/markdown text (no prime-directive risk).
function rowActive(row) {
if (!row) return false;
var t = (row.textContent || "").replace(/^[\s ]+/, "");
var c = t.charCodeAt(0);
// ✢ U+2722, ✶ U+2736, ✻ U+273B, ✽ U+273D
return c === 0x2722 || c === 0x2736 || c === 0x273b || c === 0x273d;
}
// The overlay MUST be opaque to cover CC's real verb glyph behind it
// (transparent => the verb shows through and overlaps the ad into
// garble). Sample CC's actual surface colour read-only (getComputedStyle
// is reconciliation-safe): walk ancestors for the first non-transparent
// background so the box blends into the chat panel (theme-matched) and
// is never a hardcoded mismatched colour.
function surfaceBg(el) {
try {
var n = el, hops = 0;
while (n && n.nodeType === 1 && hops++ < 10) {
var bg = (window.getComputedStyle(n) || {}).backgroundColor;
if (bg && bg !== "transparent" &&
bg !== "rgba(0, 0, 0, 0)") return bg;
n = n.parentElement;
}
} catch (e) { /* no layout / jsdom — fall through */ }
return "var(--vscode-editor-background,#1e1e1e)";
}
// Clicking the ad: the anchor's real http(s) href is what actually opens
// the advertiser page — the VS Code webview host navigates it externally,
// which is the ONLY click-out that survives CC's `default-src 'none'` CSP.
// We therefore do NOT preventDefault (that would suppress the host's open).
// The loopback /click ping is now purely the fire-and-forget billing
// metric (CSP-revived by the companion extension.js connect-src patch);
// losing it must never cost the click-through. Capture phase, fully guarded.
document.addEventListener("click", function (ev) {
var el = ev.target;
while (el && el !== document) {
if (el.getAttribute && el.getAttribute("data-vibe-ads-ad")) {
// Walk up to find which surface contains this anchor so the click
// ping carries surface= (mirrors impression events).
var surface = "overlay";
var p = el;
while (p && p !== document) {
try {
if (p.getAttribute && p.getAttribute("data-vibe-ads-banner") === "1") {
surface = "banner"; break;
}
if (p.getAttribute && p.getAttribute("data-vibe-ads-overlay") === "1") {
surface = "overlay"; break;
}
} catch (e) { /* prime directive */ }
p = p.parentNode;
}
// Include the current cumulative visible_ms so the extension-
// side loopback can apply the click-threshold floor (don't bill
// clicks before the ad has been visible long enough).
var vms = viewVisibleMsNow(AD, surface);
var clickEventUuid = newEventUuid();
dlog("click.ad", { ct: CLICKTOKEN, surface: surface,
visibleMs: vms, eventUuid: clickEventUuid });
// `ad=` is the attribution CLAIM — the same param the view-event
// pings carry (the pollAd-adopted identifier the block keys its
// _vt sessions on, i.e. the AD text). Without it the host's
// recent-ads registry (audit #17) could not resolve a click that
// lands during the ≤10s /ad poll lag after a rotation and billed
// it to the freshly-rotated campaign instead.
ping("click?ct=" + encodeURIComponent(CLICKTOKEN)
+ "&corr=" + encodeURIComponent(CORR)
+ "&surface=" + encodeURIComponent(surface)
+ "&visible_ms=" + vms
+ "&ad=" + encodeURIComponent(AD)
+ "&event_uuid=" + encodeURIComponent(clickEventUuid));
return;
}
el = el.parentNode;
}
}, true);
// Ad-icon load failure → inline 'K' badge. The icon normally arrives as a
// CSP-safe data: URI; this catches the residual cases (a stored https GCS
// URL the backend couldn't inline, or a genuine 404) so the slot never
// renders empty. `error` events don't bubble but DO fire in the capture
// phase on ancestors, so one document-level capture listener covers the
// overlay AND the banner. Programmatic by necessity — an inline onerror=""
// is blocked by CC's webview script-src CSP. Idempotent: swapping outerHTML
// for the SVG removes the data-va-icon node, so it can't re-fire.
document.addEventListener("error", function (ev) {
try {
var t = ev && ev.target;
if (t && t.tagName === "IMG" &&
t.getAttribute && t.getAttribute("data-va-icon") === "1") {
t.outerHTML = FAVICON_FALLBACK;
}
} catch (e) { /* prime directive: never let this disturb the page */ }
}, true);
// --- Usage-banner rewrite loop -------------------------------------
// Runs every 1 s independent of the spinner's `active` gate (the banner
// is visible at idle). findUsageBanner picks the tightest matching element
// via looksLikeUsageBanner; when found, replaces its innerHTML with a
// single clickable ad anchor (buildBannerHtml) mirroring the spinner
// creative. Guarded against rewrite thrash via _bannerLast equality check;
// found/miss each logged once.
function findUsageBanner() {
var els = document.querySelectorAll("span, div, p");
var best = null, bestLen = Infinity;
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.children.length > 8) continue; // not a leaf-ish node
var t = (el.textContent || "").trim();
if (t.length > 240) continue; // banner is short
if (!looksLikeUsageBanner(t)) continue;
if (t.length < bestLen) { best = el; bestLen = t.length; }
}
return best;
}
var _bannerLogged = false, _bannerLast = "", _bannerMissLogged = false;
var _bannerEl = null;
// Fires impression_rendered/viewable exactly once per session for the
// banner surface. Spinner has its own (st.sentRender / st.wasVisible).
var _bannerSentRender = false, _bannerSentViewable = false;
// No-serve latch (wave-4 carry-over of the wave-2 /ad gate): the host
// answers /ad with an EMPTY payload on a confirmed kill, a deliberate
// disable, or any serving-gate refusal. A fetch ERROR is transient
// network and keeps today's keep-last-ad behavior; a SUCCESSFUL empty
// response is the no-serve signal. After TWO empty reads with no served
// payload between them (debounce against a single racing read; fetch
// errors neither count nor reset — only a real payload proves serving
// resumed) the block stops SHOWING anything — overlay dropped, banner
// hidden, every view session ended — instead of leaving the last
// creative painted (unbillable but visible) until idle/reload. Any
// served payload re-arms. The signed-out demo flow serves real
// payloads, so this only engages on a genuine stop.
var _adEmptyPolls = 0, _noServe = false;
if (BANNER_ON) setInterval(function () {
try {
// No-serve: keep the banner hidden and its session ended until the
// host serves again (see pollAd / enterNoServe).
if (_noServe) {
if (_bannerEl) {
try { _bannerEl.style.setProperty("display", "none", "important"); }
catch (e) { /* prime directive */ }
}
try { viewHide(AD, "banner"); } catch (e) { /* prime directive */ }
return;
}
// IDLE-ONLY (user request): while CC is actively thinking the
// spinner ad covers that turn — hide the banner ad. We keep a ref
// to the element we clobbered (its text no longer matches
// looksLikeUsageBanner, so findUsageBanner can't re-find it) and
// just toggle display.
if (_spinnerActive) {
if (_bannerEl) {
try { _bannerEl.style.setProperty("display", "none", "important"); }
catch (e) { /* prime directive */ }
}
// W3: banner is hidden during spinner; END its visibility session
// (it must not bill while display:none — a fresh session starts
// when the banner un-hides at idle).
try { viewHide(AD, "banner"); } catch (e) { /* prime directive */ }
return;
}
var el = (_bannerEl && _bannerEl.isConnected)
? _bannerEl : findUsageBanner();
if (!el) {
if (!_bannerMissLogged) { _bannerMissLogged = true;
dlog("banner.miss", {}); }
// No banner element to render into → make sure the accumulator
// isn't counting.
try { viewHide(AD, "banner"); } catch (e) { /* prime directive */ }
return;
}
try { el.style.removeProperty("display"); } catch (e) {} // un-hide
if (!_bannerLogged) { _bannerLogged = true;
dlog("banner.found", { len: (el.textContent || "").length }); }
var html = buildBannerHtml(AD, CLICKURL);
if (el.getAttribute("data-vibe-ads-banner") === "1" &&
_bannerLast === html) {
// Still mounted + unchanged: ensure accumulator is running.
try { viewShow(AD, "banner"); } catch (e) { /* prime directive */ }
return;
}
el.innerHTML = html;
el.setAttribute("data-vibe-ads-banner", "1");
_bannerEl = el; _bannerLast = html;
// W3: banner is now visible — start (or resume) accumulation.
try { viewShow(AD, "banner"); } catch (e) { /* prime directive */ }
// Initial impressions for the banner surface. Guarded so we only
// fire once per block session (true first commit of the banner ad).
if (!_bannerSentRender) {
ping("impression_rendered?surface=banner&ad=" + encodeURIComponent(AD)
+ "&event_uuid=" + encodeURIComponent(newEventUuid()));
_bannerSentRender = true;
}
if (!_bannerSentViewable) {
var bVis = (typeof document.hidden === "undefined")
? true : !document.hidden;
if (bVis) {
ping("impression_viewable?surface=banner&ad="
+ encodeURIComponent(AD)
+ "&event_uuid=" + encodeURIComponent(newEventUuid()));
_bannerSentViewable = true;
}
}
} catch (e) {
dlog("banner.error", { msg: String(e && e.message || e).slice(0, 160) });
}
}, 1000);
// BODY-LEVEL OVERLAY — the ONLY React-safe architecture (proven 3×: any
// mutation of CC's spinner subtree — innerHTML, child append, child
// restyle — makes CC's next React reconciliation tear spinnerRow out and
// never re-render it that turn). So we NEVER touch CC's tree: read its
// rect READ-ONLY (getBoundingClientRect is reconciliation-safe) and paint
// the ad in OUR OWN element appended to <body>, OUTSIDE React's roots,
// absolutely positioned over the spinner. CC's spinner then behaves
// natively (stays the whole turn, removed at turn end) so detection is
// reliable every tick — the recurring "vanish / never return" is
// structurally impossible. Liveness = spinnerRow PRESENT (not its rect:
// jsdom has no layout, and presence is the true signal).
var overlay = null;
// True while CC is actively thinking (spinner ad shown). The usage-banner
// ad is gated on the INVERSE of this: shown only at idle, hidden during a
// turn (user request — it was visible all the time). It STAYS true while
// the overlay is frozen at idle (below), so the persisted spinner ad
// remains the sole ad surface and the banner stays suppressed.
var _spinnerActive = false;
// PERSIST-AT-IDLE → DOCK-TO-COMPOSER (user request): when CC goes idle we
// don't drop the overlay AND we don't strand it at its last viewport pixel
// (that floated over transcript content on scroll). Instead `_frozen`
// marks idle (billing paused, "thinking" animation stopped) and we DOCK the
// overlay as a compact line just above CC's input/composer box — a stable,
// always-present bottom anchor — so it stays parked and out of the scrolled
// transcript. The next active turn thaws (paint re-glues to the verb +
// viewShow resumes the same session). If the composer can't be located on a
// given CC build, we DROP the idle ad (prime-directive fallback) rather
// than strand it. While idle the ad accrues NO view-time.
var _frozen = false; // idle: billing paused, animation stopped
var _docked = false; // idle overlay re-anchored above the composer
var _dockNode = null; // cached composer element (read-only rect target)
function ensureOverlay(row) {
if (overlay && overlay.parentNode) return overlay;
overlay = document.createElement("div");
overlay.setAttribute("data-vibe-ads", String(TIER));
overlay.setAttribute("data-vibe-ads-overlay", "1");
// OPAQUE, theme-matched bg: it must cover CC's verb glyph behind it
// (transparent => verb shows through and overlaps the ad). The
// earlier "box flashes over the input" concern is solved separately
// by visibility:hidden until the FIRST placeOverlay sets real coords
// — so opaque is safe AND the flash is gone.
overlay.style.cssText =
"position:fixed;z-index:2147483646;pointer-events:auto;" +
"display:flex;align-items:center;box-sizing:border-box;" +
"overflow:hidden;white-space:nowrap;visibility:hidden;background:" +
surfaceBg(row);
try { (document.body || document.documentElement).appendChild(overlay); }
catch (e) { /* body not ready yet — retried next tick */ }
return overlay;
}
var _rect = "";
function placeOverlay(row) {
try {
var r = row.getBoundingClientRect();
if (r && (r.width || r.height || r.top || r.left)) {
// Only WRITE styles when the rect actually moved — a redundant
// style write every 140ms forces needless recalc/compositing and
// looked glitchy. (read is unavoidable; the write is what we gate.)
var key = r.left + "," + r.top + "," + r.width + "," + r.height;
if (key !== _rect) {
_rect = key;
overlay.style.left = r.left + "px";
overlay.style.top = r.top + "px";
overlay.style.minWidth = r.width + "px";
overlay.style.height = r.height + "px";
overlay.style.visibility = "visible"; // reveal once positioned
}
}
} catch (e) { /* no layout (jsdom) — overlay still renders the ad */ }
}
// Locate CC's input/composer box — the stable, always-present element at
// the panel bottom that the idle ad docks above. READ-ONLY (querySelector +
// getBoundingClientRect only; the composer is NEVER mutated — prime
// directive). This runs in the CC WEBVIEW document, which is isolated from
// the VS Code workbench, so a generic editable-textbox selector cannot
// match the Monaco editor. CC's composer is a contenteditable textbox
// (newer builds use contenteditable="plaintext-only", not "true"); we try
// the most specific selector first, then a plain textarea, and among
// matches pick the LOWEST visible one (the composer sits at the bottom).
// Returns null if nothing matches (CC restructured) — the caller then DROPS
// the idle ad rather than stranding it. The matched selector/class is
// logged once (composer.found) so the locator can be hardened to a stable
// hashed class later, the way the spinnerRow_ locator was.
var _composerLogged = false;
function findComposer() {
try {
var sels = ['[contenteditable][role="textbox"]',
'div[contenteditable]', 'textarea'];
for (var s = 0; s < sels.length; s++) {
var els = document.querySelectorAll(sels[s]);
var best = null, bestTop = -1;
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (el.nodeType !== 1) continue;
var r = el.getBoundingClientRect();
if (!(r.width > 0 && r.height > 0)) continue; // visible only
if (r.top > bestTop) { bestTop = r.top; best = el; }
}
if (best) {
if (!_composerLogged) { _composerLogged = true;
dlog("composer.found", { sel: sels[s], tag: best.tagName,
cls: String(best.className || "").slice(0, 80) }); }
return best;
}
}
} catch (e) { /* no layout / prime directive */ }
return null;
}
// Park the idle overlay as a compact line in the gap just above the
// composer's top edge. Same read-only-rect technique as placeOverlay; keeps
// the overlay's existing height (the last verb line-height) so it stays a
// compact line and spans the composer width (theme-bg blends it in). The
// "dock," key prefix keeps it distinct from placeOverlay's key so the first
// dock write always lands.
function placeDocked(composer) {
try {
var r = composer.getBoundingClientRect();
if (r && (r.width || r.height || r.top || r.left)) {
var h = overlay.offsetHeight || 20;
var GAP = 4;
var top = r.top - h - GAP;
if (top < 0) top = 0; // clamp if composer near top
var key = "dock," + r.left + "," + top + "," + r.width;
if (key !== _rect) {
_rect = key;
overlay.style.left = r.left + "px";
overlay.style.top = top + "px";
overlay.style.minWidth = r.width + "px";
overlay.style.visibility = "visible";
}
}
} catch (e) { /* no layout */ }
}
function dropOverlay() {
// W3: stop the overlay-surface visibility accumulator for the
// current ad. Banner accumulator (if running) is independent.
try { viewHide(AD, "overlay"); } catch (e) { /* prime directive */ }
try { if (overlay && overlay.parentNode)
overlay.parentNode.removeChild(overlay); } catch (e) {}
overlay = null; _rect = ""; _shown = false;
st.wasVisible = false; _spinnerActive = false; _frozen = false;
_docked = false; _dockNode = null;
st.simStart = 0;
_chromeSig = ""; _dotsEl = null; _elapsedEl = null;
}
// Idle transition (replaces the old freeze-at-pixel). DOCK the overlay as a
// compact line above CC's composer and pause billing. We deliberately keep
// `overlay`, `lastNode`, `_spinnerActive`, `st.sentRender`, and
// `st.wasVisible` set: the next active turn thaws (paint re-glues to the
// verb + viewShow resumes the same impression session — no duplicate
// impression_rendered/viewable). st.simStart is cleared so the per-turn
// elapsed timer restarts at 0 next turn. The "thinking" dots are stopped
// (cleared via the cached child's textContent — NEVER innerHTML, which would
// detach the click anchor; see the clickable-overlay rule). PRIME-DIRECTIVE
// FALLBACK: if the composer can't be located, DROP the ad rather than
// strand it over content.
function dockOverlay() {
if (_frozen) return;
var composer = findComposer();
if (!composer) {
dlog("loop.idle.dock_miss_drop", {});
dropOverlay();
lastNode = null;
return;
}
_frozen = true;
_docked = true;
_dockNode = composer;
try { viewPause(AD, "overlay"); } catch (e) { /* prime directive */ }
st.simStart = 0;
try { if (_dotsEl) _dotsEl.textContent = ""; } catch (e) { /* safe */ }
placeDocked(composer); // reposition immediately so it never strands
dlog("loop.idle.dock", {});
}
// Host stopped serving (two consecutive empty /ad payloads — see the
// _noServe declaration). Tear down every ad surface via the EXISTING
// drop paths and end every view session: the wave-2 host gate already
// refuses to BILL a stale overlay, this removes the PIXELS too.
function enterNoServe() {
if (_noServe) return; // idempotent
_noServe = true;
dlog("ad.no_serve", { emptyPolls: _adEmptyPolls });
try { dropOverlay(); } catch (e) { /* best-effort */ }
lastNode = null;
try { if (_bannerEl) _bannerEl.style.setProperty(
"display", "none", "important"); } catch (e) { /* prime directive */ }
try { viewHide(AD, "banner"); } catch (e) { /* best-effort */ }
// Belt & suspenders: END every remaining session. dropOverlay ended
// the overlay's and viewHide the banner's; the wipe covers stragglers
// so nothing can emit another view event while no-serve holds.
try { _vt = Object.create(null); } catch (e) { /* best-effort */ }
}
// Audit #27: pollAd adopted a DIFFERENT ad while the overlay is parked
// at idle (docked + frozen). paint() only runs in the active branch, so
// without this the docked line kept showing the OLD creative — its
// clicks opened the old URL but could not attribute (the old session is
// gone → visible_ms 0 → host 15s click floor) or misattributed to the
// new adId. Retarget the EXISTING stable child nodes ONLY: the ad-text
// TEXT NODE inside the anchor (nodeValue write) and the anchor's href
// attribute. NEVER innerHTML and never re-create the anchor — rewriting
// a live clickable element detaches it mid-click (a shipped bug, fixed
// once). The icon is retargeted in place too (a src swap on the EXISTING
// img — the img is a SIBLING of the anchor, so the anchor is untouched):
// the swap used to be deferred to the next thaw's rebuild, which left the
// NEW ad's text docked beside the OLD ad's logo for the whole idle
// period. When the icon node shape doesn't match the new creative (img
// vs inline 'K' SVG) there is no anchor-safe in-place swap — fall through
// to the drop path; a missing overlay beats a crossed icon/ad pair.
// Billing: pollAd already wiped
// _vt (the old ad's session is ENDED); the docked state stays
// NON-billing per persist-at-idle, so NO live session is started here —
// the next thaw's viewShow(AD) opens the NEW ad's session, and the
// click ping reads the swapped AD module var, so both attribute to the
// new ad. If the docked structure can't be retargeted safely
// (unexpected children), fall back to the existing drop path so a stale
// creative is never shown.
function retargetDockedOverlay() {
try {
var a = overlay && overlay.querySelector('a[data-vibe-ads-ad]');
var tn = a && a.firstChild;
if (!a || !tn || tn.nodeType !== 3) {
dlog("ad.dock_retarget_drop", {});
dropOverlay();
lastNode = null;
return;
}
// Icon shape check BEFORE any mutation so a drop never leaves a
// half-retargeted overlay. Tier <3 renders no icon at all — skip.
var icon = (TIER >= 3)
? overlay.querySelector('img[data-va-icon="1"]') : null;
if (TIER >= 3 && (ICON_URL ? !icon : !!icon)) {
// New creative needs an img but the docked one has the 'K' SVG
// (or vice versa): no anchor-safe in-place swap exists.
dlog("ad.dock_retarget_drop", { why: "icon_shape" });
dropOverlay();
lastNode = null;
return;
}
tn.nodeValue = AD; // text node only — anchor never detached
a.setAttribute("href", CLICKURL ? CLICKURL : "#");
if (icon && ICON_URL) icon.setAttribute("src", ICON_URL);
dlog("ad.dock_retarget", { toId: AD_ID });
} catch (e) {
// Never show a stale creative: drop on any error (prime directive —
// dropOverlay only touches OUR element).
try { dropOverlay(); lastNode = null; } catch (e2) { /* no-op */ }
}
}
function paint(row, anim) {
var now = Date.now();
_verbSeq++;
var gap = _lastVerbMs ? (now - _lastVerbMs) : 0;
var reused = (row === _lastVerbEl);
if (_verbSeq <= 12 || !reused || gap > 1200) {
dlog("verb.dom", { seq: _verbSeq, reused: reused, gapMs: gap,
tag: row.tagName, cls: String(row.className || "").slice(0, 80),
kids: row.children.length,
pCls: row.parentElement
? String(row.parentElement.className || "").slice(0, 80) : null,
txt: (row.textContent || "").trim().slice(0, 40) });
}
_lastVerbEl = row; _lastVerbMs = now;
lastNode = row; lastSeenMs = now;
if (!_foundLogged) { _foundLogged = true;
dlog("spinner.found", { cls: String(row.className || "").slice(0, 80) }); }
if (anim) st.frame++;
if (!st.simStart) st.simStart = now; // elapsed = since turn start
if (!st.sentRender) {
ping("impression_rendered?surface=overlay&ad=" + encodeURIComponent(AD)
+ "&event_uuid=" + encodeURIComponent(newEventUuid()));
st.sentRender = true;
}
var vis = (typeof document.hidden === "undefined")
? true : !document.hidden;
if (vis && !st.wasVisible) {
ping("impression_viewable?surface=overlay&ad=" + encodeURIComponent(AD)
+ "&event_uuid=" + encodeURIComponent(newEventUuid()));
}
st.wasVisible = vis;
if (!_shown) { _shown = true; dlog("loop.adopt", { active: true }); }
var o = ensureOverlay(row);
_spinnerActive = true;
// W3: this overlay-ad-session is now visible. The accumulator will
// start counting visible time toward the threshold. AD is the ad
// text rather than an ad_id — the block doesn't receive an id (the
// backend correlates by client_id + ts + corr) so we pass the AD
// string as the session key; same ad text in the same session
// continues accumulating. Safe no-op when AD is empty.
try { viewShow(AD, "overlay"); } catch (e) { /* prime directive */ }
placeOverlay(row);
var dots = ellipsis(st.frame);
var elapsed = fmtElapsed(now - st.simStart);
// AD_ID and ICON_URL are in the signature so a rotation between two
// creatives with identical text+clickUrl, or a same-ad icon update,
// still triggers the structural rebuild (the icon img is only written
// on this path).
var sig = TIER + "|" + AD_ID + "|" + AD + "|" + CLICKURL + "|" + ICON_URL;
if (sig !== _chromeSig) {
o.innerHTML = buildAdHtml(TIER, { ad: AD,
href: CLICKURL, elapsed: elapsed, dots: dots });
_chromeSig = sig;
_dotsEl = o.querySelector('[data-va-dots]');
_elapsedEl = o.querySelector('[data-va-elapsed]');
} else {
if (_dotsEl) _dotsEl.textContent = dots;
if (_elapsedEl) {
_elapsedEl.textContent = elapsed;
}
}
}
// POSITION on every animation frame so the overlay stays glued to the
// spinner with zero perceptible lag — including while the user scrolls
// or CC streams text and the row moves (the 140ms-only cadence left the
// ad behind during scroll). This is ONE getBoundingClientRect on ONE
// cached node per frame with the style WRITE gated to only-on-move — no
// layout thrash (unlike the removed unthrottled capture scroll handler,
// which fired many times per frame). rAF self-pauses when the tab is
// hidden. Content/animation + detection/idle stay on the slower loop.
function frame() {
try {
// Active: glue to the spinner verb. Idle+docked: keep parked above the
// composer (cheap getBoundingClientRect on the cached _dockNode — never
// a full-document query here; re-acquisition lives in evaluate()). If
// _dockNode disconnected, hold position this frame; evaluate() re-finds.
if (overlay && !_frozen && lastNode && lastNode.isConnected) {
placeOverlay(lastNode);
} else if (overlay && _frozen && _docked
&& _dockNode && _dockNode.isConnected) {
placeDocked(_dockNode);
}
} catch (e) { /* prime directive */ }
try { window.requestAnimationFrame(frame); }
catch (e) { setTimeout(frame, 16); }
}
try { window.requestAnimationFrame(frame); }
catch (e) { setTimeout(frame, 16); }
setInterval(pollActivity, 1000);
// Live ad refresh: poll the loopback every 60s for the current ad from
// the extension (which polls /v1/portfolio). When the ad changes, swap
// the module-scope AD/CLICKURL/ICON_URL so the next paint() and banner
// tick render the fresh creative without a VS Code reload.
function pollAd() {
try {
fetch(BASE + "/ad").then(function (r) { return r.json(); })
.then(function (j) {
if (!j || !j.adText) {
// SUCCESSFUL response, no ad = the host's no-serve signal
// (kill / disable / serving-gate refusal). Debounced to two
// consecutive reads — see the _noServe declaration. A fetch
// ERROR never reaches here (catch below keeps the last ad).
_adEmptyPolls++;
if (_adEmptyPolls >= 2) enterNoServe();
return;
}
_adEmptyPolls = 0;
if (_noServe) {
// Host resumed serving (possibly the SAME creative). Re-arm:
// the dropped overlay re-mounts on the next active paint()
// and the banner repaints on its next 1s tick.
_noServe = false;
_bannerLast = "";
dlog("ad.serve_resume", { adId: j.adId });
}
// Icon is part of the change check: a same-ad icon update (an
// advertiser swaps their creative icon, or the backend's first
// serve carried the raw https URL and a later one the inlined
// data: URI) must be adopted too, or the stale icon sits next to
// this ad for the life of the webview.
var changed = (j.adId && j.adId !== AD_ID) || (j.adText !== AD)
|| (j.clickUrl !== CLICKURL) || ((j.iconUrl || "") !== ICON_URL);
if (!changed) return;
dlog("ad.rotated", { fromId: AD_ID, toId: j.adId, from: AD, to: j.adText });
AD_ID = j.adId || AD_ID;
AD = j.adText;
CLICKURL = j.clickUrl || "";
ICON_URL = j.iconUrl || "";
// Rebuild favicon for new icon
FAVICON = ICON_URL ? faviconImg(ICON_URL) : FAVICON_FALLBACK;
// Reset view-time sessions: old ad's accumulated time must not
// carry over to the new ad's billing.
_vt = Object.create(null);
st.sentRender = false;
st.wasVisible = false;
_bannerSentRender = false;
_bannerSentViewable = false;
// Force overlay re-render by clearing the chrome signature
_chromeSig = "";
_dotsEl = null;
_elapsedEl = null;
// Force banner re-render
_bannerLast = "";
// Audit #27: the docked idle overlay is not repainted by paint()
// (it only runs in the active branch) — retarget its stable
// child nodes in place so a stale creative never sits on screen.
if (overlay && _frozen) retargetDockedOverlay();
}).catch(function () {
// Fetch ERROR (loopback unreachable / transient network): keep
// the last ad — deliberately NOT the no-serve signal.
});
} catch (e) { /* ignore */ }
}
setInterval(pollAd, 10000);
// Also poll once shortly after start to catch any rotation that
// happened between patch-time and now.
setTimeout(pollAd, 5000);
// --- Defense-in-depth state evaluation -----------------------------
// ONE shared evaluator, driven by FOUR independent signals so a miss in
// any single one can't strand the overlay. Combine is biased toward
// HIDING (a missed show is mild; a stuck overlay over CC's UI is the
// failure that's burned us repeatedly).
// • DOM glyph (findSpinner + rowActive) — primary "show"
// • Transcript (realAct.done / staleness) — DOM-independent force-HIDE
// • MutationObserver — instant edge, poll backup
// • visibilitychange + watchdog — recover stale/wedged state
var TXN_STALE_MS = 12000; // no transcript activity this long => idle
var _evaluating = false;
function evaluate() {
if (_evaluating) return; // re-entrancy guard (observer+timer)
_evaluating = true;
try {
// No-serve (host /ad gate — see pollAd/enterNoServe): never paint
// or keep an overlay while the host has stopped serving, even with
// a live spinner. Biased toward HIDE, matching this evaluator's
// design. pollAd re-arms on the next served payload.
if (_noServe) {
if (overlay) { dropOverlay(); lastNode = null; }
return;
}
var now = Date.now();
var row = findSpinner(); // READ-ONLY, class-scoped
// Content-freshness check (the missing piece that previously made
// row 03 'after-prompt-idle' fail intermittently). CC leaves the
// spinnerRow mounted at turn end with the LAST glyph still present
// (e.g. ✻ frozen). rowActive() alone would keep reporting "active"
// forever and paint() would keep animating the ad's dots. CC's
// textContent may STILL be changing at idle (an elapsed-time/
// timestamp child re-renders) so a whole-string signature is not
// reliable. The truthful signal is the ANIMATED GLYPH: while
// thinking CC cycles through {✢ U+2722, ✶ U+2736, ✻ U+273B,
// ✽ U+273D}; at idle it freezes on one. We track the first non-
// whitespace code point and only count the row as ACTIVE if that
// code point CHANGED within GRACE_MS.
if (row) {
var t = (row.textContent || "").replace(/^[\s ]+/, "");
var cc = t.charCodeAt(0) | 0;
if (cc !== lastSig) { lastSig = cc; lastSigMs = now; }
} else {
lastSig = null;
}
var glyphLed = !!(row && rowActive(row));
var fresh = glyphLed && lastSigMs > 0
&& (now - lastSigMs) <= GRACE_MS;
var domActive = glyphLed && fresh;
// Transcript second opinion (only when we actually have data — never
// force-hide on missing/empty activity; DOM-only in that case).
var txnIdle = !!(realAct && (realAct.done === true ||
(typeof realAct.ts === "number" && realAct.ts > 0 &&
(now - realAct.ts) > TXN_STALE_MS)));
if (domActive && !txnIdle) {
if (_frozen) {
_frozen = false; _docked = false; _dockNode = null;
dlog("loop.thaw", {});
}
paint(row, true);
} else if (overlay && !_frozen && ((now - lastSeenMs) > GRACE_MS
|| txnIdle || (glyphLed && !fresh))) {
// Turn ended (DOM idle past GRACE, OR the transcript says done, OR a
// glyph-led row froze stale > GRACE_MS). DOCK the overlay above the
// composer and pause billing — no longer freeze-at-pixel (which
// floated over scrolled transcript content). The next active turn
// thaws it. If the composer can't be found, dockOverlay() DROPS the
// ad (prime-directive fallback).
dlog("loop.idle.dock_enter",
{ sinceSeenMs: now - lastSeenMs, txnIdle: txnIdle,
staleFrozen: glyphLed && !fresh });
dockOverlay();
} else if (overlay && _frozen && _docked
&& (!_dockNode || !_dockNode.isConnected)) {
// Still idle but CC re-rendered/removed the composer element:
// re-acquire it (the 80ms cadence absorbs the one querySelectorAll),
// or DROP if it's truly gone — never strand the ad over content.
var c2 = findComposer();
if (c2) { _dockNode = c2; placeDocked(c2); }
else {
dlog("loop.idle.dock_lost_drop", {});
dropOverlay(); lastNode = null;
}
}
} catch (e) {
dlog("loop.error", { msg: String(e && e.message || e).slice(0, 200) });
/* prime directive: never disturb Claude Code */
} finally { _evaluating = false; }
}
setInterval(evaluate, 80); // primary cadence; rAF handles positioning
// Signal 3 REMOVED — root cause of the CC crash. A whole-document
// { childList:true, subtree:true } observer firing evaluate() (=
// full-document querySelectorAll + getBoundingClientRect) on EVERY DOM
// mutation meant that while CC streams a response (transcript mutates
// per token) it ran hundreds of times/sec → main-thread saturation →
// the webview goes unresponsive and VS Code terminates it. Intermittent
// & load-dependent, so a quick test of 0.3.25 looked fine. The 80ms
// interval detects within 80ms; the remaining bounded signals
// (interval + rAF + transcript-idle + watchdog + visibilitychange)
// preserve reliability with NO unbounded cost. Do not reintroduce a
// document-wide subtree observer.
// Signal 4a: rAF pauses while the tab is hidden, so a turn that ended in
// the background could leave a stale overlay. Re-evaluate on re-show.
try {
document.addEventListener("visibilitychange", function () {
if (!document.hidden) evaluate();
}, false);
window.addEventListener("focus", function () { evaluate(); }, false);
} catch (e) { /* no document/window — fine */ }
// Signal 4b: independent watchdog. lastSeenMs refreshes every evaluate()
// while genuinely active (even across multi-minute turns), so this only
// fires if the main loop itself wedged (exception/throttle) yet the
// overlay is still up — a true backstop against "stuck forever". Its own
// timer so a broken evaluate() can't disable it.
var WATCHDOG_MS = 15000;
setInterval(function () {
try {
// `!_frozen`: a FROZEN overlay is stale-by-design (it persists at
// idle), so the watchdog must ignore it — otherwise it would tear
// down the persisted ad after 15s, defeating PERSIST-AT-IDLE. This
// still catches a genuinely wedged ACTIVE overlay (loop threw/
// throttled while a turn was live), which is the backstop's purpose.
if (overlay && !_frozen && (Date.now() - lastSeenMs) > WATCHDOG_MS) {
dlog("loop.watchdog", { sinceSeenMs: Date.now() - lastSeenMs });
dropOverlay();
lastNode = null;
}
} catch (e) { /* prime directive */ }
}, 5000);
} catch (e) { /* no-op */ }
})();
/* VIBE-ADS-END */