#!/usr/bin/env node /** * Hafta status line for Claude Code. * * Claude Code invokes this on every status-line refresh, passing a JSON blob on * stdin, and renders each stdout line. Both `statusLine` and `spinnerVerbs` are * first-class settings — nothing here patches, wraps, or monkey-patches Claude * Code itself, which is what keeps this from breaking on every update. * * Two hard rules, in this order: * * 1. **Never hang.** A wedged status line wedges the user's editor. Every * path is bounded by a deadline and the process exits on its own. * 2. **Never block on the network.** The refresh reads a cache file written * by a detached background fetch. The user's keystroke latency must never * depend on our API being up. * * If anything at all goes wrong this prints nothing and exits 0. A broken ad is * worth exactly zero; a broken editor costs the user far more than we earn. */ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, readFileSync, writeFileSync, writeSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' import { acquireLease, heartbeat } from './hafta-presence.mjs' const HOME = join(homedir(), '.hafta') const CACHE = join(HOME, 'slot.json') const CONFIG = join(HOME, 'config.json') // How long a fetched slot stays displayable. Deliberately tight — just over two // rotations. There is no offline mode: if the server cannot be reached we stop // showing anything rather than render an ad we will never be able to bill for. const FRESH_MS = 2 * 60 * 1000 // Rotate every 12s: the 10s view threshold plus 2s of margin. // // This is the floor, not a target. Anything under the threshold rotates before // the impression is billable and is worth exactly zero to everyone — the // developer, the advertiser, and us. The 2s buys tolerance for a status line // that refreshes on its own schedule rather than ours. const ROTATE_MS = 12 * 1000 // When there is nothing to show — outage, killswitch, or empty inventory — retry // sooner than a full rotation. Reusing ROTATE_MS here meant every blip cost a // full 45s of blank line after the network had already come back. const RETRY_MS = 12 * 1000 const DEADLINE_MS = 900 // stdout on a pipe is async; process.exit() drops pending chunks. writeSync // is the only way to guarantee the line actually lands. const put = (s) => { try { writeSync(1, s) } catch {} } // stdin is a one-shot stream; keep the raw payload for the chained command. let STDIN_RAW = '' function readJson(path, fallback = null) { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return fallback } } /** Strip C0/C1 control characters so creative text can never emit its own * escape sequences into the user's terminal. The OSC 8 framing below is the * only escape this script is allowed to print. */ const clean = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f-\x9f]/g, '') function main() { const cfg = readJson(CONFIG) if (!cfg?.token || !cfg?.api || cfg.enabled === false) return // Claude Code hands us a JSON payload; `session_id` is what distinguishes one // open window from another. Read it defensively — a status line that dies on // an unexpected payload shape takes the user's editor chrome with it. let sessionId = 'unknown' try { STDIN_RAW = readFileSync(0, 'utf8') const parsed = JSON.parse(STDIN_RAW) if (parsed?.session_id) sessionId = String(parsed.session_id) } catch {} heartbeat(sessionId) // Only the lease holder may serve or settle. Everyone else renders the same // cached creative and bills nothing — one human, one billable attention // stream, however many windows they have open. const leader = acquireLease(sessionId) const slot = readJson(CACHE) const now = Date.now() const fresh = slot && typeof slot.ts === 'number' && now - slot.ts <= FRESH_MS // Exactly one worker per refresh, and it always carries the outgoing slot's // token. There used to be two spawn sites — one to fetch, one to settle — // and because `slot.ts` and `slot.shownAt` are within a second of each other // both fired at once. The plain fetch overwrote the cache before the settle // worker read it, so most impressions were served and never settled: real // views the developer was never paid for. const interval = slot?.creative ? ROTATE_MS : RETRY_MS const settleable = slot?.token && slot.shownAt && !slot.settled && now - slot.shownAt >= ROTATE_MS // A slot is still earning until it has been on screen for a full rotation, // and `ts` (fetched) runs a beat ahead of `shownAt` (first painted). Timing // the replacement off `ts` therefore threw the current slot away seconds // before it became billable — the developer watched the ad and was paid // nothing for it. Never fetch over a live, unsettled slot. const live = slot?.token && slot.shownAt && !slot.settled const due = !slot || (!live && now - (slot.ts || 0) >= interval) if (leader && (due || settleable)) { try { const args = [join(import.meta.dirname, 'hafta-fetch.mjs')] if (settleable) { // Mark it settled before spawning, so a second refresh arriving in the // same instant cannot queue the same token twice. slot.settled = true writeFileSync(CACHE, JSON.stringify(slot)) args.push('--settle', slot.token, String(now - slot.shownAt)) } spawn(process.execPath, args, { detached: true, stdio: 'ignore' }).unref() } catch {} } if (!fresh || !slot.creative) { if (cfg.chain) runChained(cfg.chain) return } // Mark first paint. `shownAt` is what the visible-duration claim is measured // from — and the server bounds that claim against its own clock anyway. if (leader && !slot.shownAt) { slot.shownAt = now try { writeFileSync(CACHE, JSON.stringify(slot)) } catch {} } const text = clean(slot.creative) const url = /^https?:\/\//i.test(slot.clickUrl || '') ? clean(slot.clickUrl) : '' // OSC 8 hyperlinks are not universally supported, and a terminal that does // not understand them renders the escape bytes as garbage. Detect passively // from the environment — never by interrogating the TTY, whose reply would // land in the input stream Claude Code's TUI is reading. const e = process.env let shape = 'hybrid' if (e.TMUX) shape = 'plain' else if (e.SSH_TTY || e.SSH_CONNECTION) shape = 'hybrid' else if (e.KITTY_WINDOW_ID || e.WEZTERM_PANE) shape = 'osc8' else if (e.TERM_PROGRAM === 'iTerm.app' || e.TERM_PROGRAM === 'vscode') shape = 'osc8' const dot = '\x1b[33m◆\x1b[0m' // amber diamond const dim = (s) => `\x1b[2m${s}\x1b[0m` let line if (url && shape === 'osc8') { line = `${dot} \x1b]8;;${url}\x07${text}\x1b]8;;\x07` } else if (url) { line = `${dot} ${text} ${dim(url)}` } else { line = `${dot} ${text}` } put(line + '\n') // Chain the user's previous status line, if the installer captured one, so // installing Hafta never silently deletes a status line they already had. if (cfg.chain) runChained(cfg.chain) } function runChained(cmd) { try { // stdin was already consumed above for session_id, so it cannot be read // again here. Pass the payload we kept instead of silently handing the // chained command an empty stream. const child = spawn(cmd, { shell: true, timeout: DEADLINE_MS, input: STDIN_RAW }) let out = '' child.stdout?.on('data', (d) => { out += d }) child.on('close', () => { if (out.trim()) put(out.endsWith('\n') ? out : out + '\n'); process.exit(0) }) setTimeout(() => process.exit(0), DEADLINE_MS) } catch { /* a broken chained command must never take us down */ } } try { if (!existsSync(HOME)) mkdirSync(HOME, { recursive: true, mode: 0o700 }) } catch {} try { main() } catch { /* never throw into the status line */ }