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.
before: ~/code/myapp main
after: ctx 48% | 5h 14% ~3h20m | 7d 50% ~4d | opus-4.8 ~/code/myapp main
The trailing part is whatever your own statusline already prints - cct wraps ANY
statusline command, not a specific one (a busier bar, like the adtention plugin's
main $9.07 21:04
, gets the same segment prepended).
Install:
npm i -g cc-context-telemetry
(No Node or npm on the machine, e.g. a native Claude Code install? Skip the npm step: git clone
the repo and use the absolute path to its bin/cct-statusline
as the command
below. The statusline path is pure POSIX shell, so it needs neither Node nor npm.)
Set it as your statusLine
in ~/.claude/settings.json
:
{
"statusLine": { "type": "command", "command": "cc-context-telemetry-statusline" }
}
That is it - your statusline is now ctx % | 5h % | 7d % | model
with reset countdowns. (Settings
changes take effect in a new session. Prefer a stable absolute path? Point command
at an
installed or checked-out bin/cct-statusline
instead of the global shim.)
Already run a statusline you want to keep? Add CCT_WRAP
, set to your existing statusline command; the segment then prepends to it on the same line:
{
"statusLine": {
"type": "command",
"command": "cc-context-telemetry-statusline",
"env": { "CCT_WRAP": "<your existing statusline command>" }
}
}
(If your Claude Code version does not accept an env
block, export CCT_WRAP
in your shell profile instead.)
Either direction chains, because cct exec
s through: cct can wrap your bar (above), or if
another statusline tool already owns your statusLine
, point THAT tool at cct instead of the reverse - put cct's command in the host's own wrap channel so it keeps managing the statusLine. See examples/host-owned-statusline.md.
CCT_SEGMENTS
picks which segments show and in what order - a comma (or space) separated
list. Default: ctx,5h,7d,model
. Order is the display order; presence toggles a segment on or off; unknown tokens are ignored; unset or empty uses the default. Tokens:
ctx
- context window used %
5h
/7d
- Pro/Max rate-limit usage, each with a
~time-left
reset countdownmodel
- the current model as a short token (e.g.
opus-4.8[1m]
,fable-5
)
CCT_SEGMENTS="ctx,5h,7d" # drop the model
CCT_SEGMENTS="model,ctx,5h,7d" # model first
CCT_SEGMENTS="5h,7d" # just the rate limits
Set it in your statusLine
env block in ~/.claude/settings.json
, next to command
(and
CCT_WRAP
if you wrap an existing bar):
{
"statusLine": {
"type": "command",
"command": "cc-context-telemetry-statusline",
"env": { "CCT_SEGMENTS": "ctx,5h,7d" }
}
}
(Or export CCT_SEGMENTS
in your shell profile instead.)
Both set an environment variable Claude Code reads at launch, so the change takes effect
only in a NEW session - existing sessions keep their current bar until you start a fresh
one. If another tool owns your statusLine
(a host-owned setup, e.g. a plugin that runs cct through its own wrap channel), setting the variable in that tool's per-render command takes effect on the next render with no restart; see examples/host-owned-statusline.md.
Remove the statusLine
block from ~/.claude/settings.json
(or set command
back to your
original statusline command, i.e. your CCT_WRAP
value), then npm uninstall -g cc-context-telemetry
(or delete your clone). Optionally clear the telemetry files with
rm -rf ~/.claude/cc-context-telemetry
.
Claude Code hooks and plugins cannot see context fullness or rate limits. The ONLY place
Claude Code exposes the authoritative context_window
(used percentage, window size) and
Pro/Max rate_limits
is the statusLine
command. This tiny, zero-dependency library
bridges that gap: a statusLine wrapper that prepends a compact segment - context %, the
5h/7d rate limits each with a ~time-left
reset countdown, and the current model (default
ctx,5h,7d,model
, all configurable) - to your own bar, and also writes that telemetry to a per-session file your hooks can read.
ctx
is per session. The 5h/7d rate limits are account-wide but Claude Code only refreshes them on an API call, so any single session's view is often stale. The bar therefore shows, per window, the reading from whichever of your open sessions ON THIS MACHINE refreshed most recently ("latest API call wins": each session tracks when its rate_limits last changed, and the newest change is picked). A reading whose reset boundary already elapsed is treated as stale and never picked; if no session has a current reading for a window, the bar falls back to this session's last-known usage with no countdown. So your open sessions agree instead of showing different stale numbers. It does not converge across separate machines.
One job, three pieces:
bin/cct-statusline
- the POSIX shell entry Claude Code calls as its statusLine, every render. It ispure 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
. Then it prints thectx % | 5h % | 7d % | model
segment (each rate-limit field carrying a~time-left
countdown to its reset) and, ifCCT_WRAP
is 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 noexec
s that commandCCT_WRAP
the segment is the whole bar, never blank. There is no per-render Node spawn at all (an earlier design spawnednode
every render; across many sessions that piled up).index.js
- the consumer.
readTelemetry(sessionId)
reads 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
- an on-demand CLI reader over
readTelemetry
, for the manual live check. It is NOT in the per-render path; it runs only when you invoke it.
Zero 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
= 18 is required only for the hooks API (
readTelemetry
) and thebin/telemetry.js
reader. Never throws, never callsclaude
.
In any hook (PreToolUse, PreCompact, etc.), read the latest reading:
const { readTelemetry } = require('cc-context-telemetry');
const d = require('fs').readFileSync(0, 'utf8'); // hook stdin
const sessionId = JSON.parse(d).session_id;
const t = readTelemetry(sessionId);
if (t && t.fresh && t.contextPct >= 85) { /* near the wall: act */ }
readTelemetry(sessionId, opts)
returns null
when absent, otherwise:
{
sessionId, // string
contextPct, // number or null (context window used %)
usedPercentage, // number or null (alias of contextPct)
windowSize, // number or null (context_window_size)
fiveHourPct, // number, OMITTED when Claude Code did not report it
sevenDayPct, // number, OMITTED when Claude Code did not report it
fiveHourResetsAt, // number (Unix epoch seconds), OMITTED when absent
sevenDayResetsAt, // number (Unix epoch seconds), OMITTED when absent
model, // string or null (model id)
ts, // ISO timestamp string or null
source, // "statusline"
fresh // true when contextPct is a finite number AND ts is within the TTL
}
contextPct
/ usedPercentage
are passed through verbatim from Claude Code's
context_window.used_percentage
(a percentage, 0..100 in practice). The library does
NOT range-clamp them; it only verifies they are numbers, so treat them as advisory and
do not assume a strict 0..100
bound. fresh
already rejects non-finite values.
fiveHourResetsAt
/ sevenDayResetsAt
are the Unix epoch (seconds) when each rate-limit
window next resets. They are omitted (like the percentages) when the payload has no
rate_limits
or no resets_at
, and also when the value is not a plausible epoch - the
reader rejects non-epoch or absurd values (mirroring the statusline renderer) so a corrupt
file cannot hand you a bogus reset time. Compute time-left yourself, e.g.
fiveHourResetsAt - Date.now() / 1000
. A reset time can still be in the PAST when the
reading is stale (Claude Code refreshes rate_limits
only on an API call), so gate on
fresh
and treat an already-passed reset as stale, not as "resetting now".
fresh
rejects both stale and far-future timestamps (it checks the absolute age
against the TTL). When fresh
is false, treat it as "no signal" and stay
observe-only. opts.ttlSec
(or env CCT_TTL_SEC
) overrides the default 120s TTL. An override is honored only when it is a finite number greater than 0; a blank, zero, negative, or non-numeric value falls back to the default.
The module also exports DEFAULT_TTL_SEC
(the 120s default, a number),
parsePayload(d, sessionId)
(the raw-to-normalized field mapping),
rawPath(sessionId)
, pruneRaw()
, telemetryDir()
, telemetryPath(sessionId)
,
writeTelemetry(sessionId, obj)
, and nowIso()
. The session id is sanitized
(everything outside [A-Za-z0-9_-]
becomes _
), so a reading is always confined to the telemetry directory.
The pure-shell entry writes ONE file per session: the RAW Claude Code statusline
payload, verbatim, at <dir>/telemetry-raw-<session>.json
. It is overwritten each
render (atomically, via a temp file + rename), so a single session never grows the
directory. readTelemetry(sessionId)
parses that raw file on demand. The raw payload looks like (fields vary by plan/model/version - the lib reads them straight, never assuming a shape):
{
"session_id": "abc123",
"context_window": { "used_percentage": 47.2, "context_window_size": 200000 },
"rate_limits": {
"five_hour": { "used_percentage": 12, "resets_at": 1783359600 },
"seven_day": { "used_percentage": 30, "resets_at": 1783368000 }
},
"model": { "id": "claude-opus-4-1" }
}
rate_limits
is present only for Pro/Max OAuth; it is absent (so fiveHourPct
/
sevenDayPct
are omitted, never reported as 0) for API-key / Bedrock / Vertex.
context_window.used_percentage
is null
before the first API response and right
after a compaction (so contextPct
is null
and fresh
is false
).
Each rate_limits
window also carries resets_at
(a Unix epoch, seconds). The shell
segment renders it as a compact ~time-left
countdown next to the percent: at most two
units (~6d23h
, ~2h54m
, ~44m
, ~<1m
). It is a pure duration (reset minus date +%s
),
so there is no clock or timezone handling. The countdown is omitted whenever resets_at
is
missing (older Claude Code, or non-OAuth plans), implausible, or already in the past (a
reading whose reset boundary elapsed predates the last reset, so it is treated as stale: the
countdown is dropped and the bar shows this session's last-known usage % without one) -
never fabricated. (readTelemetry
surfaces these reset epochs too, as
fiveHourResetsAt
/ sevenDayResetsAt
; see the Consumer API section.)
telemetry-raw-<session>.json
is per session, so live sessions bound the file count,
but a long-lived machine sees a new session id (UUID) per claude
run. So
readTelemetry
prunes off the hot path (throttled, at most once per
CCT_PRUNE_EVERY_SEC
, default hourly): it deletes raw files older than
CCT_PRUNE_AGE_SEC
(default 1 day) and then caps the total at CCT_PRUNE_MAX_FILES
(default 200, deleting the oldest beyond the cap). It only ever touches this lib's own
telemetry-raw-*.json
files. Pruning is best-effort and never throws.
CCT_WRAP
- your original statusline command, as a SINGLE foreground command (a program plus args, e.g.
'/path/to/your-statusline' status
). The entryexec
s it with the same stdin so it becomes the statusLine process and forwards its output. It must NOT be a pipeline (a | b
) or backgrounded (trailing&
): a pipeline mis-routes stdin, and&
leaves a process running past the render (exactly as it would running directly). If you need a pipeline, put it in a small script and pointCCT_WRAP
at that script. WhenCCT_WRAP
is unset, the entry prints its own minimal bar (ctx 47% | 5h 12% ~3h | 7d 30% ~5d | opus-4.8
).CCT_SEGMENTS
- which bar segments to show and in what order (default
ctx,5h,7d,model
); see Customizing the bar.CCT_DIR
- telemetry directory (default
~/.claude/cc-context-telemetry/
). The shell entry mirrors this default; set it on the statusLine env AND for your hooks so both sides agree.CCT_TTL_SEC
- freshness window for
readTelemetry
in seconds (default 120).CCT_PRUNE_AGE_SEC
-
delete raw files older than this on the reader side (default 86400, 1 day).
CCT_PRUNE_MAX_FILES -
hard cap on raw files; the oldest beyond it are deleted (default 200).
CCT_PRUNE_EVERY_SEC -
throttle: prune at most once per this interval across reader invocations (default 3600, hourly).
The raw payload file (telemetry-raw-<session>.json
) IS the verbatim statusline payload, so it doubles as the debug dump: inspect it directly to see the real field paths Claude Code sent. It may contain real session content and is overwritten each render; the reader auto-prunes it (see Raw-file hygiene).
Claude Code runs your statusLine on every render in every session and kills that process
each time to keep it bounded. cct stays safe by exec
-ing your CCT_WRAP
command: the
entry process BECOMES your statusline, so what Claude Code spawns and kills IS your
statusline, torn down each render exactly as if you ran it directly - no extra process, no
pile-up. There is also NO Node on the per-render path (only shell; all JSON parsing happens
later, on demand, in index.js
), so nothing can outlive a render and orphan. An earlier
design that spawned node
every render orphaned under Claude Code's render-kills and piled
up; the current exec-through wrapper does not. test/loadrepro
proves it.
Full rationale (exec vs spawn, the orphan incident, the stdin/EOF contract, and the forking-statusline corollary): docs/process-safety.md.
Wire 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:
node examples/print-telemetry.js <session-id>
Check for sane values (a numeric or null contextPct
, a windowSize
, a model
). Your
<session-id>
is the session_id
field in any hook payload, and the <session>
in the
raw file written this session under ~/.claude/cc-context-telemetry/
. If values are null or missing, open that raw file - it IS the verbatim statusline payload - to see the real field paths Claude Code sent.
See the examples/ directory for complete, runnable files: a copy-pasteable PreToolUse warning hook (
pretooluse-warn.js
) and a telemetry
printer for the live check (print-telemetry.js
). The examples are not published to npm.MIT