{"slug": "show-hn-cc-context-telemetry-context-and-rate-limit-in-claude-code-s-bar", "title": "Show HN: cc-context-telemetry - context and rate-limit % in Claude Code's bar", "summary": "Developer alagiz released cc-context-telemetry, an open-source tool that displays Claude Code's context usage percentage, Pro/Max rate-limit status with reset countdowns, and the active model directly in the terminal statusline bar. The tool wraps any existing statusline command and requires no Node.js runtime for native installations, addressing a gap where Claude Code's own hooks and plugins cannot surface these metrics.", "body_md": "Show Claude Code's context % and Pro/Max rate-limit usage (with how long until each limit resets) and the current model, right next to your existing statusline bar.\n\n```\nbefore:   ~/code/myapp  main\n\nafter:    ctx 48% | 5h 14% ~3h20m | 7d 50% ~4d | opus-4.8   ~/code/myapp  main\n```\n\nThe trailing part is whatever your own statusline already prints - cct wraps ANY\nstatusline command, not a specific one (a busier bar, like the adtention plugin's\n`main $9.07 21:04`\n\n, gets the same segment prepended).\n\nInstall:\n\n```\nnpm i -g cc-context-telemetry\n```\n\n(No Node or npm on the machine, e.g. a native Claude Code install? Skip the npm step: `git clone`\n\nthe repo and use the absolute path to its `bin/cct-statusline`\n\nas the `command`\n\nbelow. The statusline path is pure POSIX shell, so it needs neither Node nor npm.)\n\nSet it as your `statusLine`\n\nin `~/.claude/settings.json`\n\n:\n\n```\n{\n  \"statusLine\": { \"type\": \"command\", \"command\": \"cc-context-telemetry-statusline\" }\n}\n```\n\nThat is it - your statusline is now `ctx % | 5h % | 7d % | model`\n\nwith reset countdowns. (Settings\nchanges take effect in a new session. Prefer a stable absolute path? Point `command`\n\nat an\ninstalled or checked-out `bin/cct-statusline`\n\ninstead of the global shim.)\n\n**Already run a statusline you want to keep?** Add `CCT_WRAP`\n\n, set to your existing\nstatusline command; the segment then prepends to it on the same line:\n\n```\n{\n  \"statusLine\": {\n    \"type\": \"command\",\n    \"command\": \"cc-context-telemetry-statusline\",\n    \"env\": { \"CCT_WRAP\": \"<your existing statusline command>\" }\n  }\n}\n```\n\n(If your Claude Code version does not accept an `env`\n\nblock, export `CCT_WRAP`\n\nin your\nshell profile instead.)\n\nEither direction chains, because cct `exec`\n\ns through: cct can wrap your bar (above), or if\nanother statusline tool already owns your `statusLine`\n\n, point THAT tool at cct instead of\nthe reverse - put cct's command in the host's own wrap channel so it keeps managing the\nstatusLine. See [ examples/host-owned-statusline.md](/alagiz/cc-context-telemetry/blob/main/examples/host-owned-statusline.md).\n\n`CCT_SEGMENTS`\n\npicks which segments show and in what order - a comma (or space) separated\nlist. Default: `ctx,5h,7d,model`\n\n. Order is the display order; presence toggles a segment on\nor off; unknown tokens are ignored; unset or empty uses the default. Tokens:\n\n`ctx`\n\n- context window used %`5h`\n\n/`7d`\n\n- Pro/Max rate-limit usage, each with a`~time-left`\n\nreset countdown`model`\n\n- the current model as a short token (e.g.`opus-4.8[1m]`\n\n,`fable-5`\n\n)\n\n```\nCCT_SEGMENTS=\"ctx,5h,7d\"          # drop the model\nCCT_SEGMENTS=\"model,ctx,5h,7d\"    # model first\nCCT_SEGMENTS=\"5h,7d\"              # just the rate limits\n```\n\nSet it in your `statusLine`\n\nenv block in `~/.claude/settings.json`\n\n, next to `command`\n\n(and\n`CCT_WRAP`\n\nif you wrap an existing bar):\n\n```\n{\n  \"statusLine\": {\n    \"type\": \"command\",\n    \"command\": \"cc-context-telemetry-statusline\",\n    \"env\": { \"CCT_SEGMENTS\": \"ctx,5h,7d\" }\n  }\n}\n```\n\n(Or export `CCT_SEGMENTS`\n\nin your shell profile instead.)\n\n**Both set an environment variable Claude Code reads at launch, so the change takes effect\nonly in a NEW session** - existing sessions keep their current bar until you start a fresh\none. If another tool owns your `statusLine`\n\n(a host-owned setup, e.g. a plugin that runs cct\nthrough its own wrap channel), setting the variable in that tool's per-render command takes\neffect on the next render with no restart; see\n[ examples/host-owned-statusline.md](/alagiz/cc-context-telemetry/blob/main/examples/host-owned-statusline.md).\n\nRemove the `statusLine`\n\nblock from `~/.claude/settings.json`\n\n(or set `command`\n\nback to your\noriginal statusline command, i.e. your `CCT_WRAP`\n\nvalue), then `npm uninstall -g cc-context-telemetry`\n\n(or delete your clone). Optionally clear the telemetry files with\n`rm -rf ~/.claude/cc-context-telemetry`\n\n.\n\nClaude Code hooks and plugins cannot see context fullness or rate limits. The ONLY place\nClaude Code exposes the authoritative `context_window`\n\n(used percentage, window size) and\nPro/Max `rate_limits`\n\nis the `statusLine`\n\ncommand. This tiny, zero-dependency library\nbridges that gap: a statusLine wrapper that prepends a compact segment - context %, the\n5h/7d rate limits each with a `~time-left`\n\nreset countdown, and the current model (default\n`ctx,5h,7d,model`\n\n, all configurable) - to your own bar, and also writes that telemetry to a\nper-session file your hooks can read.\n\n`ctx`\n\nis per session. The 5h/7d rate limits are account-wide but Claude Code only refreshes\nthem on an API call, so any single session's view is often stale. The bar therefore shows,\nper window, the reading from whichever of your open sessions ON THIS MACHINE refreshed most\nrecently (\"latest API call wins\": each session tracks when its rate_limits last changed, and\nthe newest change is picked). A reading whose reset boundary already elapsed is treated as\nstale and never picked; if no session has a current reading for a window, the bar falls back\nto this session's last-known usage with no countdown. So your open sessions agree instead of\nshowing different stale numbers. It does not converge across separate machines.\n\nOne job, three pieces:\n\n`bin/cct-statusline`\n\n- the POSIX shell entry Claude Code calls as its statusLine, every render. It is**pure shell, with NO Node on the per-render path**: it reads the payload once, extracts the session id, and atomically writes the RAW payload to`telemetry-raw-<session>.json`\n\n. Then it prints the`ctx % | 5h % | 7d % | model`\n\nsegment (each rate-limit field carrying a`~time-left`\n\ncountdown to its reset) and, if`CCT_WRAP`\n\nis set to your original statusline command,so its bar appends right after the segment on the same line and it BECOMES the statusLine process (see \"Safe to run every render\" for why this matters); with no`exec`\n\ns that command`CCT_WRAP`\n\nthe segment is the whole bar, never blank. There is no per-render Node spawn at all (an earlier design spawned`node`\n\nevery render; across many sessions that piled up).`index.js`\n\n- the consumer.`readTelemetry(sessionId)`\n\nreads the raw payload file, parses it ON DEMAND, and returns the latest reading with a freshness check. So all JSON parsing happens here, only when a hook actually asks - never on the hot path. It also prunes stale per-session raw files (off the hot path, throttled).`bin/telemetry.js`\n\n- an on-demand CLI reader over`readTelemetry`\n\n, for the manual live check. It is NOT in the per-render path; it runs only when you invoke it.\n\nZero third-party dependencies, and cross-platform (CI-tested on Linux, macOS, and Windows): the pure-POSIX-shell statusline path runs anywhere Claude Code does. On Windows, Claude Code runs statusLine commands through Git Bash, which ships the shell and tools it needs. Node\n\n= 18 is required only for the hooks API (\n\n`readTelemetry`\n\n) and the`bin/telemetry.js`\n\nreader. Never throws, never calls`claude`\n\n.\n\nIn any hook (PreToolUse, PreCompact, etc.), read the latest reading:\n\n``` js\nconst { readTelemetry } = require('cc-context-telemetry');\nconst d = require('fs').readFileSync(0, 'utf8'); // hook stdin\nconst sessionId = JSON.parse(d).session_id;\nconst t = readTelemetry(sessionId);\nif (t && t.fresh && t.contextPct >= 85) { /* near the wall: act */ }\n```\n\n`readTelemetry(sessionId, opts)`\n\nreturns `null`\n\nwhen absent, otherwise:\n\n```\n{\n  sessionId,        // string\n  contextPct,       // number or null (context window used %)\n  usedPercentage,   // number or null (alias of contextPct)\n  windowSize,       // number or null (context_window_size)\n  fiveHourPct,      // number, OMITTED when Claude Code did not report it\n  sevenDayPct,      // number, OMITTED when Claude Code did not report it\n  fiveHourResetsAt, // number (Unix epoch seconds), OMITTED when absent\n  sevenDayResetsAt, // number (Unix epoch seconds), OMITTED when absent\n  model,            // string or null (model id)\n  ts,               // ISO timestamp string or null\n  source,           // \"statusline\"\n  fresh             // true when contextPct is a finite number AND ts is within the TTL\n}\n```\n\n`contextPct`\n\n/ `usedPercentage`\n\nare passed through verbatim from Claude Code's\n`context_window.used_percentage`\n\n(a percentage, 0..100 in practice). The library does\nNOT range-clamp them; it only verifies they are numbers, so treat them as advisory and\ndo not assume a strict `0..100`\n\nbound. `fresh`\n\nalready rejects non-finite values.\n\n`fiveHourResetsAt`\n\n/ `sevenDayResetsAt`\n\nare the Unix epoch (seconds) when each rate-limit\nwindow next resets. They are omitted (like the percentages) when the payload has no\n`rate_limits`\n\nor no `resets_at`\n\n, and also when the value is not a plausible epoch - the\nreader rejects non-epoch or absurd values (mirroring the statusline renderer) so a corrupt\nfile cannot hand you a bogus reset time. Compute time-left yourself, e.g.\n`fiveHourResetsAt - Date.now() / 1000`\n\n. A reset time can still be in the PAST when the\nreading is stale (Claude Code refreshes `rate_limits`\n\nonly on an API call), so gate on\n`fresh`\n\nand treat an already-passed reset as stale, not as \"resetting now\".\n\n`fresh`\n\nrejects both stale and far-future timestamps (it checks the absolute age\nagainst the TTL). When `fresh`\n\nis false, treat it as \"no signal\" and stay\nobserve-only. `opts.ttlSec`\n\n(or env `CCT_TTL_SEC`\n\n) overrides the default 120s TTL.\nAn override is honored only when it is a finite number greater than 0; a blank,\nzero, negative, or non-numeric value falls back to the default.\n\nThe module also exports `DEFAULT_TTL_SEC`\n\n(the 120s default, a number),\n`parsePayload(d, sessionId)`\n\n(the raw-to-normalized field mapping),\n`rawPath(sessionId)`\n\n, `pruneRaw()`\n\n, `telemetryDir()`\n\n, `telemetryPath(sessionId)`\n\n,\n`writeTelemetry(sessionId, obj)`\n\n, and `nowIso()`\n\n. The session id is sanitized\n(everything outside `[A-Za-z0-9_-]`\n\nbecomes `_`\n\n), so a reading is always confined to\nthe telemetry directory.\n\nThe pure-shell entry writes ONE file per session: the RAW Claude Code statusline\npayload, verbatim, at `<dir>/telemetry-raw-<session>.json`\n\n. It is overwritten each\nrender (atomically, via a temp file + rename), so a single session never grows the\ndirectory. `readTelemetry(sessionId)`\n\nparses that raw file on demand. The raw payload\nlooks like (fields vary by plan/model/version - the lib reads them straight, never\nassuming a shape):\n\n```\n{\n  \"session_id\": \"abc123\",\n  \"context_window\": { \"used_percentage\": 47.2, \"context_window_size\": 200000 },\n  \"rate_limits\": {\n    \"five_hour\": { \"used_percentage\": 12, \"resets_at\": 1783359600 },\n    \"seven_day\": { \"used_percentage\": 30, \"resets_at\": 1783368000 }\n  },\n  \"model\": { \"id\": \"claude-opus-4-1\" }\n}\n```\n\n`rate_limits`\n\nis present only for Pro/Max OAuth; it is absent (so `fiveHourPct`\n\n/\n`sevenDayPct`\n\nare omitted, never reported as 0) for API-key / Bedrock / Vertex.\n`context_window.used_percentage`\n\nis `null`\n\nbefore the first API response and right\nafter a compaction (so `contextPct`\n\nis `null`\n\nand `fresh`\n\nis `false`\n\n).\n\nEach `rate_limits`\n\nwindow also carries `resets_at`\n\n(a Unix epoch, seconds). The shell\nsegment renders it as a compact `~time-left`\n\ncountdown next to the percent: at most two\nunits (`~6d23h`\n\n, `~2h54m`\n\n, `~44m`\n\n, `~<1m`\n\n). It is a pure duration (reset minus `date +%s`\n\n),\nso there is no clock or timezone handling. The countdown is omitted whenever `resets_at`\n\nis\nmissing (older Claude Code, or non-OAuth plans), implausible, or already in the past (a\nreading whose reset boundary elapsed predates the last reset, so it is treated as stale: the\ncountdown is dropped and the bar shows this session's last-known usage % without one) -\nnever fabricated. (`readTelemetry`\n\nsurfaces these reset epochs too, as\n`fiveHourResetsAt`\n\n/ `sevenDayResetsAt`\n\n; see the Consumer API section.)\n\n`telemetry-raw-<session>.json`\n\nis per session, so live sessions bound the file count,\nbut a long-lived machine sees a new session id (UUID) per `claude`\n\nrun. So\n`readTelemetry`\n\nprunes off the hot path (throttled, at most once per\n`CCT_PRUNE_EVERY_SEC`\n\n, default hourly): it deletes raw files older than\n`CCT_PRUNE_AGE_SEC`\n\n(default 1 day) and then caps the total at `CCT_PRUNE_MAX_FILES`\n\n(default 200, deleting the oldest beyond the cap). It only ever touches this lib's own\n`telemetry-raw-*.json`\n\nfiles. Pruning is best-effort and never throws.\n\n`CCT_WRAP`\n\n- your original statusline command, as a SINGLE foreground command (a program plus args, e.g.`'/path/to/your-statusline' status`\n\n). The entry`exec`\n\ns it with the same stdin so it becomes the statusLine process and forwards its output. It must NOT be a pipeline (`a | b`\n\n) or backgrounded (trailing`&`\n\n): a pipeline mis-routes stdin, and`&`\n\nleaves a process running past the render (exactly as it would running directly). If you need a pipeline, put it in a small script and point`CCT_WRAP`\n\nat that script. When`CCT_WRAP`\n\nis unset, the entry prints its own minimal bar (`ctx 47% | 5h 12% ~3h | 7d 30% ~5d | opus-4.8`\n\n).`CCT_SEGMENTS`\n\n- which bar segments to show and in what order (default`ctx,5h,7d,model`\n\n); see Customizing the bar.`CCT_DIR`\n\n- telemetry directory (default`~/.claude/cc-context-telemetry/`\n\n). The shell entry mirrors this default; set it on the statusLine env AND for your hooks so both sides agree.`CCT_TTL_SEC`\n\n- freshness window for`readTelemetry`\n\nin seconds (default 120).`CCT_PRUNE_AGE_SEC`\n\n- delete raw files older than this on the reader side (default 86400, 1 day).`CCT_PRUNE_MAX_FILES`\n\n- hard cap on raw files; the oldest beyond it are deleted (default 200).`CCT_PRUNE_EVERY_SEC`\n\n- throttle: prune at most once per this interval across reader invocations (default 3600, hourly).\n\nThe raw payload file (`telemetry-raw-<session>.json`\n\n) IS the verbatim statusline\npayload, so it doubles as the debug dump: inspect it directly to see the real field\npaths Claude Code sent. It may contain real session content and is overwritten each\nrender; the reader auto-prunes it (see Raw-file hygiene).\n\nClaude Code runs your statusLine on every render in every session and kills that process\neach time to keep it bounded. cct stays safe by `exec`\n\n-ing your `CCT_WRAP`\n\ncommand: the\nentry process BECOMES your statusline, so what Claude Code spawns and kills IS your\nstatusline, torn down each render exactly as if you ran it directly - no extra process, no\npile-up. There is also NO Node on the per-render path (only shell; all JSON parsing happens\nlater, on demand, in `index.js`\n\n), so nothing can outlive a render and orphan. An earlier\ndesign that spawned `node`\n\nevery render orphaned under Claude Code's render-kills and piled\nup; the current exec-through wrapper does not. `test/loadrepro`\n\nproves it.\n\nFull rationale (exec vs spawn, the orphan incident, the stdin/EOF contract, and the\nforking-statusline corollary): [docs/process-safety.md](/alagiz/cc-context-telemetry/blob/main/docs/process-safety.md).\n\nWire it (Quick start), open any session, and send ONE message - the statusline renders every turn, so you do not need to fill the context. (Settings changes only take effect in a FRESH session, so start a new one after saving.) Then print the parsed reading:\n\n```\nnode examples/print-telemetry.js <session-id>\n```\n\nCheck for sane values (a numeric or null `contextPct`\n\n, a `windowSize`\n\n, a `model`\n\n). Your\n`<session-id>`\n\nis the `session_id`\n\nfield in any hook payload, and the `<session>`\n\nin the\nraw file written this session under `~/.claude/cc-context-telemetry/`\n\n. If values are null\nor missing, open that raw file - it IS the verbatim statusline payload - to see the real\nfield paths Claude Code sent.\n\nSee the [ examples/](/alagiz/cc-context-telemetry/blob/main/examples) directory for complete, runnable files: a\ncopy-pasteable PreToolUse warning hook (\n\n`pretooluse-warn.js`\n\n) and a telemetry\nprinter for the live check (`print-telemetry.js`\n\n). The examples are not published to\nnpm.MIT", "url": "https://wpnews.pro/news/show-hn-cc-context-telemetry-context-and-rate-limit-in-claude-code-s-bar", "canonical_source": "https://github.com/alagiz/cc-context-telemetry", "published_at": "2026-07-17 07:58:24+00:00", "updated_at": "2026-07-17 08:21:25.300838+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "large-language-models"], "entities": ["Claude Code", "cc-context-telemetry", "alagiz", "npm"], "alternates": {"html": "https://wpnews.pro/news/show-hn-cc-context-telemetry-context-and-rate-limit-in-claude-code-s-bar", "markdown": "https://wpnews.pro/news/show-hn-cc-context-telemetry-context-and-rate-limit-in-claude-code-s-bar.md", "text": "https://wpnews.pro/news/show-hn-cc-context-telemetry-context-and-rate-limit-in-claude-code-s-bar.txt", "jsonld": "https://wpnews.pro/news/show-hn-cc-context-telemetry-context-and-rate-limit-in-claude-code-s-bar.jsonld"}}