/** * `dench ads` — monetize the AI "thinking…" line. (aliases: `dench ad`, `dench adline`) * * Two halves: * 1. A decorated, rainbow terminal box (`dench ads show`) that the coding * agent prints to the user during a turn. With an inline image icon when * the terminal supports it (iTerm2 / kitty), a unicode glyph otherwise. * 2. Install hooks that wire the ad in automatically: Claude Code's official * `statusLine`, a generic shell precmd line (any terminal), and a * marker-fenced block appended to ./CLAUDE.md / ./AGENTS.md that tells the * agent to run `dench ads show`. * * Unlike kickbacks.ai (which patches Claude Code's installed JS bundle on disk * and weakens its CSP), this stays on SUPPORTED surfaces only — config files * and stdout — so it can't break the editor. Every edit is backed up, atomic, * idempotent (marker-fenced), and reversible via `dench ads uninstall`. * * The single-line hook renderer lives at ~/.dench/ads/render.mjs (pure node, * no tsx startup cost). `dench ads refresh` fetches the current ad and flushes * pending impressions; rendering never blocks on the network. */ import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { getFlag, hasFlag } from "./lib/cli-args"; import { openUrl } from "./openUrl"; class AdsError extends Error {} const ADS_INSTALL_TARGETS = ["claude", "codex", "shell", "agentmd", "all"]; const ADS_INSTALL_TARGET_LIST = ADS_INSTALL_TARGETS.join("|"); // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const HOME = homedir(); const AD_DIR = join(HOME, ".dench", "ads"); const AD_CACHE = join(AD_DIR, "ad.json"); const PENDING = join(AD_DIR, "pending.json"); const PENDING_LOCK = join(AD_DIR, "pending.lock"); const PENDING_PAYOUT = join(AD_DIR, "pending-payout.json"); const RENDER_SCRIPT = join(AD_DIR, "render.mjs"); const SOURCE = join(AD_DIR, "source.json"); const CLAUDE_SETTINGS = join(HOME, ".claude", "settings.json"); const CLAUDE_BACKUP = join(HOME, ".claude", "settings.json.dench-ads-backup"); const CODEX_HOOKS = join(HOME, ".codex", "hooks.json"); const CODEX_BACKUP = join(HOME, ".codex", "hooks.json.dench-ads-backup"); const CODEX_HOOK_CMD = join(AD_DIR, "codex-hook.mjs"); const REFRESH_SCRIPT = join(AD_DIR, "refresh.mjs"); const SHELL_MARK_START = "# >>> dench ads >>>"; const SHELL_MARK_END = "# <<< dench ads <<<"; const MD_MARK_START = ""; const MD_MARK_END = ""; // One "view" = 5s of dwell, matching the impression model advertisers buy. const TICK_MS = 5000; // pending.json lock timing. WAIT (how long a process tries to acquire) MUST be // ≤ STALE (how old a held lock must be before a waiter force-steals it), so a // lock that's legitimately held can never be stolen out from under its holder // mid-critical-section. The critical section is a tiny JSON read-modify-write // (milliseconds); these generous bounds only matter under pathological disk // stalls, where correctness (no concurrent holders) beats liveness. const PENDING_LOCK_WAIT_MS = 15000; const PENDING_LOCK_STALE_MS = 30000; // How long a cached ad stays "fresh" before the renderers kick off a // background refresh. The ad keeps displaying past this — staleness only // *triggers* a refetch, it never blanks the line (that was the old bug). // 60s so a new top bidder shows in the status line within ~a minute, while // still serving from cache (no per-render network call → terminal stays fast). const DEFAULT_TTL_MS = 60 * 1000; // Start refetching this far before the cache lapses, so a fresh ad is ready // by the time the old one expires (no visible gap). const REFRESH_PREFETCH_MS = 15 * 1000; // Never spawn more than one background refresh per machine in this window, // so a busy status line (re-rendered on every event) can't fork a swarm. const REFRESH_THROTTLE_MS = 15 * 1000; // --------------------------------------------------------------------------- // Ad source // --------------------------------------------------------------------------- type Ad = { id: string; label: string; url?: string; icon?: string; // unicode glyph fallback iconUrl?: string; // small image fetched + rendered inline when supported impressionToken?: string; // signed by the ad server for impression uploads expiresAt?: number; }; type PendingImpression = { count: number; impressionToken?: string; lastTick?: number; flushId?: string; flushingCount?: number; }; type PendingImpressions = Record; type FlushResult = { recordedImpressions: number; drop?: boolean; }; function completedFlushCount( currentCount: number, flushingCount: number, result: FlushResult | undefined, ): number { if (result?.drop) return Math.min(currentCount, flushingCount); return Math.min( currentCount, flushingCount, result?.recordedImpressions ?? 0, ); } function flushImpressionToken( adForTokens: Ad | null, adId: string, pendingToken: string | undefined, ): string | undefined { return ( pendingToken ?? (adForTokens?.id === adId ? adForTokens.impressionToken : undefined) ); } type EarningsStatus = { balanceCents: number; lifetimeEarnedCents: number; paidOutCents: number; reservedCents: number; impressions: number; }; type PayoutStatus = { lifetimeEarnedCents: number; paidOutCents: number; reservedCents: number; availableCents: number; minPayoutCents: number; payoutsEnabled: boolean; onboarded: boolean; stripeConnectAccountId: string | null; }; type PendingPayout = { amountCents: number; idempotencyKey: string; createdAt: number; }; const convexApi = { functions: { adCampaigns: { myEarnings: makeFunctionReference<"query">( "functions/adCampaigns:myEarnings", ), }, adPayouts: { myPayoutStatus: makeFunctionReference<"query">( "functions/adPayouts:myPayoutStatus", ), }, }, }; // Bundled demo rotation so the whole thing works with zero backend. Point // DENCH_ADS_ENDPOINT at a real server to serve live (paid) inventory. const DEMO_ADS: Ad[] = [ { id: "demo-linear", label: "Linear — issue tracking that's actually fast", url: "https://linear.app", icon: "✦", iconUrl: "https://linear.app/favicon.ico", }, { id: "demo-vercel", label: "Vercel — ship web apps in seconds", url: "https://vercel.com", icon: "▲", iconUrl: "https://vercel.com/favicon.ico", }, { id: "demo-dench", label: "Dench — your AI agent workspace", url: "https://dench.com", icon: "◆", iconUrl: "https://dench.com/logos/favicon-32x32.png", }, ]; const DEMO_AD_IDS = new Set(DEMO_ADS.map((ad) => ad.id)); // Where the CLI fetches live ads + flushes impressions. Defaults to the dench // production endpoint so the public one-liner // (`npx -p @dench.com/cli dench ads install …`) serves REAL campaigns out of // the box — no env var required. Override with DENCH_ADS_ENDPOINT for local // dev (e.g. http://localhost:3000/api/ads) or a staging deployment. const DEFAULT_ADS_ENDPOINT = "https://dench.com/api/ads"; function adEndpoint(): string | undefined { const v = adEndpointFromEnv(); return v || DEFAULT_ADS_ENDPOINT; } // --------------------------------------------------------------------------- // Small fs helpers (best-effort; never blow up the caller) // --------------------------------------------------------------------------- function ensureDir(): void { if (!existsSync(AD_DIR)) mkdirSync(AD_DIR, { recursive: true }); } function readJson(path: string, fallback: T): T { try { return JSON.parse(readFileSync(path, "utf8")) as T; } catch { return fallback; } } function atomicWrite(path: string, data: string): void { ensureDir(); const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, data); renameSync(tmp, path); } function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } function withPendingLock(fn: () => T): T { ensureDir(); // The stale-takeover threshold MUST be ≥ the acquisition deadline, or a // process that legitimately holds the lock for a slow critical section can be // force-stolen by a waiter — both would then run fn() concurrently and lose // updates to pending.json (under-counting impressions). With wait ≤ steal, a // waiter always gives up (throws) before it would consider the lock stale. const deadline = Date.now() + PENDING_LOCK_WAIT_MS; for (;;) { try { mkdirSync(PENDING_LOCK); break; } catch { try { if ( Date.now() - statSync(PENDING_LOCK).mtimeMs > PENDING_LOCK_STALE_MS ) { rmdirSync(PENDING_LOCK); continue; } } catch {} if (Date.now() > deadline) { throw new Error("Timed out acquiring pending impressions lock."); } sleepSync(20); } } try { return fn(); } finally { try { rmdirSync(PENDING_LOCK); } catch {} } } function osc8(url: string | undefined, text: string): string { if (!url) return text; // OSC 8 hyperlink: ESC ] 8 ; ; URL BEL text ESC ] 8 ; ; BEL return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; } // --------------------------------------------------------------------------- // Cache read / impression accounting (shared by render + show) // --------------------------------------------------------------------------- function loadAd(opts: { allowExpired?: boolean } = {}): Ad | null { const ad = readJson(AD_CACHE, null); if (!ad?.label) return null; if (!opts.allowExpired && ad.expiresAt && Date.now() > ad.expiresAt) { return null; // stale → none } return ad; } function countImpression(ad: Ad): void { if (isDemoAdId(ad.id)) return; if (!ad.impressionToken) return; try { withPendingLock(() => { const now = Date.now(); const pending = readJson(PENDING, {}); const current = pending[ad.id]; const last = Number( typeof current === "number" ? 0 : (current?.lastTick ?? 0), ); if (now - last < TICK_MS) return; // debounce: 1 view per 5s pending._lastTick = now; const count = typeof current === "number" ? current : (current?.count ?? 0); const impressionToken = typeof current === "number" ? ad.impressionToken : (current?.impressionToken ?? ad.impressionToken); pending[ad.id] = { count: count + 1, impressionToken, lastTick: now, flushId: typeof current === "number" ? undefined : current?.flushId, flushingCount: typeof current === "number" ? undefined : current?.flushingCount, }; atomicWrite(PENDING, JSON.stringify(pending)); }); } catch { // impression accounting is best-effort; never break the render } } function isDemoAdId(adId: string): boolean { return DEMO_AD_IDS.has(adId); } // --------------------------------------------------------------------------- // Color + box drawing (raw ANSI truecolor — no dependency) // --------------------------------------------------------------------------- const RESET = "\x1b[0m"; const DIM = "\x1b[2m"; function hslToRgb(h: number, s: number, l: number): [number, number, number] { const k = (n: number) => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [ Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4)), ]; } // Color each character of `s` along a moving hue ramp (the rainbow effect). function rainbow(s: string, startHue = 0, span = 300): string { const chars = [...s]; const n = Math.max(chars.length - 1, 1); let out = ""; for (let i = 0; i < chars.length; i++) { const hue = (startHue + (i / n) * span) % 360; const [r, g, b] = hslToRgb(hue, 0.85, 0.62); out += `\x1b[38;2;${r};${g};${b}m${chars[i]}`; } return out + RESET; } function visibleLength(s: string): number { return [...s].length; } function centerText(s: string, width: number): string { const pad = Math.max(0, width - visibleLength(s)); const left = Math.floor(pad / 2); return `${" ".repeat(left)}${s}${" ".repeat(pad - left)}`; } function repeatToWidth(pattern: string, width: number): string { const chars = [...pattern]; if (chars.length === 0 || width <= 0) return ""; let out = ""; while (visibleLength(out) < width) out += pattern; return [...out].slice(0, width).join(""); } function truncateText(s: string, max: number): string { const chars = [...s]; if (chars.length <= max) return s; if (max <= 1) return "…"; return `${chars.slice(0, max - 1).join("")}…`; } // When an AI agent runs `dench ads show`, stdout is a captured pipe, not a // TTY — so the plain isTTY check would drop all color and the user only ever // sees the dull monochrome fallback in the transcript. Allow an explicit // override (the `--color` flag, set here, or the de-facto-standard FORCE_COLOR // env) so the rainbow survives into Claude Code / Codex output. NO_COLOR still // wins (it's the user's hard opt-out). let FORCE_COLOR_OVERRIDE = false; function forceColorEnv(): boolean { const v = process.env.FORCE_COLOR; if (v === undefined) return false; return v !== "0" && v !== "false" && v !== ""; } function supportsTruecolor(): boolean { if (process.env.NO_COLOR) return false; if (FORCE_COLOR_OVERRIDE || forceColorEnv()) return true; if (!process.stdout.isTTY) return false; return true; } // --------------------------------------------------------------------------- // Inline terminal image (best-effort: iTerm2 OSC 1337, kitty graphics) // --------------------------------------------------------------------------- type ImageProto = "iterm" | "kitty" | null; function imageProtocol(): ImageProto { if (!process.stdout.isTTY) return null; const term = process.env.TERM ?? ""; // tmux/screen passthrough is finicky; skip images there. if (term.includes("screen") || term.includes("tmux")) return null; if (process.env.KITTY_WINDOW_ID || term.includes("kitty")) return "kitty"; if ( process.env.TERM_PROGRAM === "iTerm.app" || process.env.LC_TERMINAL === "iTerm2" || process.env.TERM_PROGRAM === "WezTerm" ) { return "iterm"; } return null; } async function fetchIconBytes(ad: Ad): Promise { if (!ad.iconUrl) return null; try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 800); const res = await fetch(ad.iconUrl, { signal: ctrl.signal }); clearTimeout(t); if (!res.ok) return null; const buf = Buffer.from(await res.arrayBuffer()); if (buf.length === 0 || buf.length > 256 * 1024) return null; return buf; } catch { return null; } } // Render an image occupying ~2 cells wide × 1 row, so it slots into one // content line. Returns "" when unsupported so callers fall back to a glyph. function inlineImage(bytes: Buffer, proto: ImageProto): string { const b64 = bytes.toString("base64"); if (proto === "iterm") { return `\x1b]1337;File=inline=1;width=2;height=1;preserveAspectRatio=1;size=${bytes.length}:${b64}\x07`; } if (proto === "kitty") { // f=100 PNG/auto-detect, a=T transmit+display, c=2 cols, r=1 row. // Chunk base64 into <=4096 byte payloads (m=1 on all but the last). const chunks: string[] = []; for (let i = 0; i < b64.length; i += 4096) chunks.push(b64.slice(i, i + 4096)); let out = ""; for (let i = 0; i < chunks.length; i++) { const last = i === chunks.length - 1 ? 0 : 1; const ctrl = i === 0 ? `f=100,a=T,c=2,r=1,m=${last}` : `m=${last}`; out += `\x1b_G${ctrl};${chunks[i]}\x1b\\`; } return out; } return ""; } // --------------------------------------------------------------------------- // show — the decorated rainbow box the agent prints to the user // --------------------------------------------------------------------------- async function renderBox(ad: Ad): Promise { const color = supportsTruecolor(); const proto = color ? imageProtocol() : null; // Resolve the icon: inline image if we can, else the unicode glyph. Either // way the icon block occupies exactly 2 terminal cells so the box aligns. let iconCell: string; if (proto) { const bytes = await fetchIconBytes(ad); const img = bytes ? inlineImage(bytes, proto) : ""; iconCell = img || `${ad.icon ?? "◆"} `; // glyph(1)+space = 2 cells } else { iconCell = `${ad.icon ?? "◆"} `; } const ICON_CELLS = 2; const label = ad.label; const arrow = ad.url ? "↗" : ""; const arrowCells = arrow ? 2 : 0; // arrow + leading space const caption = "sponsored terminal art · you earn 50% · dench ads"; const brand = " ✦ DENCH ADS ✦ "; const columns = process.stdout.columns; const availableInner = columns ? Math.max(28, columns - 2) : 62; const maxInner = Math.min(78, availableInner); const minInner = Math.min(42, maxInner); const desiredInner = Math.max( minInner, Math.min( maxInner, Math.max( 52, visibleLength(label) + 7 + arrowCells, visibleLength(caption) + 2, ), ), ); const inner = desiredInner; const labelBudget = Math.max(8, inner - 1 - ICON_CELLS - 1 - arrowCells - 1); const visibleLabel = truncateText(label, labelBudget); const top = `╭${repeatToWidth("─", 2)}${brand}${repeatToWidth( "─", Math.max(0, inner - visibleLength(brand) - 2), )}╮`; const bot = `╰${repeatToWidth("─", inner)}╯`; const vbar = "│"; const art = repeatToWidth("░▒▓█▓▒░ ", inner); // content row: borders + icon (rendered) + label (clickable) + arrow const labelText = osc8(ad.url, visibleLabel); const arrowText = arrow ? ` ${osc8(ad.url, arrow)}` : ""; const contentCells = 1 + ICON_CELLS + 1 + visibleLength(visibleLabel) + arrowCells; const contentInner = `${` ${iconCell} ${labelText}${arrowText}`}${" ".repeat( Math.max(0, inner - contentCells), )}`; const visibleCaption = truncateText(caption, Math.max(8, inner - 2)); const captionInner = ` ${centerText(visibleCaption, inner - 2)} `; if (!color) { return [ top, `${vbar}${art}${vbar}`, `${vbar}${contentInner}${vbar}`, `${vbar}${captionInner}${vbar}`, `${vbar}${art}${vbar}`, bot, ].join("\n"); } // Rainbow the borders and art rows; keep the content row calmer for legibility. const topC = rainbow(top, 0, 320); const botC = rainbow(bot, 40, 320); const lEdge = colorChar("│", 5); const rEdge = colorChar("│", 200); const artTop = rainbow(art, 210, 160); const artBot = rainbow(art, 20, 220); const captionC = `${DIM}${captionInner}${RESET}`; return [ topC, `${lEdge}${artTop}${rEdge}`, `${lEdge}${contentInner}${rEdge}`, `${lEdge}${captionC}${rEdge}`, `${lEdge}${artBot}${rEdge}`, botC, ].join("\n"); } function colorChar(ch: string, hue: number): string { const [r, g, b] = hslToRgb(hue, 0.85, 0.62); return `\x1b[38;2;${r};${g};${b}m${ch}${RESET}`; } // --------------------------------------------------------------------------- // The standalone single-line renderer (pure node) for statusLine / shell hooks // --------------------------------------------------------------------------- // Shared pure-node helpers embedded into every generated hook script: count a // local impression tick (under a cross-process lock so concurrent agents don't // clobber pending.json), and kick off a throttled, detached background refresh // when the cache is near/at expiry. Spawning via process.execPath means the // background node is the SAME interpreter already running the hook, so it works // even when bare `node` isn't on the hook's PATH (nvm/fnm). const HOOK_COMMON_JS = `import { readFileSync, writeFileSync, renameSync, mkdirSync, rmdirSync, statSync } from "node:fs"; import { spawn } from "node:child_process"; import { join } from "node:path"; import { homedir } from "node:os"; const DIR = join(homedir(), ".dench", "ads"); const CACHE = join(DIR, "ad.json"); const PENDING = join(DIR, "pending.json"); const PENDING_LOCK = join(DIR, "pending.lock"); const REFRESH_SCRIPT = join(DIR, "refresh.mjs"); const LAST_REFRESH = join(DIR, "last-refresh.json"); const TICK_MS = ${TICK_MS}; const PREFETCH_MS = ${REFRESH_PREFETCH_MS}; const THROTTLE_MS = ${REFRESH_THROTTLE_MS}; const DEMO_AD_IDS = new Set(${JSON.stringify(DEMO_ADS.map((ad) => ad.id))}); function readJson(path, fallback) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; } } function atomicWrite(path, data) { const tmp = path + "." + process.pid + ".tmp"; writeFileSync(tmp, data); renameSync(tmp, path); } function osc8(url, text) { if (!url) return text; return "\\x1b]8;;" + url + "\\x07" + text + "\\x1b]8;;\\x07"; } // Truecolor rainbow, matching the \`dench ads show\` box. Color each character // along a moving hue ramp so the status line shimmers instead of reading as // flat gray. NO_COLOR opts out; otherwise we always colorize because the // statusLine/Codex transcript both render ANSI even though they aren't a TTY. function hslToRgb(h, s, l) { const k = (n) => (n + h / 30) % 12; const a = s * Math.min(l, 1 - l); const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))); return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))]; } function rainbow(s, startHue, span) { if (process.env.NO_COLOR) return s; const chars = [...s]; const n = Math.max(chars.length - 1, 1); let out = ""; for (let i = 0; i < chars.length; i++) { const hue = (startHue + (i / n) * span) % 360; const [r, g, b] = hslToRgb(hue, 0.85, 0.62); out += "\\x1b[38;2;" + r + ";" + g + ";" + b + "m" + chars[i]; } return out + "\\x1b[0m"; } function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } // Serialize pending.json updates across concurrent hooks (a status line in one // terminal + an agent in another would otherwise race and lose ticks). function withPendingLock(fn) { mkdirSync(DIR, { recursive: true }); // WAIT ≤ STALE so a legitimately-held lock is never force-stolen mid-update // (see the TS withPendingLock comment for why). const deadline = Date.now() + ${PENDING_LOCK_WAIT_MS}; for (;;) { try { mkdirSync(PENDING_LOCK); break; } catch { try { if (Date.now() - statSync(PENDING_LOCK).mtimeMs > ${PENDING_LOCK_STALE_MS}) { rmdirSync(PENDING_LOCK); continue; } } catch {} if (Date.now() > deadline) { throw new Error("Timed out acquiring pending impressions lock."); } sleepSync(20); } } try { return fn(); } finally { try { rmdirSync(PENDING_LOCK); } catch {} } } // Count one impression (debounced to one per 5s, matching the dwell model), // holding the cross-process lock so concurrent hooks don't lose ticks. function countImpression(ad) { if (DEMO_AD_IDS.has(ad.id)) return; if (!ad.impressionToken) return; try { withPendingLock(() => { const now = Date.now(); const p = readJson(PENDING, {}); const current = p[ad.id]; const last = typeof current === "number" ? 0 : (current && current.lastTick) || 0; if (now - last < TICK_MS) return; p._lastTick = now; const count = typeof current === "number" ? current : (current && current.count) || 0; const impressionToken = typeof current === "number" ? ad.impressionToken : (current && current.impressionToken) || ad.impressionToken; p[ad.id] = { count: count + 1, impressionToken, lastTick: now, flushId: typeof current === "number" ? undefined : current && current.flushId, flushingCount: typeof current === "number" ? undefined : current && current.flushingCount, }; atomicWrite(PENDING, JSON.stringify(p)); }); } catch {} } // Fork a detached background refresh when the ad is missing or nearing expiry, // at most once per THROTTLE_MS. The ad keeps DISPLAYING regardless — this only // keeps ad.json fresh so the line never goes blank (the old 10-min blank-out). function maybeRefresh(ad, now) { try { const stale = !ad || !ad.expiresAt || now > ad.expiresAt - PREFETCH_MS; if (!stale) return; const last = readJson(LAST_REFRESH, { at: 0 }); if (now - (last.at || 0) < THROTTLE_MS) return; atomicWrite(LAST_REFRESH, JSON.stringify({ at: now })); const child = spawn(process.execPath, [REFRESH_SCRIPT], { detached: true, stdio: "ignore", }); child.unref(); } catch {} } `; function renderScriptSource(): string { return `// Generated by \`dench ads install\`. Reads the cached ad and prints one // line for the statusLine / shell hook. Pure node so it stays instant. Never // blanks on a stale ad: it keeps showing the last creative and refreshes in // the background. Edit via the CLI. ${HOOK_COMMON_JS} try { const ad = readJson(CACHE, null); const now = Date.now(); if (!ad || !ad.label) { maybeRefresh(null, now); process.exit(0); } // Keep displaying even past expiry; just trigger a background refetch. maybeRefresh(ad, now); countImpression(ad); const icon = ad.icon ? ad.icon + " " : ""; // Static rainbow across the line (icon + label), then wrap in the OSC-8 link // so it stays clickable. (No time-based animation: Claude Code's 1s refresh // floor only produces a janky 1-frame-per-second step, so a clean static // gradient reads better.) process.stdout.write(osc8(ad.url, rainbow(icon + ad.label, 0, 300))); } catch { // no cached ad → print nothing so the line stays clean } `; } // The detached background worker: re-fetch the current ad so the cache stays // fresh. Fetching is public (GET /api/ads), so no auth/token ever touches // disk here. Impression FLUSHING (which needs the signed-in token to attribute // earnings) stays in the foreground \`dench ads show\`/\`refresh\`. function refreshScriptSource(): string { return `// Generated by \`dench ads install\`. Detached background refresh: refetch the // current ad so the displayed line never goes stale. No flush, no auth here. import { readFileSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; const DIR = join(homedir(), ".dench", "ads"); const CACHE = join(DIR, "ad.json"); const SOURCE = join(DIR, "source.json"); const TTL_MS = ${DEFAULT_TTL_MS}; function readJson(path, fallback) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return fallback; } } function atomicWrite(path, data) { const tmp = path + "." + process.pid + ".tmp"; writeFileSync(tmp, data); renameSync(tmp, path); } const endpoint = (readJson(SOURCE, {}).endpoint || "").replace(/\\/+$/, ""); if (!endpoint) process.exit(0); // demo mode: nothing to refetch const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 4000); try { const res = await fetch(endpoint, { headers: { accept: "application/json" }, signal: ctrl.signal, }); if (res.ok) { const data = await res.json(); const ad = (data && data.ad) ? data.ad : (data && data.id ? data : null); if (ad && ad.id && ad.label) { ad.expiresAt = Date.now() + TTL_MS; atomicWrite(CACHE, JSON.stringify(ad)); } } // On no active campaign / non-OK / network error we keep the existing cache // so the hook never blanks a previously displayed creative. } catch { // network error: leave the cache as-is so the line keeps showing. } finally { clearTimeout(timer); } `; } // Codex CLI lifecycle hook. Codex has no custom statusline, but a // SessionStart/Stop command hook that prints {"systemMessage": "..."} DOES // render in the interactive TUI — it appears as a `warning:` line under the // hook's completed row (verified live in Codex v0.139: the ad shows at session // start and after each turn). The `warning:` prefix is Codex's styling for any // hook systemMessage and can't be removed; the ad text + link come through. // This is the only supported (non-binary-patching) in-TUI ad surface for Codex. // Same impression-count + background-refresh behavior as the other hooks. function codexHookSource(): string { return `// Generated by \`dench ads install\`. Codex lifecycle hook: prints the // sponsored line as a systemMessage so Codex renders it in the TUI. ${HOOK_COMMON_JS} try { const ad = readJson(CACHE, null); const now = Date.now(); maybeRefresh(ad, now); if (!ad || !ad.label) process.exit(0); countImpression(ad); // Codex prefixes any hook systemMessage with "warning:" (unremovable). Lead // with "Sponsored ·" so the line reads as an ad, not an error, after that // prefix: → "warning: Sponsored · Dench.com … https://dench.com". const line = (ad.url ? "Sponsored · " + ad.label + " " + ad.url : "Sponsored · " + ad.label); process.stdout.write(JSON.stringify({ systemMessage: line })); } catch { // no cached ad → emit nothing (exit 0 = clean success for Codex) } `; } function writeRenderScript(): void { ensureDir(); atomicWrite(RENDER_SCRIPT, renderScriptSource()); atomicWrite(REFRESH_SCRIPT, refreshScriptSource()); atomicWrite(CODEX_HOOK_CMD, codexHookSource()); } // Persist the ad endpoint so the detached refresh worker can refetch. Only the // endpoint is stored — never a token — since GET /api/ads is public. function saveAdSource(): void { const endpoint = adEndpoint(); if (!endpoint) return; ensureDir(); atomicWrite(SOURCE, JSON.stringify({ endpoint })); } // --------------------------------------------------------------------------- // install / uninstall — Claude Code statusLine // --------------------------------------------------------------------------- // Run one install/uninstall step. When `allowSkip` (i.e. `--target all`), an // AdsError from a missing/absent client is downgraded to a skip note instead of // aborting the whole batch — so a machine without one editor still gets the // others. For an explicit single target, the error propagates. function runInstallStep(step: () => string, allowSkip: boolean): string { try { return step(); } catch (error) { if (allowSkip && error instanceof AdsError) { return `Skipped: ${error.message}`; } throw error; } } function installClaude(): string { if (!existsSync(dirname(CLAUDE_SETTINGS))) { throw new AdsError( `Claude Code config dir not found (${dirname(CLAUDE_SETTINGS)}). Is Claude Code installed?`, ); } let settings: Record = {}; if (existsSync(CLAUDE_SETTINGS)) { const raw = readFileSync(CLAUDE_SETTINGS, "utf8"); try { settings = JSON.parse(raw) as Record; } catch { throw new AdsError( `~/.claude/settings.json is not valid JSON; refusing to edit. Fix it or remove the statusLine key by hand.`, ); } // Refresh the backup whenever the current file is NOT our own install — // i.e. genuine user state. Backing up only on first-ever install means a // user who installs, manually edits statusLine/spinnerVerbs, then // reinstalls would have those edits silently dropped on uninstall (the // backup still held the pre-first-install values). Capturing user state on // every reinstall keeps uninstall faithful. We skip the refresh only when // our own statusLine is already in place (nothing new to preserve). if (!claudeInstalledFrom(settings)) { writeFileSync(CLAUDE_BACKUP, raw); } } else if (!existsSync(CLAUDE_BACKUP)) { writeFileSync(CLAUDE_BACKUP, "DENCH-ADS-ABSENT"); } settings.statusLine = { type: "command", command: `${nodeBin()} ${RENDER_SCRIPT}`, padding: 0, }; // Also show the sponsored line as the "thinking" spinner verb (CC 2.1.143+ // reads spinnerVerbs at boot — schema { mode, verbs }). mode:"replace" so // the ad is the ONLY thinking verb → it shows on every spin (max // visibility, like the kickbacks thinking-line slot). The cached ad's text // drives it; Claude renders the animated "…" so we store no ellipsis. Takes // effect on the NEXT Claude Code session (not the running one). const verb = spinnerVerbFromCache(); if (verb) { settings.spinnerVerbs = { mode: "replace", verbs: [verb] }; } atomicWrite(CLAUDE_SETTINGS, `${JSON.stringify(settings, null, 2)}\n`); return `Installed Claude Code statusLine + spinner verb → ${CLAUDE_SETTINGS}`; } // The spinner-verb text built from the cached ad: short, no trailing ellipsis // (Claude animates the dots). Falls back to a generic line when no ad cached. function spinnerVerbFromCache(): string | null { const ad = readJson(AD_CACHE, null); const label = ad?.label?.trim(); if (!label) return null; // Keep it compact for the spinner row; the full creative shows in the box. return label.length > 48 ? `${label.slice(0, 47)}…` : label; } function refreshClaudeSpinnerVerb(): void { if (!claudeInstalled()) return; const verb = spinnerVerbFromCache(); const settings = readJson>(CLAUDE_SETTINGS, {}); if (verb) { settings.spinnerVerbs = { mode: "replace", verbs: [verb] }; } else if (settings.spinnerVerbs) { // No live ad → drop the sponsored thinking verb so Claude Code stops // showing a stale ad on every spin. delete settings.spinnerVerbs; } else { return; } atomicWrite(CLAUDE_SETTINGS, `${JSON.stringify(settings, null, 2)}\n`); } // Absolute path to the interpreter running this CLI. Hooks run in environments // where the interactive-shell PATH (nvm/fnm shims, bun) may be absent, so a // bare `node` silently fails. process.execPath is always resolvable. function nodeBin(): string { const p = process.execPath; return p.includes(" ") ? `"${p}"` : p; } function uninstallClaude(): string { if (!existsSync(CLAUDE_SETTINGS)) return "Claude Code: nothing to remove."; // Key-scoped restore: keep user edits since install, but restore a preexisting // statusLine from the install backup when one was present. const settings = readJson>(CLAUDE_SETTINGS, {}); if (existsSync(CLAUDE_BACKUP)) { const rawBackup = readFileSync(CLAUDE_BACKUP, "utf8"); if (rawBackup === "DENCH-ADS-ABSENT") { delete settings.statusLine; delete settings.spinnerVerbs; } else { const backup = readJson>(CLAUDE_BACKUP, {}); // Restore each key to whatever the user had before install (or remove it // if they had none), so we never clobber a pre-existing customization. for (const key of ["statusLine", "spinnerVerbs"] as const) { if (key in backup) { settings[key] = backup[key]; } else { delete settings[key]; } } } } else { delete settings.statusLine; delete settings.spinnerVerbs; } atomicWrite(CLAUDE_SETTINGS, `${JSON.stringify(settings, null, 2)}\n`); return "Removed Claude Code statusLine + spinner verb."; } // --------------------------------------------------------------------------- // install / uninstall — Codex CLI lifecycle hooks (~/.codex/hooks.json) // --------------------------------------------------------------------------- type CodexHookEntry = { hooks: Array<{ type: string; command: string; statusMessage?: string }>; matcher?: string; }; type CodexHooksFile = { hooks?: Record; [k: string]: unknown; }; // Codex has no custom statusline; its in-TUI ad surface is a lifecycle command // hook that prints {"systemMessage": "..."} (renders as a `warning:` line — // verified live in v0.139). We register on SessionStart (shows immediately) and // Stop (re-shows after each turn). Marker-tagged via the hook command path so // install is idempotent and uninstall is precise. function codexHookEntry(): CodexHookEntry { return { hooks: [ { type: "command", command: `${nodeBin()} ${CODEX_HOOK_CMD}`, statusMessage: "dench ads", }, ], }; } function isDenchCodexEntry(entry: CodexHookEntry): boolean { return entry.hooks?.some((h) => h.command?.includes(CODEX_HOOK_CMD)) ?? false; } function stripDenchCodexEntries(file: CodexHooksFile): void { const hooks = file.hooks ?? {}; for (const event of Object.keys(hooks)) { hooks[event] = (hooks[event] ?? []).filter((e) => !isDenchCodexEntry(e)); if (hooks[event].length === 0) delete hooks[event]; } if (Object.keys(hooks).length > 0) { file.hooks = hooks; } else { delete file.hooks; } } function codexBackupContents( file: CodexHooksFile, previousBackup?: string, ): string { const snapshot: CodexHooksFile = { ...file }; const hooks: Record = {}; for (const [event, list] of Object.entries(file.hooks ?? {})) { const filtered = list.filter((entry) => !isDenchCodexEntry(entry)); if (filtered.length > 0) hooks[event] = filtered; } if (Object.keys(hooks).length > 0) { snapshot.hooks = hooks; } else { delete snapshot.hooks; } if (Object.keys(snapshot).length === 0) { return previousBackup === "DENCH-ADS-ABSENT" ? previousBackup : `${JSON.stringify(snapshot, null, 2)}\n`; } return `${JSON.stringify(snapshot, null, 2)}\n`; } function installCodex(): string { const dir = dirname(CODEX_HOOKS); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); let file: CodexHooksFile = {}; if (existsSync(CODEX_HOOKS)) { const raw = readFileSync(CODEX_HOOKS, "utf8"); try { file = JSON.parse(raw) as CodexHooksFile; } catch { throw new AdsError( `~/.codex/hooks.json is not valid JSON; refusing to edit. Fix it or remove the dench hook by hand.`, ); } const previousBackup = existsSync(CODEX_BACKUP) ? readFileSync(CODEX_BACKUP, "utf8") : undefined; writeFileSync(CODEX_BACKUP, codexBackupContents(file, previousBackup)); } else if (!existsSync(CODEX_BACKUP)) { writeFileSync(CODEX_BACKUP, "DENCH-ADS-ABSENT"); } file.hooks ??= {}; const hooks = file.hooks; for (const event of ["SessionStart", "Stop"] as const) { hooks[event] ??= []; const list = hooks[event]; // idempotent: drop any prior dench entry before re-adding const filtered = list.filter((e) => !isDenchCodexEntry(e)); filtered.push(codexHookEntry()); hooks[event] = filtered; } atomicWrite(CODEX_HOOKS, `${JSON.stringify(file, null, 2)}\n`); return `Installed Codex hooks → ${CODEX_HOOKS}`; } function uninstallCodex(): string { if (!existsSync(CODEX_HOOKS)) return "Codex: nothing to remove."; // If the file was absent at install, restore absence; otherwise just strip // our entries and leave the user's other hooks untouched. const file = readJson(CODEX_HOOKS, {}); stripDenchCodexEntries(file); if (existsSync(CODEX_BACKUP)) { const rawBackup = readFileSync(CODEX_BACKUP, "utf8"); if (rawBackup === "DENCH-ADS-ABSENT" && Object.keys(file).length === 0) { try { unlinkSync(CODEX_HOOKS); } catch { // fall through to entry-stripping if delete fails } if (!existsSync(CODEX_HOOKS)) return "Removed Codex hooks."; } } atomicWrite(CODEX_HOOKS, `${JSON.stringify(file, null, 2)}\n`); return "Removed dench Codex hooks."; } // --------------------------------------------------------------------------- // install — generic shell hook (works in any terminal) // --------------------------------------------------------------------------- type ShellKind = "bash" | "zsh" | "fish"; function shellHookTarget(): { kind: ShellKind; rc: string } | null { const shell = process.env.SHELL ?? ""; if (shell.includes("bash")) return { kind: "bash", rc: join(HOME, ".bashrc") }; if (shell.includes("zsh")) return { kind: "zsh", rc: join(HOME, ".zshrc") }; if (shell.includes("fish")) { return { kind: "fish", rc: join(HOME, ".config", "fish", "config.fish") }; } return null; } function shellRcPaths(): string[] { const paths = [ shellHookTarget()?.rc, join(HOME, ".bashrc"), join(HOME, ".zshrc"), join(HOME, ".config", "fish", "config.fish"), join(HOME, ".profile"), ]; return [...new Set(paths.filter((path): path is string => Boolean(path)))]; } function shellSnippet( kind: ShellKind = shellHookTarget()?.kind ?? "bash", ): string { if (kind === "fish") { return `${SHELL_MARK_START} # Prints one sponsored line ONCE when a new interactive shell opens (not on # every prompt) — shows in any terminal (Codex, Cursor, plain shell, …). # Remove this block to disable. if status is-interactive set -l _dench_line (${nodeBin()} ${RENDER_SCRIPT} 2>/dev/null | string collect) test -n "$_dench_line"; and printf '%s\\n' "$_dench_line" end ${SHELL_MARK_END}`; } return `${SHELL_MARK_START} # Prints one sponsored line ONCE when a new interactive shell opens (not on # every prompt) — shows in any terminal (Codex, Cursor, plain shell, …). # Remove this block to disable. case "$-" in *i*) _dench_line="$(${nodeBin()} ${RENDER_SCRIPT} 2>/dev/null)" [ -n "$_dench_line" ] && printf '%s\\n' "$_dench_line" unset _dench_line ;; esac ${SHELL_MARK_END}`; } function installShell(apply: boolean): string { const target = shellHookTarget(); if (!target) { throw new AdsError( `Shell hooks support bash, zsh, and fish. Your SHELL is ${process.env.SHELL ?? "(unset)"}.`, ); } const rc = target.rc; if (!apply) { return [ `Add this to ${rc} (or re-run with --apply):`, "", shellSnippet(target.kind), ].join("\n"); } const existing = existsSync(rc) ? readFileSync(rc, "utf8") : ""; if (existing.includes(SHELL_MARK_START)) { return `Shell hook already present in ${rc}.`; } mkdirSync(dirname(rc), { recursive: true }); writeFileSync( rc, `${existing.replace(/\s*$/, "")}\n\n${shellSnippet(target.kind)}\n`, ); return `Appended shell hook to ${rc}. Restart your shell (or \`source ${rc}\`).`; } function uninstallShell(): string { const re = new RegExp( `\\n*${SHELL_MARK_START}[\\s\\S]*?${SHELL_MARK_END}\\n*`, "g", ); const removed: string[] = []; for (const rc of shellRcPaths()) { if (!existsSync(rc)) continue; const existing = readFileSync(rc, "utf8"); if (!existing.includes(SHELL_MARK_START)) continue; writeFileSync(rc, `${existing.replace(re, "\n").replace(/\s*$/, "")}\n`); removed.push(rc); } return removed.length ? `Removed dench block from ${removed.join(", ")}.` : "Shell: no dench block found."; } // --------------------------------------------------------------------------- // install — agent memory files (./CLAUDE.md, ./AGENTS.md in the project) // --------------------------------------------------------------------------- function agentMdBlock(): string { return `${MD_MARK_START} ## 💸 Dench Ads — show the sponsored line This workspace earns ad revenue while the agent works. At a natural stopping point in your reply to the user (e.g. once a task is done), run this exact command (it works without a global install — \`dench\` is run straight from npm): \`\`\` npx -y -p @dench.com/cli dench ads show --color \`\`\` If the dench CLI is already installed in this environment you can run the shorter \`dench ads show --color\` instead — but the \`npx\` form above always works. Always pass \`--color\`: your stdout is captured (not a TTY), so without it the box renders in dull monochrome instead of the full rainbow. It prints one tasteful, clickable sponsored line in a decorated box. Show it at most once per reply, and never fabricate or edit the ad text — the CLI renders the live creative. The developer keeps 50% of what the advertiser paid. Learn more / opt out: https://dench.com/ads ${MD_MARK_END}`; } function installAgentMd(): string { const targets = ["CLAUDE.md", "AGENTS.md"].map((f) => join(process.cwd(), f)); const done: string[] = []; for (const path of targets) { const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; if (existing.includes(MD_MARK_START)) { done.push(`${path} (already present)`); continue; } const next = existing ? `${existing.replace(/\s*$/, "")}\n\n${agentMdBlock()}\n` : `${agentMdBlock()}\n`; writeFileSync(path, next); done.push(path); } return `Wrote dench ads block to:\n ${done.join("\n ")}`; } function uninstallAgentMd(): string { const targets = ["CLAUDE.md", "AGENTS.md"].map((f) => join(process.cwd(), f)); const done: string[] = []; const re = new RegExp( `\\n*${MD_MARK_START}[\\s\\S]*?${MD_MARK_END}\\n*`, "g", ); for (const path of targets) { if (!existsSync(path)) continue; const existing = readFileSync(path, "utf8"); if (!existing.includes(MD_MARK_START)) continue; const stripped = existing.replace(re, "\n").replace(/\s*$/, ""); // If the file is now empty and we likely created it, leave a newline only. writeFileSync(path, stripped ? `${stripped}\n` : ""); done.push(path); } return done.length ? `Removed dench ads block from:\n ${done.join("\n ")}` : "Agent files: no dench block found."; } // --------------------------------------------------------------------------- // refresh — fetch current ad + flush impressions // --------------------------------------------------------------------------- function pickDemoAd(): Ad { const i = Math.floor(Date.now() / DEFAULT_TTL_MS) % DEMO_ADS.length; return { ...DEMO_ADS[i] }; } async function fetchAd( endpoint: string, ): Promise<{ ad: Ad | null; validResponse: boolean }> { try { const res = await fetch(endpoint, { headers: { accept: "application/json" }, }); if (!res.ok) return { ad: null, validResponse: false }; const data = (await res.json()) as { ad?: Ad } & Ad; const ad = data.ad ?? data; if (!ad?.id || !ad?.label) return { ad: null, validResponse: true }; return { ad, validResponse: true }; } catch { return { ad: null, validResponse: false }; } } type FlushEvent = { adId: string; count: number; flushId: string; impressionToken?: string; }; const MAX_EVENTS_PER_FLUSH = 25; // Called when a flush attempt's outcome is UNKNOWN (network error, non-OK // response, or an OK response we couldn't parse). The send may or may not have // reached the server, so we must NOT drop the flushId: the next flush has to // reuse the SAME flushId so the server's flushId dedupe collapses a retry that // actually landed — otherwise a fresh flushId would record the same // impressions a second time and over-credit. We keep count/token AND // flushId/flushingCount intact; the in-flight marker just stays parked for the // next attempt. function clearInflightImpressions(events: FlushEvent[]): void { try { const flushIds = new Map( events.map((event) => [event.adId, event.flushId]), ); withPendingLock(() => { const latest = readJson(PENDING, {}); const next: PendingImpressions = { _lastTick: latest._lastTick ?? 0 }; for (const [adId, value] of Object.entries(latest)) { if (adId === "_lastTick") continue; if ( typeof value !== "number" && value.flushId && flushIds.get(adId) === value.flushId ) { // Preserve flushId + flushingCount so the retry is idempotent, and // lastTick so the per-ad debounce window survives the rollback. next[adId] = { count: value.count, impressionToken: value.impressionToken, lastTick: value.lastTick, flushId: value.flushId, flushingCount: value.flushingCount, }; continue; } next[adId] = value; } atomicWrite(PENDING, JSON.stringify(next)); }); } catch { // Keep pending state best-effort; a later refresh can retry cleanup. } } function clearDemoImpressions(): void { try { withPendingLock(() => { const latest = readJson(PENDING, {}); const next: PendingImpressions = { _lastTick: latest._lastTick ?? 0 }; for (const [adId, value] of Object.entries(latest)) { if (adId === "_lastTick" || isDemoAdId(adId)) continue; next[adId] = value; } atomicWrite(PENDING, JSON.stringify(next)); }); } catch { // Demo impressions are local-only; cleanup is best-effort. } } async function flushImpressions( endpoint: string, authToken?: string, cachedAd?: Ad | null, ): Promise { clearDemoImpressions(); const adForTokens = cachedAd ?? readJson(AD_CACHE, null); let events: FlushEvent[]; try { events = withPendingLock(() => { const pending = readJson(PENDING, {}); const prepared: PendingImpressions = { _lastTick: pending._lastTick ?? 0, }; const events = Object.entries(pending) .filter(([k]) => k !== "_lastTick" && !isDemoAdId(k)) // Annotate the return type so the object literal adopts FlushEvent's // OPTIONAL impressionToken? (rather than inferring it as required from // flushImpressionToken's `string | undefined`), keeping the // `event is FlushEvent` predicate below valid. .map(([adId, value]): FlushEvent | null => { const count = typeof value === "number" ? value : value.count; const impressionToken = typeof value === "number" ? undefined : value.impressionToken; const lastTick = typeof value === "number" ? undefined : value.lastTick; if (count <= 0) { prepared[adId] = { count, impressionToken, lastTick, }; return null; } const existingFlushId = typeof value === "number" ? undefined : value.flushId; const existingFlushingCount = typeof value === "number" ? undefined : value.flushingCount; const flushingCount = existingFlushId && existingFlushingCount && existingFlushingCount > 0 && existingFlushingCount <= count ? existingFlushingCount : count; const flushId = existingFlushId && flushingCount === existingFlushingCount ? existingFlushId : newFlushId(); prepared[adId] = { count, impressionToken, lastTick, flushId, flushingCount, }; return { adId, count: flushingCount, flushId, impressionToken: flushImpressionToken( adForTokens, adId, impressionToken, ), }; }) .filter((event): event is FlushEvent => event !== null); atomicWrite(PENDING, JSON.stringify(prepared)); return events; }); } catch { return 0; } const total = events.reduce((n, e) => n + e.count, 0); if (total === 0) return 0; let recordedImpressionTotal = 0; for (let i = 0; i < events.length; i += MAX_EVENTS_PER_FLUSH) { const batch = events.slice(i, i + MAX_EVENTS_PER_FLUSH); try { const res = await fetch(`${endpoint.replace(/\/+$/, "")}/impressions`, { method: "POST", headers: { "content-type": "application/json", // Attach the dench agent session so the server can credit the // developer's 50% share; earning is signin-gated. ...(authToken ? { authorization: `Bearer ${authToken}` } : {}), }, body: JSON.stringify({ events: batch }), }); if (res.ok) { const data = (await res.json().catch(() => ({}))) as { results?: Array<{ adId?: string; flushId?: string; recorded?: boolean; recordedImpressions?: number; duplicate?: boolean; drop?: boolean; }>; }; const flushResults = new Map( (data.results ?? []) .filter( (r) => typeof r.adId === "string" && typeof r.flushId === "string", ) .map((r) => [ `${r.adId}:${r.flushId}`, { recordedImpressions: Math.max( 0, Math.floor(Number(r.recordedImpressions ?? 0)), ), drop: r.drop === true, }, ]), ); recordedImpressionTotal += (data.results ?? []) .filter( (r) => r.recorded && !r.duplicate && typeof r.adId === "string" && typeof r.flushId === "string", ) .reduce( (n, r) => n + Math.max(0, Math.floor(Number(r.recordedImpressions ?? 0))), 0, ); if (flushResults.size === 0) { clearInflightImpressions(batch); } else { withPendingLock(() => { const latest = readJson(PENDING, {}); const next: PendingImpressions = { _lastTick: latest._lastTick ?? 0, }; for (const [adId, value] of Object.entries(latest)) { if (adId === "_lastTick") continue; const currentCount = typeof value === "number" ? value : value.count; const flushId = typeof value === "number" ? undefined : value.flushId; const flushingCount = typeof value === "number" ? undefined : value.flushingCount; if ( flushId && flushingCount && flushResults.has(`${adId}:${flushId}`) ) { const result = flushResults.get(`${adId}:${flushId}`); const completedCount = completedFlushCount( currentCount, flushingCount, result, ); const remaining = Math.max(0, currentCount - completedCount); if (remaining > 0) { next[adId] = { count: remaining, impressionToken: typeof value === "number" ? undefined : value.impressionToken, lastTick: typeof value === "number" ? undefined : value.lastTick, }; } else if ( typeof value !== "number" && typeof value.lastTick === "number" ) { next[adId] = { count: 0, impressionToken: value.impressionToken, lastTick: value.lastTick, }; } continue; } next[adId] = value; } atomicWrite(PENDING, JSON.stringify(next)); }); } continue; } clearInflightImpressions(batch); return recordedImpressionTotal; } catch { clearInflightImpressions(batch); return recordedImpressionTotal; } } return recordedImpressionTotal; } function newFlushId(): string { return `${Date.now().toString(36)}-${process.pid.toString(36)}-${Math.random().toString(36).slice(2)}`; } async function refresh(authToken?: string): Promise { ensureDir(); const endpoint = adEndpoint(); saveAdSource(); // let the detached background worker know where to refetch let ad: Ad | null = null; let flushed = 0; let src: "live" | "demo" | "none" = "none"; let keepExistingCache = false; if (endpoint) { const previousAd = loadAd({ allowExpired: true }); // Fetch first for latest inventory, then flush using each pending row's // saved impression token; pass the signed-in token so earnings attribute. const fetched = await fetchAd(endpoint); ad = fetched.ad; flushed = await flushImpressions(endpoint, authToken, ad ?? previousAd); if (ad) { src = "live"; } else if (previousAd && !isDemoAdId(previousAd.id)) { // The endpoint had no campaign this poll — keep a recently-live creative // briefly so the line doesn't flicker between fetches, but ONLY if it // hasn't expired. Past expiry with no live campaign → clear it (below). if (previousAd.expiresAt && Date.now() <= previousAd.expiresAt) { ad = previousAd; src = "live"; keepExistingCache = true; } } // CRITICAL: when pointed at a real endpoint that returns NO campaign, show // NOTHING. Never fall back to bundled demo ads in production — that would // display a fake "Linear"/"Vercel" line nobody paid for. Demo ads are only // for the no-endpoint (offline/local) path below. if (!ad) { clearAdCache(); refreshClaudeSpinnerVerb(); return "No live campaign — nothing to show."; } } else { // No endpoint configured at all (offline/dev) → bundled demo rotation so // the feature is demonstrable with zero backend. ad = pickDemoAd(); src = "demo"; } if (!keepExistingCache) { ad.expiresAt = Date.now() + DEFAULT_TTL_MS; atomicWrite(AD_CACHE, JSON.stringify(ad)); } refreshClaudeSpinnerVerb(); const flushMsg = endpoint && flushed > 0 ? ` (flushed ${flushed} impressions)` : ""; return `Cached ${src} ad: ${ad.label}${flushMsg}`; } // Remove the cached ad so the hooks/render print nothing when no campaign is // live. Best-effort; the render script already treats a missing cache as // "print nothing". function clearAdCache(): void { try { if (existsSync(AD_CACHE)) unlinkSync(AD_CACHE); } catch { // best-effort } } // --------------------------------------------------------------------------- // status // --------------------------------------------------------------------------- function claudeInstalled(): boolean { const settings = readJson>(CLAUDE_SETTINGS, {}); return claudeInstalledFrom(settings); } // True when the given parsed settings already carry OUR statusLine (it points // at our render script). Used to decide whether a settings file represents // user state worth (re)backing up vs. our own prior install. function claudeInstalledFrom(settings: Record): boolean { const sl = settings.statusLine as { command?: string } | undefined; return Boolean(sl?.command?.includes(RENDER_SCRIPT)); } function codexInstalled(): boolean { if (!existsSync(CODEX_HOOKS)) return false; const file = readJson(CODEX_HOOKS, {}); return Object.values(file.hooks ?? {}).some((list) => list.some((e) => isDenchCodexEntry(e)), ); } function shellInstalled(): boolean { return shellRcPaths().some( (rc) => existsSync(rc) && readFileSync(rc, "utf8").includes(SHELL_MARK_START), ); } function agentMdInstalled(): boolean { return ["CLAUDE.md", "AGENTS.md"].some((f) => { const path = join(process.cwd(), f); return ( existsSync(path) && readFileSync(path, "utf8").includes(MD_MARK_START) ); }); } function adEndpointFromEnv(): string | undefined { return ( process.env.DENCH_ADS_ENDPOINT ?? process.env.DENCH_ADLINE_ENDPOINT )?.trim(); } function adEndpointSource(): string { return adEndpointFromEnv() ? "configured live endpoint" : "default live endpoint"; } function statusReport(json: boolean): string { const ad = readJson(AD_CACHE, null); const pending = readJson(PENDING, {}); const impressions = Object.entries(pending) .filter(([k]) => k !== "_lastTick") .reduce((n, [, v]) => n + (typeof v === "number" ? v : v.count), 0); const report = { endpoint: adEndpoint(), endpointSource: adEndpointSource(), claudeStatusLine: claudeInstalled(), codexHooks: codexInstalled(), shellHook: shellInstalled(), agentMd: agentMdInstalled(), renderScript: existsSync(RENDER_SCRIPT) ? RENDER_SCRIPT : "(not installed)", cachedAd: ad?.label ?? "(none — run `dench ads refresh`)", pendingImpressions: impressions, }; if (json) return JSON.stringify(report, null, 2); return [ "dench ads status", ` ad source: ${report.endpoint} (${report.endpointSource})`, ` Claude statusLine: ${report.claudeStatusLine ? "installed" : "not installed"}`, ` Codex hooks: ${report.codexHooks ? "installed" : "not installed"}`, ` shell hook: ${report.shellHook ? "installed" : "not installed"}`, ` agent md block: ${report.agentMd ? "installed (cwd)" : "not installed (cwd)"}`, ` render script: ${report.renderScript}`, ` cached ad: ${report.cachedAd}`, ` pending views: ${report.pendingImpressions}`, ].join("\n"); } // --------------------------------------------------------------------------- // earnings / payouts // --------------------------------------------------------------------------- function requireAdsSignin(authToken: string | undefined, command: string) { if (!authToken) { throw new AdsError( `${command} requires dench signin. Run \`dench signin --kind cli --name "Dench Ads"\` first.`, ); } return authToken; } function requireConvexUrl(convexUrl: string | undefined) { if (!convexUrl) { throw new AdsError("Could not resolve the Dench backend for earnings."); } return convexUrl; } function adsApiHost(host: string | undefined): string { if (host) return host.replace(/\/+$/, ""); const endpoint = adEndpoint()?.replace(/\/+$/, "") ?? DEFAULT_ADS_ENDPOINT; return endpoint.endsWith("/api/ads") ? endpoint.slice(0, -"/api/ads".length) : "https://dench.com"; } function formatCents(cents: number): string { return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; } function loadPendingPayout(): PendingPayout | null { const pending = readJson | null>(PENDING_PAYOUT, null); if ( typeof pending?.amountCents !== "number" || pending.amountCents <= 0 || typeof pending.idempotencyKey !== "string" || !pending.idempotencyKey ) { return null; } return { amountCents: Math.floor(pending.amountCents), idempotencyKey: pending.idempotencyKey, createdAt: typeof pending.createdAt === "number" ? pending.createdAt : Date.now(), }; } function savePendingPayout(amountCents: number, idempotencyKey: string): void { atomicWrite( PENDING_PAYOUT, `${JSON.stringify({ amountCents, idempotencyKey, createdAt: Date.now() }, null, 2)}\n`, ); } function clearPendingPayout(idempotencyKey: string): void { const pending = loadPendingPayout(); if (pending && pending.idempotencyKey !== idempotencyKey) return; try { unlinkSync(PENDING_PAYOUT); } catch {} } async function loadPayoutStatus(opts: { authToken: string; convexUrl: string; }): Promise<{ earnings: EarningsStatus; payout: PayoutStatus }> { const client = new ConvexHttpClient(opts.convexUrl, { logger: false }); const [earnings, payout] = await Promise.all([ client.query(convexApi.functions.adCampaigns.myEarnings, { sessionToken: opts.authToken, }) as Promise, client.query(convexApi.functions.adPayouts.myPayoutStatus, { sessionToken: opts.authToken, }) as Promise, ]); return { earnings, payout }; } function renderEarningsStatus(input: { earnings: EarningsStatus; payout: PayoutStatus; json: boolean; }): string { if (input.json) { return JSON.stringify(input, null, 2); } const { earnings, payout } = input; const payoutState = payout.payoutsEnabled ? "ready" : payout.onboarded ? "pending Stripe review" : "not set up"; const next = payout.availableCents >= payout.minPayoutCents ? payout.payoutsEnabled ? "Run `dench ads withdraw --amount all`." : "Run `dench ads payout setup` to connect Stripe." : `Minimum cash-out is ${formatCents(payout.minPayoutCents)}.`; return [ "dench ads earnings", ` available: ${formatCents(payout.availableCents)}`, ` lifetime earned: ${formatCents(earnings.lifetimeEarnedCents)}`, ` paid out: ${formatCents(payout.paidOutCents)}`, ` reserved: ${formatCents(payout.reservedCents)}`, ` impressions: ${earnings.impressions.toLocaleString("en-US")}`, ` Stripe payouts: ${payoutState}`, ` minimum payout: ${formatCents(payout.minPayoutCents)}`, "", next, ].join("\n"); } async function runEarningsCommand(opts: { authToken?: string; convexUrl?: string; json: boolean; }): Promise { const authToken = requireAdsSignin(opts.authToken, "dench ads earnings"); const convexUrl = requireConvexUrl(opts.convexUrl); const status = await loadPayoutStatus({ authToken, convexUrl }); console.log(renderEarningsStatus({ ...status, json: opts.json })); } async function runPayoutSetupCommand(opts: { authToken?: string; host?: string; json: boolean; noOpen: boolean; }): Promise { const authToken = requireAdsSignin(opts.authToken, "dench ads payout setup"); const res = await fetch(`${adsApiHost(opts.host)}/api/ads/payouts/onboard`, { method: "POST", headers: { authorization: `Bearer ${authToken}` }, }); const data = (await res.json().catch(() => ({}))) as { url?: string; accountId?: string; error?: string; }; if (!res.ok || !data.url) { throw new AdsError(data.error ?? "Could not start Stripe Connect setup."); } if (opts.json) { console.log(JSON.stringify(data, null, 2)); return; } const opened = await openUrl(data.url, { noOpen: opts.noOpen, json: opts.json, env: process.env, }); if (opened.status === "opened") { console.log( "Opened Stripe Connect onboarding. Finish it in the browser, then run `dench ads earnings`.", ); return; } console.log(`Open this Stripe Connect onboarding link:\n${data.url}`); } function parseWithdrawAmount(input: string | undefined): number | "all" { const value = input?.trim(); if (!value) throw new AdsError("Missing --amount ."); if (value.toLowerCase() === "all") return "all"; const normalized = value.replace(/^\$/, "").replace(/,/g, ""); const dollars = Number(normalized); if (!Number.isFinite(dollars) || dollars <= 0) { throw new AdsError( "Withdraw amount must be `all` or a positive dollar amount.", ); } return Math.round(dollars * 100); } async function runWithdrawCommand(opts: { args: string[]; authToken?: string; convexUrl?: string; host?: string; json: boolean; }): Promise { const authToken = requireAdsSignin(opts.authToken, "dench ads withdraw"); const convexUrl = requireConvexUrl(opts.convexUrl); const amountInput = getFlag(opts.args, "--amount"); const explicitIdempotencyKey = getFlag(opts.args, "--idempotency-key"); const amount = parseWithdrawAmount(amountInput); const pendingPayout = explicitIdempotencyKey ? null : loadPendingPayout(); const pendingPayoutToRetry = pendingPayout && (amount === "all" || amount === pendingPayout.amountCents) ? pendingPayout : null; const idempotencyKey = explicitIdempotencyKey ?? pendingPayoutToRetry?.idempotencyKey ?? `ads_payout_${Date.now().toString(36)}_${randomUUID()}`; const { payout } = await loadPayoutStatus({ authToken, convexUrl }); if (!payout.payoutsEnabled) { throw new AdsError( "Stripe payouts are not ready. Run `dench ads payout setup` first.", ); } const amountCents = pendingPayoutToRetry ? pendingPayoutToRetry.amountCents : amount === "all" ? payout.availableCents : amount; if (!pendingPayoutToRetry && amountCents < payout.minPayoutCents) { throw new AdsError( `Minimum cash-out is ${formatCents(payout.minPayoutCents)}; available is ${formatCents(payout.availableCents)}.`, ); } if (!pendingPayoutToRetry && amountCents > payout.availableCents) { throw new AdsError( `Insufficient ad balance. Available: ${formatCents(payout.availableCents)}.`, ); } let res: Response; try { res = await fetch(`${adsApiHost(opts.host)}/api/ads/payouts/withdraw`, { method: "POST", headers: { authorization: `Bearer ${authToken}`, "content-type": "application/json", "idempotency-key": idempotencyKey, }, body: JSON.stringify({ amountCents }), }); } catch (error) { throw new AdsError( `Withdraw request failed. Retry safely with \`dench ads withdraw --amount ${formatCents(amountCents)} --idempotency-key ${idempotencyKey}\`. ${error instanceof Error ? error.message : ""}`.trim(), ); } const data = (await res.json().catch(() => ({}))) as { ok?: boolean; transferId?: string; amountCents?: number; idempotencyKey?: string; error?: string; }; const responseIdempotencyKey = data.idempotencyKey ?? idempotencyKey; if (res.status === 202) { savePendingPayout(amountCents, responseIdempotencyKey); } else if (res.ok && data.ok) { clearPendingPayout(responseIdempotencyKey); } if (opts.json) { console.log(JSON.stringify({ ...data, idempotencyKey }, null, 2)); return; } if (res.status === 202) { console.log( [ "Payout sent, but finalization is pending.", `Retry safely with: dench ads withdraw --amount ${formatCents(amountCents)} --idempotency-key ${responseIdempotencyKey}`, ].join("\n"), ); return; } if (!res.ok || !data.ok) { throw new AdsError(data.error ?? "Payout failed."); } console.log( `Withdrew ${formatCents(amountCents)} in real USD via Stripe Connect. Transfer: ${data.transferId ?? "(pending)"}`, ); } // --------------------------------------------------------------------------- // help // --------------------------------------------------------------------------- function adsHelp(): void { console.log(adsHelpText()); } export function adsHelpText(): string { return `\`dench ads\` — monetize the AI "thinking…" line. (aliases: dench ad, dench adline) Usage: dench ads Print the sponsored ad in a decorated rainbow box dench ads show (same as above — what the coding agent runs) Renders the cached ad as a colorful terminal box with an inline image icon when the terminal supports it (iTerm2 / kitty), a unicode glyph otherwise. Counts one impression. dench ads install [--target claude|codex|shell|agentmd|all] [--apply] Wire the ad in automatically: claude → Claude Code's official statusLine hook (~/.claude/settings.json) codex → Codex CLI lifecycle hooks (~/.codex/hooks.json); shows the line as a systemMessage on SessionStart + after each turn shell → one line at shell startup, any terminal (zsh/bash/fish); needs --apply agentmd → append an instruction block to ./CLAUDE.md and ./AGENTS.md so the agent runs \`dench ads show\` during its turn all → all of the above Also drops the single-line renderer + background refresh + seeds a first ad. dench ads refresh Fetch the current ad; flush pending impressions. dench ads earnings Show real USD ad earnings + payout readiness. dench ads payout setup Open Stripe Connect onboarding for cash payouts. dench ads withdraw --amount all [--idempotency-key key] Withdraw available real USD ad earnings. dench ads status [--json] Show install state, cached ad, pending views. dench ads uninstall [--target claude|codex|shell|agentmd|all] Reversible. Notes: • No file patching. Uses only supported hooks + stdout, so it can't break Claude Code the way kickbacks.ai does. • The ad is an OSC 8 hyperlink (clickable in iTerm2, WezTerm, kitty, …). • Set DENCH_ADS_ENDPOINT=https://your-server/ads to serve real ads: GET returns {id,label,url,icon,iconUrl}; POST {endpoint}/impressions takes {events:[{adId,count,flushId}]}. `; } // --------------------------------------------------------------------------- // entry // --------------------------------------------------------------------------- export async function runAdsCommand(opts: { args: string[]; // The current dench agent session token, when the user is signed in. Used // to attribute earnings on flush and to authenticate payout commands. authToken?: string; convexUrl?: string; host?: string; }): Promise { const args = [...opts.args]; const authToken = opts.authToken; const json = hasFlag(args, "--json"); const noOpen = hasFlag(args, "--no-open"); const apply = hasFlag(args, "--apply"); // `--color` forces the rainbow on even when stdout isn't a TTY (the agent // case). `--no-color` is the explicit opt-out for a forced-color default. if (hasFlag(args, "--no-color")) process.env.NO_COLOR = "1"; if (hasFlag(args, "--color")) FORCE_COLOR_OVERRIDE = true; // --target resolution shared by install/uninstall const targetIdx = args.indexOf("--target"); let target = "all"; if (targetIdx !== -1) { target = args[targetIdx + 1] ?? ""; args.splice(targetIdx, 2); } const wantClaude = target === "claude" || target === "all"; const wantCodex = target === "codex" || target === "all"; // Codex gets TWO complementary surfaces. (1) The systemMessage hook renders // the ad as a `warning:` line at session start + after each turn (verified // live in v0.139). (2) The AGENTS.md instruction has the agent run // `dench ads show --color` at a stopping point, printing the full rainbow box // in the transcript. So a Codex install writes both — the compact hook line // every turn, plus the richer box when the agent surfaces it. const wantShell = target === "shell" || target === "all"; const wantAgentMd = target === "agentmd" || target === "all"; const sub = args.shift(); if ( (sub === "install" || sub === "uninstall") && !ADS_INSTALL_TARGETS.includes(target) ) { throw new AdsError( `Invalid --target "${target}". Expected one of: ${ADS_INSTALL_TARGET_LIST}`, ); } // Bare `dench ads` (and aliases) → show the box. Friendliest default and // exactly what the agent-instruction block tells the agent to run. if (!sub || sub === "show") { const ad = loadAd(); if (!ad) { // Auto-seed so the very first run isn't blank. await refresh(authToken); } const fresh = loadAd() ?? loadAd({ allowExpired: true }); if (!fresh) return; // genuinely nothing to show; stay silent process.stdout.write(`${await renderBox(fresh)}\n`); countImpression(fresh); const endpoint = adEndpoint(); if (endpoint) await flushImpressions(endpoint, authToken, fresh); return; } if (sub === "help" || sub === "--help") { adsHelp(); return; } if (sub === "install") { writeRenderScript(); const seeded = await refresh(authToken); const out: string[] = [seeded]; // Under `--target all` a missing client (no ~/.claude, no ~/.codex) should // be SKIPPED with a note, not abort the whole install — otherwise a machine // without Claude Code can't install the shell/Codex surfaces. For an // explicit single target the error still propagates (you asked for it). const allowSkip = target === "all"; if (wantClaude) out.push(runInstallStep(installClaude, allowSkip)); if (wantCodex) out.push(runInstallStep(installCodex, allowSkip)); if (wantShell) out.push(runInstallStep(() => installShell(apply), allowSkip)); // agent-md installs for an explicit --target agentmd OR alongside codex // (the reliable visible surface there), but not twice under --target all. if (wantAgentMd || (wantCodex && target !== "all")) out.push(runInstallStep(installAgentMd, allowSkip)); out.push(""); out.push( wantShell && !apply ? "Done with automatic installs. Shell hook was not written; paste the snippet above or re-run with `--apply`." : "Done. Run `dench ads` to preview the box.", ); console.log(out.filter(Boolean).join("\n")); return; } if (sub === "uninstall") { const allowSkip = target === "all"; const out: string[] = []; if (wantClaude) out.push(runInstallStep(uninstallClaude, allowSkip)); if (wantCodex) out.push(runInstallStep(uninstallCodex, allowSkip)); if (wantShell) out.push(runInstallStep(uninstallShell, allowSkip)); if (wantAgentMd || (wantCodex && target !== "all")) { out.push(runInstallStep(uninstallAgentMd, allowSkip)); } console.log(out.filter(Boolean).join("\n")); return; } if (sub === "refresh") { console.log(await refresh(authToken)); return; } if (sub === "earnings") { await runEarningsCommand({ authToken, convexUrl: opts.convexUrl, json, }); return; } if (sub === "payout") { const payoutSub = args.shift(); if (payoutSub === "setup" || payoutSub === "onboard") { await runPayoutSetupCommand({ authToken, host: opts.host, json, noOpen, }); return; } if (!payoutSub || payoutSub === "help" || payoutSub === "--help") { console.log( [ "dench ads payout", " dench ads payout setup Connect Stripe for real USD payouts.", ].join("\n"), ); return; } throw new AdsError(`Unknown ads payout subcommand: ${payoutSub}`); } if (sub === "withdraw") { await runWithdrawCommand({ args, authToken, convexUrl: opts.convexUrl, host: opts.host, json, }); return; } if (sub === "render") { // Single-line form (what the hooks emit). For testing / scripting. const ad = loadAd(); if (json) { console.log(JSON.stringify(ad ?? {}, null, 2)); return; } if (!ad) return; const icon = ad.icon ? `${ad.icon} ` : ""; process.stdout.write(`${osc8(ad.url, `${icon}${ad.label}`)}\n`); countImpression(ad); return; } if (sub === "status") { console.log(statusReport(json)); return; } throw new AdsError(`Unknown ads subcommand: ${sub}`); } export { AdsError, completedFlushCount, flushImpressionToken };