Building claude-hud: a status line and Stop hook, built with Claude Code itself
claude-hud is a Claude Code plugin: a status line plus a Stop-hook script that keep context-window usage, 5-hour session usage, and 7-day weekly usage visible without ever having to ask. I built it by describing the idea to Claude Code and having it write and iterate on its own monitoring plugin.
The idea
Claude Code’s built-in status indicator gives a rough read on context and rate-limit usage, but it’s coarse — enough to know things are “getting up there,” not enough to answer “do I /compact now, or do I have another 20 minutes?” The ask to Claude was to build something that computes the real numbers from the actual session data on every turn, not a guess refreshed occasionally.
Two hook surfaces, two different data shapes
Claude Code exposes usage data to a plugin through two completely different channels, and the plugin’s two scripts exist because of that split.
Status line (statusline-command.sh) |
Stop hook (token-count.sh) |
|
|---|---|---|
| Input | JSON on stdin, pre-computed by Claude Code | just session_id + transcript_path |
| Has usage numbers already? | Yes | No — must derive them from the transcript |
| Runs | On every status-line render | On Stop and StopFailure |
| Job | Format and display | Parse, compute, and warn |
The status line
statusline-command.sh is fed a JSON blob on stdin, once per render, already containing the numbers Claude Code has computed:
{
"context_window": { "used_percentage": 71, "context_window_size": 200000 },
"rate_limits": {
"five_hour": { "used_percentage": 71, "resets_at": 1739999999 },
"seven_day": { "used_percentage": 71, "resets_at": 1739999999 }
},
"session_id": "..."
}
Its job is mostly presentation:
jqpulls the fields out of the JSON.make_bar()does ceiling-division to render a 10-character█/░block bar.epoch_to_countdown()turnsresets_atinto"2h 14m".fmt_tokens()collapses raw counts into45k/1.2M.- Color follows a flat threshold ladder — red at ≥70%, yellow at ≥40%, green below — via raw ANSI escapes (
\033[31m,\033[33m,\033[32m), since a status line has no access to a terminal UI library.
The Stop hook
token-count.sh gets none of that pre-computed data — just a session_id and a transcript_path. Context-window usage isn’t a field anywhere; it has to be derived from the session’s own JSONL transcript, which is a harder problem than it sounds:
- Transcript lag. The transcript can lag behind the response that triggered the hook, so the script compares the hook’s
last_assistant_messageagainst the transcript’s tail and retries up to 10 times at 300ms intervals until they match, instead of trusting the first read. - Model detection. The model actually in use isn’t static config — it’s read straight off the last assistant message (
message.model), normalized (strip date suffixes like-20250514, detect[1m]/-1mcontext variants), and matched against a hardcoded limit table (sonnet-5→ 1M,opus-4-5→ 200k, and so on), so the percentage is computed against the actual window size for the model actually running, not an assumed one. - What counts as “used.” It isn’t
input_tokensalone — it’sinput_tokens + cache_read_input_tokens + cache_creation_input_tokensfrom the last assistant message’susageblock, since that’s the state of the window after the most recent API call, cache included. - Per-turn vs. per-session. The per-turn output total is computed separately, by walking backward through the transcript to the last non-tool-result user message and summing
output_tokensacross every assistant message since — so “this turn” and “this whole session” are tracked as distinct numbers from the same message list.
All of that runs inside an inline python3 -c "..." block invoked from bash, because JSON/JSONL parsing and the retry logic are unpleasant in pure shell, while the rest of the hook — locating the transcript file, writing sentinel files, wiring into jq — is more naturally bash.
Notification de-duplication without a database
Both scripts need to fire a desktop notification (notify-send) exactly once per session when a metric first crosses 70% — not on every subsequent render. There’s no persistent plugin state, so the mechanism is a sentinel file per session per metric:
/tmp/claude-notify-ctx-<session_id>
/tmp/claude-notify-session-<session_id>
/tmp/claude-notify-week-<session_id>
First crossing creates the file and fires notify-send; every render after that finds the file and skips. A --test [context|session|week|clear|all] mode on the status-line script fakes the stdin JSON and clears the sentinel first, so the notification path can be exercised without waiting for real usage to climb.
Plugin packaging: what’s declarative vs. what isn’t
Claude Code plugins resolve almost everything from a manifest. .claude-plugin/plugin.json declares name, version, author, and license. hooks/hooks.json registers token-count.sh against two separate hook events, not one:
{
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/token-count.sh\"", "timeout": 10 }] }
],
"StopFailure": [
{ "hooks": [{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/token-count.sh\"", "timeout": 10 }] }
]
}
}
Stop fires when Claude finishes a turn normally; StopFailure fires when the turn errors out instead. Wiring the same script to both means usage tracking doesn’t silently stop the moment a turn fails — a crashed tool call or an API error still updates the status bar and still counts toward the 70% threshold, instead of leaving a gap in the numbers right when things are already going wrong. ${CLAUDE_PLUGIN_ROOT} resolves to wherever the plugin actually landed on disk, so neither hook entry needs to know the install path in advance.
The one thing that isn’t declarative is the status line — statusLine has no equivalent manifest key, so it’s always a manual command entry in the user’s own settings.json, plugin or not.
Distribution reuses the same manifest system one level up: .claude-plugin/marketplace.json lists claude-hud with "source": "./", meaning the plugin lives at the marketplace’s own root. Since a local clone and a GitHub URL resolve through the identical mechanism, claude plugin marketplace add <owner>/<repo> and claude plugin install claude-hud@claude-hud-marketplace work unchanged whether the marketplace came from disk or from git clone’d bytes over HTTPS.
Installation
Straightforward from there: install.sh runs those two claude plugin commands, then reads installed_plugins.json with jq to find where the plugin actually landed and patches that path into settings.json’s statusLine — the one piece install can’t handle declaratively. End result is a single line:
curl -fsSL https://raw.githubusercontent.com/Khaleelibrahimofficial/claude-hud/main/install.sh | bash
Removal
There’s no uninstall.sh — removal is the reverse of what install.sh did, and every step is a plain claude plugin command plus one manual settings edit (the same asymmetry as install: everything else is declarative, statusLine isn’t).
- Remove the
statusLineentry.install.shpatched~/.claude/settings.jsondirectly (plugins have no manifest key for it, so nothing auto-removes it on uninstall). Delete thestatusLinekey, or run:jq 'del(.statusLine)' ~/.claude/settings.json > /tmp/claude-hud-settings-tmp.json \ && mv /tmp/claude-hud-settings-tmp.json ~/.claude/settings.json - Uninstall the plugin.
claude plugin uninstall claude-hud@claude-hud-marketplace - Remove the marketplace registration (optional — only needed if you don’t plan to reinstall from it later):
claude plugin marketplace remove claude-hud-marketplace - Clean up leftover sentinel files. The notification de-dup files aren’t tracked or removed by plugin uninstall since they live outside the plugin’s install path:
rm -f /tmp/claude-notify-ctx-* /tmp/claude-notify-session-* /tmp/claude-notify-week-* - Restart Claude Code to drop the hook registrations and stop rendering the status line.
Takeaways
- The status line and the Stop hook see fundamentally different inputs. One gets pre-computed percentages on stdin; the other gets a session ID and has to reconstruct token/context state itself by reading and reasoning over the raw transcript.
- Model context-window size isn’t a constant — it has to be inferred per-message. Hardcoding “200k tokens” breaks silently the moment a session runs on a 1M-context model or variant; reading
message.modeloff the transcript is what keeps the percentage honest. - Sentinel files are a perfectly good substitute for state when a hook has no database. One file per session per metric is enough to get “fire once” semantics out of a stateless script.
StopandStopFailureboth matter. Wiring the same script to both events means usage tracking survives a failed turn, not just a clean one.- Plugins have no
statusLinemanifest key. Everything else (hooks, commands, agents, MCP servers) auto-registers from the manifest; a status line is always a manualsettings.jsonpatch.
Try it yourself
curl -fsSL https://raw.githubusercontent.com/Khaleelibrahimofficial/claude-hud/main/install.sh | bash

