tickerline

audited · cautionFree

A rolling ticker tape of whatever you're discussing: stocks, crypto, and Polymarket prediction markets, resolved live from the conversation. No watchlist, no dictionary, no keys - three pinned public APIs, zero-network steady state. /tlm opens any market in-chat.

by goekhan2 installsmarketsstockscryptopolymarketcontext-aware
Claude Code
>
34% |🌿 main|/tlm <n> opens a market
|ETH 1,805.61 ^0.42%|BTC 64,016.00 v0.12%|MSFT 385.10 ^0.19%·|TSLA 407.76 ^0.30%·
hat will Palantir Technologies Inc. (PLTR) hit in July 2026? 11%|9. Will Tesla launch robotaxis
? for shortcuts⏵⏵ accept edits on
Security auditby claude-opus-4-8 · verdict: caution

tickerline is a Node.js statusline that reads the session JSON on stdin and tails ~1MB of the conversation transcript (.jsonl), extracts capitalized words and $cashtags from the last ~16 messages, and sends those tokens as search/price queries to three pinned public HTTPS APIs (Yahoo Finance, CoinGecko, and Polymarket's Gamma API). It renders a scrolling ticker band of resolved stock/crypto prices plus related prediction markets on the status line. It caches resolved tickers, quotes, markets, and per-session detection state as JSON files under ~/.claude/, using atomic temp+rename writes. Git branch is read purely via the filesystem (no shell). It does NOT execute subprocesses, use eval/base64, or modify settings.json — the --init subcommand only writes ~/.claude/tickerline.json and (on consent) copies a bundled tlm.md into ~/.claude/commands/, and merely prints (does not write) the settings.json snippet.

networkwrites-filesreads-homereads-transcriptenv-vars

Automated scanner flags

  • high: reads credentials / key material

Audits are advisory. This script runs on your machine — read it before installing.

2 installs

The script — read it before you run it

#!/usr/bin/env node
// tickerline v0.5.0 — a Claude Code status line whose rolling ticker tape reflects
// the companies, tokens, and prediction markets you're discussing. No dictionary, no
// fixed watchlist: names resolve to tickers LIVE via Yahoo search (stocks/ETFs) and
// CoinGecko (crypto), plus Polymarket markets. /tlm (installed by --init) opens any
// market from the band in-chat.
// Privacy: context awareness sends capitalized words from recent messages as search
// queries to Yahoo Finance, and matched names to Polymarket — HTTPS, keyless, nothing
// else; contextAware:false / polymarket:false turn that off. No shell, no keys, hosts
// pinned, input validated. Config: ~/.claude/tickerline.json.

const https = require('https');
const fs = require('fs');
const os = require('os');
const path = require('path');

const RESET = '\x1b[0m', GREEN = '\x1b[32m', RED = '\x1b[31m', YELLOW = '\x1b[33m', CYAN = '\x1b[36m', DIM = '\x1b[2m';
const FETCH_TIMEOUT_MS = 1500;
const MAX_BODY_BYTES = 524288;
const TRANSCRIPT_TAIL_BYTES = 1048576; // tail 1MB — reach past huge tool lines
const RECENT_MESSAGES = 16;   // last N text messages: tracks "now", self-clears
const DETECT_TTL_MS = 4000;   // re-scan the transcript at most this often
const RESOLVE_TTL_MS = 86400000; // name -> ticker is stable; cache a day
const ALLOWED_HOST = 'query1.finance.yahoo.com';
const CG_HOST = 'api.coingecko.com';           // canonical crypto prices
const POLY_HOST = 'gamma-api.polymarket.com';  // Gamma API, keyless
const POLY_TTL_MS = 300000;   // prediction markets move slowly
const POLY_SEARCH_TERMS = 6;
const TICKER_RE = /^[A-Z0-9.^=-]{1,15}$/;
const US_SYM_RE = /^[A-Z0-9]{1,6}(-USD)?$/;    // bare US equity/ETF or crypto -USD (rejects .BA/.NS foreign listings)

// Capitalized words that are almost never company mentions — the name-match check in
// resolveOne is the real filter; this just trims obvious noise to save lookups.
const STOP = new Set(('the a an i my your you we they it he she this that these those and or but for so if then also two one '
  + 'both not no as at by of in on to from with about into over under more most some any all each other vs versus its our his '
  + 'her their what which who how why when where here there now new next last either neither is are was were be been being '
  + 'will would can could should may might must have has had do does did get make made see look watch keep take put use used given '
  + 'agent agents inference training model models data token tokens memory chip chips '
  + 'cybersecurity security software hardware cloud network compute storage platform product service company companies startup '
  + 'private public open close high low up down first second '
  + 'ai gpu gpus cpu asic asics hbm cowos depin rwa defi tvl apr apy usd api sdk llm llms ceo cfo cto q1 q2 q3 q4 us eu inc corp ltd co '
  + 'utf json yaml html css svg npm git http https url sql jwt ansi ascii regex readme todo ipo etf').split(/\s+/).filter(Boolean));

const DEFAULTS = {
  modules: ['context', 'git'], // side info on row 1; below it roll the ticker + markets bands
  ttl: 60,
  ascii: false,
  contextAware: true,   // the whole product: the band reflects what you discuss
  maxTopicTickers: 30,  // fetch bound; the rolling band shows them all
  scrollSpeed: 2,       // columns/sec, wall-clock
  polymarket: true,     // second band: context Polymarket markets, volume-ranked
  maxPolyMarkets: 12,
  hint: true,           // "/tlm opens a market" pointer on row 1
};

let G;
function glyphs(ascii) {
  return ascii
    ? { up: '^', down: 'v', barFull: '#', barEmpty: '-', git: '', warn: '!', sep: '|', dot: '.', ell: '...' }
    : { up: '▲', down: '▼', barFull: '█', barEmpty: '░', git: '🌿 ', warn: '⚠', sep: '|', dot: '·', ell: '…' };
}

function loadConfig() {
  const configPath = process.env.TICKERLINE_CONFIG || path.join(os.homedir(), '.claude', 'tickerline.json');
  let fileConfig = {}, configError = false;
  try {
    const raw = fs.readFileSync(configPath, 'utf8');
    try { fileConfig = JSON.parse(raw); } catch { configError = true; } // present but malformed — surface it
  } catch { /* absent file is fine: use defaults */ }

  const cfg = { ...DEFAULTS, ...fileConfig };
  cfg._configError = configError;
  if (process.env.TICKERLINE_MODULES) cfg.modules = process.env.TICKERLINE_MODULES.split(',');
  if (process.env.TICKERLINE_TTL) cfg.ttl = process.env.TICKERLINE_TTL;
  if (process.env.TICKERLINE_CONTEXT_AWARE) cfg.contextAware = /^(1|true)$/i.test(process.env.TICKERLINE_CONTEXT_AWARE);
  if (process.env.TICKERLINE_ASCII) cfg.ascii = /^(1|true)$/i.test(process.env.TICKERLINE_ASCII);
  if (process.env.TICKERLINE_POLYMARKET) cfg.polymarket = /^(1|true)$/i.test(process.env.TICKERLINE_POLYMARKET);

  // Coerce every field so a mistyped config can never crash the bar.
  cfg.modules = (Array.isArray(cfg.modules) ? cfg.modules : DEFAULTS.modules).map(s => String(s).trim()).filter(Boolean);
  cfg.ttl = Number.isFinite(+cfg.ttl) && +cfg.ttl > 0 ? +cfg.ttl : DEFAULTS.ttl;
  cfg.maxTopicTickers = Number.isFinite(+cfg.maxTopicTickers) && +cfg.maxTopicTickers >= 0 ? +cfg.maxTopicTickers : DEFAULTS.maxTopicTickers;
  cfg.scrollSpeed = Number.isFinite(+cfg.scrollSpeed) && +cfg.scrollSpeed > 0 ? +cfg.scrollSpeed : DEFAULTS.scrollSpeed;
  cfg.polymarket = typeof cfg.polymarket === 'boolean' ? cfg.polymarket : DEFAULTS.polymarket;
  cfg.maxPolyMarkets = Number.isFinite(+cfg.maxPolyMarkets) && +cfg.maxPolyMarkets >= 0 ? +cfg.maxPolyMarkets : DEFAULTS.maxPolyMarkets;
  cfg.hint = typeof cfg.hint === 'boolean' ? cfg.hint : DEFAULTS.hint;
  return cfg;
}

function readStdin() {
  if (process.stdin.isTTY) return ''; // manual run with no pipe — don't block forever on EOF
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
}

function httpGet(url, timeoutMs) {
  return new Promise((resolve, reject) => {
    const req = https.get(url, { timeout: timeoutMs, headers: { 'User-Agent': 'Mozilla/5.0' } }, res => {
      if (res.statusCode !== 200) { res.resume(); reject(new Error('status ' + res.statusCode)); return; }
      res.setEncoding('utf8'); // decode across chunk boundaries — raw += would mangle split multibyte chars
      let body = '', size = 0;
      res.on('data', c => { size += Buffer.byteLength(c); if (size > MAX_BODY_BYTES) { req.destroy(new Error('too large')); return; } body += c; });
      res.on('end', () => resolve(body));
      res.on('close', () => { if (!res.complete) reject(new Error('cut off')); }); // mid-body reset MUST settle, or the bar blanks
    });
    const deadline = setTimeout(() => req.destroy(new Error('deadline')), timeoutMs); // total cap — 'timeout' below is idle-only
    deadline.unref();
    req.on('close', () => clearTimeout(deadline));
    req.on('timeout', () => req.destroy(new Error('timeout')));
    req.on('error', reject);
  });
}

// Caches are shared across sessions: tmp+rename so a reader never sees a partial file.
function atomicWrite(file, str) {
  const tmp = file + '.' + process.pid;
  try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(tmp, str); fs.renameSync(tmp, file); }
  catch { try { fs.unlinkSync(tmp); } catch {} }
}

// ---- price fetching: stocks via Yahoo spark (ONE request for all), crypto via CoinGecko ----
async function fetchYahooBatch(tickers) {
  const syms = tickers.filter(t => TICKER_RE.test(t));
  if (!syms.length) return {};
  const url = `https://${ALLOWED_HOST}/v8/finance/spark?symbols=${encodeURIComponent(syms.join(','))}&range=1d&interval=5m`;
  if (new URL(url).hostname !== ALLOWED_HOST) return {};
  const data = JSON.parse(await httpGet(url, FETCH_TIMEOUT_MS));
  const out = {}, now = Date.now();
  for (const s of syms) {
    const r = data[s]; if (!r) continue;
    const closes = (r.close || []).filter(v => typeof v === 'number');
    if (!closes.length) continue;
    const price = closes[closes.length - 1];
    const prev = r.chartPreviousClose ?? r.previousClose;
    const lastTs = (r.timestamp || [])[(r.timestamp || []).length - 1];
    out[s] = {
      price,
      changePct: typeof prev === 'number' && prev !== 0 ? ((price - prev) / prev) * 100 : null,
      stale: typeof lastTs === 'number' && now - lastTs * 1000 > 1200000, // no data 20min — market closed
    };
  }
  return out;
}

async function fetchCoinGecko(ids) {
  if (!ids.length) return {};
  const url = `https://${CG_HOST}/api/v3/simple/price?ids=${encodeURIComponent(ids.join(','))}&vs_currencies=usd&include_24hr_change=true`;
  if (new URL(url).hostname !== CG_HOST) return {};
  const data = JSON.parse(await httpGet(url, FETCH_TIMEOUT_MS));
  const out = {};
  for (const [id, v] of Object.entries(data)) {
    if (v && typeof v.usd === 'number') out[id] = { price: v.usd, changePct: typeof v.usd_24h_change === 'number' ? v.usd_24h_change : null };
  }
  return out;
}

function cacheFilePath() { return path.join(os.homedir(), '.claude', 'tickerline-cache.json'); }

// Per-ticker price cache; each entry timestamped, so a failed fetch keeps the last good value.
async function fetchQuotes(entities, ttlMs) {
  const file = cacheFilePath();
  let cache = {};
  try { cache = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
  const now = Date.now();
  const expired = entities.filter(e => !cache[e.sym] || (now - (cache[e.sym].ts || 0)) >= ttlMs);
  let changed = false;
  const jobs = [];
  const stockE = expired.filter(e => !e.cg);
  if (stockE.length) {
    jobs.push((async () => {
      try {
        const yq = await fetchYahooBatch(stockE.map(e => e.ysym || e.sym));
        for (const e of stockE) { const q = yq[e.ysym || e.sym]; if (q) { cache[e.sym] = { ...q, ts: now }; changed = true; } }
      } catch { /* keep last good */ }
    })());
  }
  const cryptoE = expired.filter(e => e.cg);
  if (cryptoE.length) {
    jobs.push((async () => {
      try {
        const cg = await fetchCoinGecko(cryptoE.map(e => e.cg));
        for (const e of cryptoE) { const q = cg[e.cg]; if (q) { cache[e.sym] = { ...q, ts: now }; changed = true; } }
      } catch { /* keep last good */ }
    })());
  }
  await Promise.all(jobs);
  if (changed) { // write (and prune week-old entries) only when something updated — not every tick
    for (const [k, v] of Object.entries(cache)) if (now - (v.ts || 0) > 604800000) delete cache[k];
    atomicWrite(file, JSON.stringify(cache));
  }
  const out = {};
  for (const e of entities) out[e.sym] = cache[e.sym] || null;
  return out;
}

// ---- Polymarket (Gamma API, keyless): live markets for the top keywords, by volume, cached 5 min ----
async function fetchPolyMarkets(keywords, cfg) {
  if (!cfg.polymarket || cfg.maxPolyMarkets === 0 || !keywords.length) return [];
  const file = path.join(os.homedir(), '.claude', 'tickerline-poly.json');
  const terms = keywords.slice(0, POLY_SEARCH_TERMS);
  const key = [...terms].sort().join('|'); // set-keyed: reordered recency must not bust the cache
  let cache = {};
  try { cache = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
  // Keyed per term-set: concurrent sessions must not evict each other (fetch storm).
  const entries = cache.entries || {};
  const hit = entries[key];
  if (hit && Date.now() - (hit.ts || 0) < POLY_TTL_MS && Array.isArray(hit.markets)) return hit.markets;

  const seen = new Set(), markets = [];
  await Promise.all(terms.map(async term => {
    const url = `https://${POLY_HOST}/public-search?q=${encodeURIComponent(term)}&limit_per_type=6`;
    try {
      if (new URL(url).hostname !== POLY_HOST) return;
      const events = JSON.parse(await httpGet(url, FETCH_TIMEOUT_MS)).events || [];
      for (const e of events) {
        const slug = String(e.slug || '');
        if (e.closed || e.active === false || !/^[A-Za-z0-9_-]+$/.test(slug) || seen.has(slug)) continue; // live, sane slug
        seen.add(slug);
        const mk = (e.markets || [])[0];
        let op = mk && mk.outcomePrices;
        if (typeof op === 'string') { try { op = JSON.parse(op); } catch { op = null; } }
        let prob = op && op[0] != null ? Math.round(parseFloat(op[0]) * 100) : null;
        if (!Number.isFinite(prob)) prob = null;
        const vol = +(e.volume || (mk && mk.volumeNum) || 0);
        // strip control chars — external titles must not inject escapes or split the row
        if (e.title) markets.push({ q: String(e.title).replace(/[\u0000-\u001f\u007f]/g, " "), prob, vol: Number.isFinite(vol) ? vol : 0, slug });
      }
    } catch { /* ignore this term */ }
  }));
  markets.sort((a, b) => b.vol - a.vol);
  const top = markets.slice(0, cfg.maxPolyMarkets);
  const now = Date.now();
  entries[key] = { ts: now, markets: top };
  for (const [k, v] of Object.entries(entries)) if (now - (v.ts || 0) > 3600000) delete entries[k];
  atomicWrite(file, JSON.stringify({ entries, markets: top })); // top-level markets = latest, read by /tlm + `markets`
  return top;
}

// ---- git without a shell (pure fs) ----
function detectGitBranch(startDir) {
  let dir = startDir && fs.existsSync(startDir) ? startDir : process.cwd();
  let gitPath = null;
  for (let i = 0; i < 64; i++) {
    const cand = path.join(dir, '.git');
    if (fs.existsSync(cand)) { gitPath = cand; break; }
    const parent = path.dirname(dir); if (parent === dir) break; dir = parent;
  }
  if (!gitPath) return '';
  let gitDir = gitPath;
  if (fs.statSync(gitPath).isFile()) { // worktree: ".git" is a file "gitdir: <path>"
    const m = fs.readFileSync(gitPath, 'utf8').trim().match(/^gitdir:\s*(.+)$/);
    if (!m) return ''; gitDir = path.resolve(dir, m[1]);
  }
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
  const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/);
  if (ref) return ref[1];
  if (/^[0-9a-f]{40}$/i.test(head)) return head.slice(0, 7);
  return '';
}

// ---- transcript -> candidate entity names ----
function tailTranscript(transcriptPath, maxBytes) {
  let fd;
  try {
    fd = fs.openSync(transcriptPath, 'r');
    const { size } = fs.fstatSync(fd);
    const start = Math.max(0, size - maxBytes);
    const buf = Buffer.alloc(size - start);
    fs.readSync(fd, buf, 0, buf.length, start);
    return buf.toString('utf8');
  } catch { return ''; } finally { if (fd !== undefined) { try { fs.closeSync(fd); } catch {} } }
}

// Last N user+assistant messages, ORIGINAL case (capitalization signals entity names).
function extractRecentText(chunk, maxMessages) {
  const msgs = [];
  for (const line of chunk.split('\n')) {
    const t = line.trim(); if (!t) continue;
    let obj; try { obj = JSON.parse(t); } catch { continue; }
    if (obj.type !== 'user' && obj.type !== 'assistant') continue;
    const content = obj.message?.content;
    let text = '';
    if (typeof content === 'string') text = content;
    else if (Array.isArray(content)) for (const b of content) if (b && b.type === 'text' && typeof b.text === 'string') text += ' ' + b.text;
    if (text.trim()) msgs.push(text);
  }
  return msgs.slice(-maxMessages).join(' ');
}

// Capitalized words/phrases + $CASHTAGS = candidates to resolve. Most-recent first, capped.
function extractCandidates(text) {
  const pos = {};
  // The window is its own dictionary: a single word that ALSO appears all-lowercase here
  // ("Push"/"push", "LIVE"/"live") is an ordinary word, not an entity. NVDA/Nvidia never do.
  const lowerWords = new Set(text.match(/\b[a-z][a-z0-9]*\b/g) || []);
  for (const m of text.matchAll(/\b[A-Z][A-Za-z0-9]+(?:[ ][A-Z][A-Za-z0-9]+){0,3}\b/g)) {
    const p = m[0];
    if (p.length < 3 || p.toLowerCase().split(' ').every(w => STOP.has(w))) continue;
    if (!p.includes(' ')) {
      if (lowerWords.has(p.toLowerCase())) continue;
      // Title-case at a sentence start is grammar, not an entity ("Guided") — need one
      // mid-sentence occurrence. ALL-CAPS and phrases are exempt.
      if (p !== p.toUpperCase() && !(p in pos)) {
        let j = m.index - 1;
        while (j >= 0 && /[ \t"'()\[\]*_`#>-]/.test(text[j])) j--;
        if (j < 0 || /[.!?:;\n]/.test(text[j])) continue;
      }
    }
    pos[p] = m.index;
  }
  for (const m of text.matchAll(/[$]([A-Za-z][A-Za-z0-9]{1,5})\b/g)) pos['$' + m[1].toUpperCase()] = m.index;
  return Object.entries(pos).sort((a, b) => b[1] - a[1]).map(e => e[0]).slice(0, 24);
}

function detectCandidates(data) {
  if (!data.transcript_path || !String(data.transcript_path).endsWith('.jsonl')) return []; // the one stdin field we open — transcripts only
  const chunk = tailTranscript(data.transcript_path, TRANSCRIPT_TAIL_BYTES);
  if (!chunk) return [];
  const recent = extractRecentText(chunk, RECENT_MESSAGES);
  return recent ? extractCandidates(recent) : [];
}

// ---- live resolution: name -> ticker via Yahoo search (+ CoinGecko id for crypto) ----
async function cgId(sym) {
  try {
    const url = `https://${CG_HOST}/api/v3/search?query=${encodeURIComponent(sym)}`;
    if (new URL(url).hostname !== CG_HOST) return null;
    const coins = JSON.parse(await httpGet(url, FETCH_TIMEOUT_MS)).coins || [];
    const hit = coins.find(c => (c.symbol || '').toUpperCase() === sym.toUpperCase());
    return hit && hit.id ? hit.id : null;
  } catch { return null; }
}

const TYPE_PREF = { EQUITY: 0, ETF: 1, CRYPTOCURRENCY: 2 }; // no INDEX: ^-syms never pass US_SYM_RE
async function resolveOne(cand) {
  const isCash = cand[0] === '$';
  const q = isCash ? cand.slice(1) : cand, qU = q.toUpperCase(), qWords = q.toLowerCase().split(' ');
  const url = `https://${ALLOWED_HOST}/v1/finance/search?q=${encodeURIComponent(q)}&quotesCount=6&newsCount=0`;
  if (new URL(url).hostname !== ALLOWED_HOST) return { miss: true };
  const quotes = JSON.parse(await httpGet(url, FETCH_TIMEOUT_MS)).quotes || [];
  let best = null;
  for (const it of quotes) {
    const type = it.quoteType;
    const ysym = String(it.symbol || '');
    if (!(type in TYPE_PREF) || !US_SYM_RE.test(ysym) || it.exchange === 'PNK') continue; // no pink sheets
    const name = (it.shortname || it.longname || '').toLowerCase();
    if (type === 'CRYPTOCURRENCY' && /tokeniz/.test(name)) continue; // skip tokenized-stock derivatives
    const base = ysym.toUpperCase().split('-')[0];
    const nameWords = name.split(/[^a-z0-9]+/);
    // single words must be the name's FIRST word ("Nvidia" yes; "IPO" ≠ Renaissance IPO ETF)
    const nameMatch = qWords.every(w => nameWords.includes(w)) && (q.includes(' ') || nameWords[0] === qWords[0]);
    const symMatch = !q.includes(' ') && base === qU;
    if (isCash ? !symMatch : !(nameMatch || symMatch)) continue;
    // n = matched as a real NAME (or cashtag): trustworthy as a Polymarket search keyword
    if (!best || TYPE_PREF[type] < TYPE_PREF[best.type]) best = { type, ysym, base, n: (nameMatch || isCash) ? 1 : 0 };
    if (TYPE_PREF[type] === 0) break; // equity is the top preference
  }
  if (!best) return { miss: true };
  if (best.type === 'CRYPTOCURRENCY') {
    const cg = await cgId(best.base);
    return cg ? { sym: best.base, cg, n: best.n } : { sym: best.base, ysym: best.ysym, n: best.n };
  }
  return { sym: best.base, ysym: best.ysym, n: best.n };
}

// Resolve candidates, cached a day — steady state does zero network, ≤8 new lookups/refresh.
async function resolveEntities(candidates) {
  const file = path.join(os.homedir(), '.claude', 'tickerline-resolve.json');
  let cache = {};
  try { cache = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
  const now = Date.now();
  const toResolve = candidates.filter(c => !cache[c] || now - (cache[c].ts || 0) >= RESOLVE_TTL_MS).slice(0, 8);
  if (toResolve.length) {
    await Promise.all(toResolve.map(async c => {
      try { cache[c] = { ...(await resolveOne(c)), ts: now }; } catch { cache[c] = { miss: true, ts: now }; }
    }));
    // prune so weeks of conversations can't grow this file unboundedly (misses age out at their TTL)
    for (const [k, v] of Object.entries(cache)) if (now - (v.ts || 0) > (v.miss ? RESOLVE_TTL_MS : 604800000)) delete cache[k];
    atomicWrite(file, JSON.stringify(cache));
  }
  const entities = [], seen = new Set();
  for (const c of candidates) {
    const r = cache[c];
    if (!r || r.miss || !r.sym || seen.has(r.sym)) continue;
    seen.add(r.sym);
    entities.push({ query: (c[0] === '$' ? c.slice(1) : c), sym: r.sym, cg: r.cg || null, ysym: r.ysym || null, n: r.n || 0 });
  }
  return entities; // most-recent first
}

// ---- rendering ----
function fmtPrice(p) {
  if (p >= 1000) return p.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  if (p >= 1) return p.toFixed(2);
  if (p >= 0.01) return p.toFixed(4);
  return p.toPrecision(4);
}

function renderQuote(ticker, q) {
  if (!q || q.price == null) return `${DIM}${ticker} --${RESET}`;
  const dir = q.changePct == null ? 0 : q.changePct >= 0 ? 1 : -1;
  const arrow = dir === 0 ? '' : dir > 0 ? G.up : G.down;
  const pct = q.changePct == null ? '' : ` ${arrow}${Math.abs(q.changePct).toFixed(2)}%`;
  const color = dir === 0 ? '' : dir > 0 ? GREEN : RED; // unknown change: default fg, not a fake gain
  // Market closed: keep the color, flag with a dim dot (whole-DIM quotes vanish on dark themes).
  return `${color}${ticker} ${fmtPrice(q.price)}${pct}${RESET}${q.stale ? DIM + G.dot + RESET : ''}`;
}

function renderPoly(m, i) {
  // Numbered so /tlm <n> works off the band; full question, default fg.
  const prob = m.prob == null ? '' : ` ${(m.prob >= 60 ? GREEN : m.prob <= 25 ? RED : YELLOW)}${m.prob}%${RESET}`;
  const body = `${CYAN}${i + 1}.${RESET} ${m.q}${prob}`;
  return m.slug ? `\x1b]8;;https://polymarket.com/event/${m.slug}\x07${body}\x1b]8;;\x07` : body; // OSC 8: Cmd+click opens it
}

function renderContext(data) {
  let pct = Math.floor(data.context_window?.used_percentage || 0);
  pct = Math.max(0, Math.min(100, pct));
  const barColor = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN;
  const filled = Math.max(0, Math.min(10, Math.floor(pct / 10)));
  return `${barColor}${G.barFull.repeat(filled) + G.barEmpty.repeat(10 - filled)}${RESET} ${pct}%`;
}

function renderGit(data) {
  let branch = ''; // a mid-rebase .git can vanish between exists/read — never blank the whole bar for it
  try { branch = detectGitBranch(data.workspace?.current_dir); } catch {}
  return branch ? `${CYAN}${G.git}${branch}${RESET}` : '';
}
function renderCost(data) { return `${YELLOW}$${(data.cost?.total_cost_usd || 0).toFixed(2)}${RESET}`; }

// ---- width budgeting (ANSI + OSC-8 aware, code-point stepped, CJK/emoji = 2 cols) ----
function chWidth(ch) {
  const c = ch.codePointAt(0);
  return (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3)
    || (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60)
    || (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f000 && c <= 0x1faff) || (c >= 0x20000 && c <= 0x2fffd) ? 2 : 1;
}
function visibleLen(s) {
  let n = 0;
  for (const ch of s.replace(/\x1b\]8;;[^\x07]*\x07/g, '').replace(/\x1b\[[0-9;]*m/g, '')) n += chWidth(ch);
  return n;
}

// window at visible col start carrying color + OSC-8 link; code-point stepped (no split surrogates).
function sliceWindow(s, start, width) {
  let vis = 0, i = 0, color = '', link = '';
  const esc = () => {
    if (s[i] !== '\x1b') return null;
    let m = s.slice(i).match(/^\x1b\[[0-9;]*m/); if (m) { color = m[0] === RESET ? '' : m[0]; return m[0]; }
    m = s.slice(i).match(/^\x1b\]8;;[^\x07]*\x07/); if (m) { link = /^\x1b\]8;;\x07$/.test(m[0]) ? '' : m[0]; return m[0]; }
    return null;
  };
  while (i < s.length && vis < start) { const e = esc(); if (e) { i += e.length; continue; } const ch = String.fromCodePoint(s.codePointAt(i)); vis += chWidth(ch); i += ch.length; }
  let out = link + color, taken = 0;
  while (i < s.length && taken < width) {
    const e = esc(); if (e) { out += e; i += e.length; continue; }
    const ch = String.fromCodePoint(s.codePointAt(i)), w = chWidth(ch);
    if (taken + w > width) break;
    out += ch; taken += w; i += ch.length;
  }
  return out + (link ? '\x1b]8;;\x07' : '') + RESET;
}

// Static if it fits, else rolls left at wall-clock speed.
function marquee(band, width, speed) {
  if (visibleLen(band) <= width) return band;
  const GAP = `   ${DIM}${G.sep}${RESET}   `;
  const loop = band + GAP + band;
  const period = visibleLen(band) + visibleLen(GAP);
  const offset = Math.floor(Date.now() * speed / 1000) % period;
  return sliceWindow(loop, offset, width);
}

// Row 1 = side info (context/git/cost). Below it: the ticker band, then the Polymarket band.
function assemble(cfg, quotes, detected, poly, data) {
  const sep = `  ${DIM}${G.sep}${RESET}  `;
  const cols = parseInt(process.env.COLUMNS, 10) || 0;
  const width = cols && cols >= 20 ? cols : 160; // no COLUMNS: assume a wide terminal, keep the marquee alive

  const rows = [];
  const seg1 = [];
  for (const mod of cfg.modules) {
    if (mod === 'context') seg1.push(renderContext(data));
    else if (mod === 'git') { const g = renderGit(data); if (g) seg1.push(g); }
    else if (mod === 'cost') seg1.push(renderCost(data));
  }
  // /tlm (a slash command --init installs) — only advertise it if it's actually there
  if (cfg.hint && poly.length && fs.existsSync(path.join(os.homedir(), '.claude', 'commands', 'tlm.md'))) seg1.push(`${DIM}/tlm <n> opens a market${RESET}`);
  let row1 = seg1.filter(Boolean).join(sep);
  if (row1) { if (visibleLen(row1) > width) row1 = sliceWindow(row1, 0, width - G.ell.length) + G.ell; rows.push(row1); }

  if (detected.length) rows.push(marquee(detected.map(t => renderQuote(t, quotes[t])).join(sep), width, cfg.scrollSpeed));
  if (poly.length) rows.push(marquee(poly.map(renderPoly).join(sep), width, cfg.scrollSpeed));
  return rows.join('\n');
}

// ---- detect cache, keyed per session: concurrent windows must not evict each other's
// record (that would collapse the detect TTL to zero and re-scan 1MB every tick) ----
function detectCachePath() { return path.join(os.homedir(), '.claude', 'tickerline-detect.json'); }
function readDetectRecord(sessionId) {
  try { return JSON.parse(fs.readFileSync(detectCachePath(), 'utf8')).sessions?.[sessionId] || null; } catch { return null; }
}
function writeDetectRecord(sessionId, rec) {
  let sessions = {};
  try { sessions = JSON.parse(fs.readFileSync(detectCachePath(), 'utf8')).sessions || {}; } catch {}
  sessions[sessionId] = rec;
  const now = Date.now();
  for (const [k, v] of Object.entries(sessions)) if (now - (v.ts || 0) > 3600000) delete sessions[k]; // drop dead sessions
  atomicWrite(detectCachePath(), JSON.stringify({ sessions }));
}

async function main() {
  const cfg = loadConfig();
  G = glyphs(cfg.ascii);
  let data = {};
  try { data = JSON.parse(readStdin()); } catch {}

  // Candidate extraction is cached DETECT_TTL_MS so the 1s refresh rarely re-scans.
  const sid = data.session_id || 'default';
  const rec = readDetectRecord(sid);
  const now = Date.now();
  let candidates;
  if (rec && rec.candidates && now - (rec.ts || 0) < DETECT_TTL_MS) candidates = rec.candidates;
  else { try { candidates = detectCandidates(data); } catch { candidates = []; } writeDetectRecord(sid, { ts: now, candidates }); }

  let entities = [];
  if (cfg.contextAware && candidates.length) { try { entities = await resolveEntities(candidates); } catch {} }
  entities = entities.slice(0, cfg.maxTopicTickers);
  const detected = entities.map(e => e.sym);

  let quotes = {}, poly = [];
  await Promise.all([
    (async () => { if (entities.length) { try { quotes = await fetchQuotes(entities, cfg.ttl * 1000); } catch {} } })(),
    // name-matched entities search Polymarket best — bare symbol collisions fill leftovers
    (async () => { try { poly = await fetchPolyMarkets([...entities.filter(e => e.n), ...entities.filter(e => !e.n)].map(e => e.query.toLowerCase()), cfg); } catch {} })(),
  ]);

  if (process.env.TICKERLINE_DEBUG) process.stderr.write('[tickerline] ' + detected.join(' ') + '\n');
  let line = '';
  try { line = assemble(cfg, quotes, detected, poly, data); } catch { line = ''; }
  if (cfg._configError) line = `${RED}${G.warn} tickerline: bad config${RESET}  ${line}`;
  console.log(line);
}

// Interactive setup (`node tickerline.js --init`). Writes ONLY ~/.claude/tickerline.json
// and (on consent) copies the repo's tlm.md to ~/.claude/commands/ — never settings.json.
function runInit() {
  const readline = require('readline');
  const rl = readline.createInterface({ input: process.stdin });
  const queue = []; let waiting = null, closed = false;
  rl.on('line', l => { if (waiting) { const w = waiting; waiting = null; w(l); } else queue.push(l); });
  rl.on('close', () => { closed = true; if (waiting) { const w = waiting; waiting = null; w(null); } });
  const nextLine = () => new Promise(res => { if (queue.length) res(queue.shift()); else if (closed) res(null); else waiting = res; });
  const ask = async (q, def) => { process.stdout.write(`${q}${def ? ` [${def}]` : ''}: `); const l = await nextLine(); return ((l == null ? '' : l).trim()) || def || ''; };

  (async () => {
    process.stdout.write('\ntickerline setup — writes ~/.claude/tickerline.json\n\n');
    const known = new Set(['context', 'git', 'cost']);
    const modules = (await ask('Top-line modules (context,git,cost)', 'context,git')).split(',').map(s => s.trim().toLowerCase()).filter(m => known.has(m));
    const speedRaw = await ask('Scroll speed (columns/sec)', '2');
    const scrollSpeed = Number.isFinite(+speedRaw) && +speedRaw > 0 ? +speedRaw : DEFAULTS.scrollSpeed;
    const ascii = /^y/i.test(await ask('ASCII-only mode? (y/N)', 'N'));
    const wantTlm = /^y/i.test(await ask('Install the /tlm command (opens markets in-chat)? (Y/n)', 'Y'));
    rl.close();

    const file = path.join(os.homedir(), '.claude', 'tickerline.json');
    let prev = {}; // re-running setup must not silently drop fields the user set by hand
    try { prev = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
    const cfg = { ...prev, modules, ascii, scrollSpeed };
    try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + '\n'); process.stdout.write(`\nWrote ${file}\n`); }
    catch (e) { process.stdout.write(`\nCould not write ${file}: ${e.message}\n`); }
    if (wantTlm) {
      const src = path.join(__dirname, 'tlm.md');
      const cmdFile = path.join(os.homedir(), '.claude', 'commands', 'tlm.md');
      try { fs.mkdirSync(path.dirname(cmdFile), { recursive: true }); fs.copyFileSync(src, cmdFile); process.stdout.write(`Wrote ${cmdFile} — /tlm now opens markets.\n`); }
      catch { process.stdout.write(`Couldn't copy ${src} — put the repo's tlm.md at ${cmdFile} yourself.\n`); }
    }
    const scriptPath = path.resolve(process.argv[1] || 'tickerline.js');
    const snippet = { statusLine: { type: 'command', command: `node "${scriptPath}"`, refreshInterval: 1 } };
    process.stdout.write('\nIf not wired up yet, add to ~/.claude/settings.json:\n' + JSON.stringify(snippet, null, 2) + '\n');
    process.stdout.write(`\n/tlm opens markets in Claude Code; outside it: node "${scriptPath}" markets\n`);
    process.exit(0);
  })();
}

// `node tickerline.js markets` — static, clickable list of the last cached markets.
function runMarkets() {
  const cyan = s => `${CYAN}${s}${RESET}`, dim = s => `${DIM}${s}${RESET}`;
  const fmtVol = v => v >= 1e6 ? '$' + (v / 1e6).toFixed(1) + 'M' : v >= 1e3 ? '$' + (v / 1e3).toFixed(0) + 'K' : '$' + Math.round(v);
  let markets = [];
  try { markets = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'tickerline-poly.json'), 'utf8')).markets || []; } catch {}
  if (!markets.length) {
    process.stdout.write('\nNo context markets cached yet — talk about something in Claude Code first.\n\n');
    process.exit(0);
  }
  process.stdout.write('\nContext prediction markets (from your last status-line refresh):\n\n');
  markets.forEach((m, i) => {
    const url = `https://polymarket.com/event/${m.slug}`;
    const prob = m.prob == null ? '' : `  ${m.prob}%`;
    process.stdout.write(`  ${String(i + 1).padStart(2)}. ${m.q}${cyan(prob)}   ${dim(fmtVol(m.vol || 0))}\n      \x1b]8;;${url}\x07${url}\x1b]8;;\x07\n\n`);
  });
  process.stdout.write(dim('Cmd/Ctrl+click a link, or copy it. /tlm opens these in Claude Code.\n\n'));
  process.exit(0);
}

const arg = process.argv[2];
if (arg === '--init' || arg === 'init' || arg === 'setup') runInit();
else if (arg === 'markets' || arg === 'poly' || arg === 'm') runMarkets();
else main().catch(() => process.exit(0)); // never blank the whole bar on an unhandled error

Reviews

no reviews yet