| name | headroom-usage-indicator |
| description | Use when you want a persistent visual reminder of whether the headroom MCP compression is actually being used this session — an always-visible status line that stays "idle" until headroom_compress is called, flips to "active" with the tokens AND money it saved (priced against the session's model), then decays back to idle after a quiet period. When idle, it also counts large tool results that were never compressed and shows them as an actionable nudge. A yellow "broken" state takes over the badge when a hook or hcat records a real engine failure, until /doctor clears it. Also shows an all-time money-saved total across sessions. A companion PostToolUse hook ("Dangi") nudges Claude in real time when a big uncompressed output lands, with an optional desktop notification (macOS or Linux), and a 😴/🤖 mascot sits at the end of the badge. A Stop/SessionEnd hook logs a per-session savings/misses ledger, surfaced as a one-time invoice line at the next session's start. |
Headroom Usage Indicator
Overview
The headroom MCP compresses large, structured tool outputs to save context — but it's easy to forget to use it. This skill adds a Claude Code status line that is an honest, always-on indicator:
- 🔴
○ headroom idle (not compressing yet) — until headroom_compress runs this session; becomes ○ headroom idle · 4 big blobs uncompressed when large tool results are going uncompressed
- 🟢
● headroom · ~2.4k tok · $0.01 · 3× | $1.83 all-time — for 60s after a compression
- ⚪
○ headroom idle · ~2.4k tok · $0.01 · 3× · 2 missed | $1.83 all-time — after 60s of quiet (keeps the totals; · N missed appears when more big results arrived than you've compressed)
- 🟡
▲ headroom broken (engine) · run /doctor (v2.7) — takes over the badge for up to 24h after a hook or hcat records a genuine engine failure (not "never installed" — "resolved and then broken"); cleared by a working hcat compression or a clean /doctor run
- 🤖
😴 dangi / 🤖 dangi: 3! — the mascot at the right end of every badge: asleep when nothing is being missed, awake with the count when big results are going uncompressed. Its hook twin nudges Claude the moment such an output lands.
v2.3 adds the prevention layer: hcat compresses structured files at the source (raw bytes never enter context — the only path that saves tokens on the first pass), and a PreToolUse gate redirects Claude from raw Reads of big structured files to hcat, once per file per session. v2.4 closes the loop: the badge counts hcat runs too, by parsing the ── hcat: … ~B tok → ~A tok receipts hcat leaves in the transcript (passthrough receipts count as nothing; a receipt is never a "missed" blob).
v2.7 adds three more layers, all detailed in "How It Works" below: ambient health (real engine failures flip the badge yellow instead of silently reading as idle; a new SessionStart hook, scripts/session-probe.sh, runs a fast per-session check), a session ledger (scripts/ledger-hook.sh, Stop/SessionEnd) that prices both what was saved and what was missed and surfaces it as a one-time invoice at the next session's start, and learned detection (Dangi remembers file-backed offenders so the hcat gate can catch them again even off its static extension list, and a bare Bash cat of a gated file gets rewritten to hcat in place instead of denied).
v2.5 — this skill's job has shrunk. Dangi, the hcat gate, hcat-on-PATH, and the headroom MCP registration all ship inside the plugin now (hooks/hooks.json, bin/hcat, .mcp.json) and register automatically while the plugin is enabled. What remains for this skill:
- Status line setup — the one piece a plugin cannot register itself; use the statusLine-only installer below (or defer to the
doctor skill, which does the same with a consent step).
- Legacy migration — pre-v2.5 installs copied scripts to
~/.claude/ and registered hooks in settings.json; those double-fire next to the plugin's own hooks. Defer to the doctor skill for cleanup rather than hand-editing.
- Manual fallback — the full copy-to-
~/.claude installer, kept as the last install section below for setups without the plugin marketplace (v2.7: it also copies session-probe.sh and ledger-hook.sh and registers SessionStart/Stop/SessionEnd, since the plugin's hooks/hooks.json would otherwise be the only place those fire).
Core principle: detect real usage from the session transcript, not from intent. It counts tool_use calls to mcp__headroom__headroom_compress plus hcat receipts, and sums the tokens_saved / receipt deltas those actually reported — so it can't lie. The dollar figure prices those tokens at the session model's input rate (a conservative floor — see below).
Prerequisites
- The headroom MCP server is available (tools appear as
mcp__headroom__headroom_compress, …_retrieve, …_stats). Plugin installs bundle the registration via .mcp.json; the engine itself, if missing, can be bootstrapped into ~/.headroom-venv by the doctor skill. Legacy installs must register it by hand. v2.7.3: the bundled registration was broken from v2.5 through v2.7.2 — its command carried literal quotes, and MCP stdio commands are spawned without a shell, so the launcher was never found (/plugin showed a ✗). If those tools are missing on a plugin install, that is the likely cause; doctor --fix repairs an installed copy in place.
jq on PATH.
How It Works
All logic lives in one shipped script, scripts/statusline.sh (in this plugin, two directories above this SKILL.md). The installer copies it to ~/.claude/headroom-statusline.sh, provisions its runtime deps (scripts/lib/attribution.jq and scripts/lib/headroom-state.sh) into ~/.claude/lib/ — the badge silently reads zero without them (issue #2) — and points statusLine.command at it. Per render it:
-
Reads the status-line stdin JSON once — transcript_path, model.id, session_id.
-
Counts & sums — tool_use blocks named mcp__headroom__headroom_compress; tokens_saved from results linked by tool_use_id (never grep for raw strings — headroom_stats results and prose mentions are false positives).
-
Missed opportunities — counts tool_result blocks ≥ 4 KB (NUDGE_BYTES=4096) that don't belong to a headroom tool, then subtracts the number of compressions (each compression "forgives" one big blob, since compressing doesn't remove the original from the transcript). Shown on the red and grey idle badges only — never while actively compressing.
-
Money — tokens_saved × input-$/MTok for the session's model.id. The price table is data-driven (v2.6): data/model-prices.json (an ordered list matched by model-id substring, first match wins) is read when present — so adding a model is a data edit, not a code change — with the built-in price_per_mtok() case table as a zero-regression offline fallback when the file is absent or invalid. The installer/doctor copies the JSON next to the statusline copy (~/.claude/headroom-model-prices.json); HEADROOM_PRICES_FILE overrides the path. Unknown model → tokens-only badge (never a wrong dollar figure). This is the floor: compressed content would have re-entered context on later turns (mostly at the 0.1× cache-read rate), so real savings compound above it.
-
Cache — per-session results cached in ~/.claude/headroom-indicator/session-<id>.cache keyed on transcript byte size; unchanged size skips the jq parse (a stat call instead of an O(transcript) parse every second). Cache format: size|n|saved|last_ts|missed.
-
Lifetime — a session writes session-<id>.totals (tokens usd) only once it has actually saved tokens (sessions that never compress anything don't leave a file behind); if the session's model changes mid-session, the recorded usd is only ever raised, never lowered, by re-pricing at the new rate — a switch to a cheaper or unpriced model can't shrink what's already been credited. The badge sums existing totals files into once more than one session exists.
Install (plugin) — wire the status line only
With the plugin installed from the marketplace, the hooks, hcat, and the MCP registration are already live — the only thing left to set up is the status line. Prefer deferring to the doctor skill (/headroom-usage-indicator:doctor), which does this same wiring after a full diagnosis and a consent step. To do just the statusLine piece directly, use the installer below — do not hand-write the statusLine. It is merge-aware: an existing custom status line is preserved (backed up under _headroomStatusLineBackup) and the headroom segment appended. Re-running is idempotent and also refreshes the copied script (the upgrade path).
Set PLUGIN_ROOT to this plugin's root — the directory two levels above this SKILL.md (it contains scripts/statusline.sh):
python3 - <<'PY'
import json, pathlib, shutil
PLUGIN_ROOT = pathlib.Path("<absolute path of the directory two levels above this SKILL.md>")
src = PLUGIN_ROOT / "scripts" / "statusline.sh"
dest = pathlib.Path.home() / ".claude" / "headroom-statusline.sh"
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
dest.chmod(0o755)
# statusline.sh resolves attribution.jq + headroom-state.sh from a lib/ dir next
# to itself; without them the badge is stuck at "idle (not compressing yet)"
# forever, showing zero savings (issue #2). Provision them alongside the copy.
lib_dir = pathlib.Path.home() / ".claude" / "lib"
lib_dir.mkdir(parents=True, exist_ok=True)
for _lib in ("attribution.jq", "headroom-state.sh"):
shutil.copyfile(PLUGIN_ROOT / "scripts" / "lib" / _lib, lib_dir / _lib)
p = pathlib.Path.home() / ".claude" / "settings.json"
data = json.loads(p.read_text()) if p.exists() else {}
MARK = "headroom-statusline.sh" # v2 marker
OLD_MARK = "mcp__headroom__headroom_compress" # v1 one-liner marker
HR = 'bash "' + str(dest) + '"'
existing = data.get("statusLine"); backup = data.get("_headroomStatusLineBackup"); base = None
if isinstance(backup, dict) and backup.get("type") == "command" and backup.get("command"):
base = backup # re-run/upgrade after a merge → re-merge onto true original
elif isinstance(existing, dict) and existing.get("type") == "command" and existing.get("command") \
and MARK not in existing["command"] and OLD_MARK not in existing["command"]:
base = existing # a real pre-existing custom status line
if base is not None:
data["_headroomStatusLineBackup"] = base
cmd = ('in=$(cat); left=$(printf \'%s\' "$in" | { ' + base["command"] + '; }); '
'hr=$(printf \'%s\' "$in" | ' + HR + '); printf \'%s %s\' "$left" "$hr"')
mode = "merged (appended to your existing status line)"
else:
cmd = HR
mode = "installed (standalone)"
data["statusLine"] = {"type": "command", "command": cmd, "refreshInterval": 1}
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
print("headroom status line", mode)
PY
Upgrading from v1 is the same command: a v1 standalone one-liner (contains OLD_MARK) is replaced outright; a v1 merged install re-merges from the backup.
To restore the user's original status line: copy _headroomStatusLineBackup back over statusLine, delete the backup key, and optionally remove ~/.claude/headroom-statusline.sh and ~/.claude/headroom-indicator/.
Migrating from a pre-v2.5 manual install
Pre-v2.5 installs copied dangi-hook.sh, hcat-gate.sh, and hcat into ~/.claude/ and registered the hooks directly in settings.json. Alongside the plugin's own hooks/hooks.json those entries double-fire every hook. Do not hand-edit the user's settings.json for this — defer to the doctor skill, which detects the legacy registration and, with consent, removes the hooks.PostToolUse entry referencing dangi-hook.sh, the hooks.PreToolUse entry referencing hcat-gate.sh, and the copies ~/.claude/dangi-hook.sh, ~/.claude/hcat-gate.sh, and ~/.claude/hcat (with a timestamped settings.json backup). The status-line copy at ~/.claude/headroom-statusline.sh stays — that is still how the badge runs.
Legacy fallback: full manual install (no plugin)
Only for setups that can't use the plugin marketplace — never run this alongside the plugin (every hook would fire twice). It copies everything into ~/.claude/ and registers the hooks in settings.json itself — including, as of v2.7, the SessionStart probe and the Stop/SessionEnd ledger hook, which the plugin's own hooks/hooks.json would otherwise be the only thing registering. Two caveats unique to this flow: hcat is not on Claude's Bash PATH here — it must be invoked by full path, ~/.claude/hcat "<file>" (the gate's deny message suggests bare hcat; read it accordingly) — and the headroom engine + MCP server must be installed and registered by hand (https://github.com/headroomlabs-ai/headroom).
Set PLUGIN_ROOT to the cloned repo root, then run:
python3 - <<'PY'
import json, pathlib, shutil
PLUGIN_ROOT = pathlib.Path("<absolute path of the cloned repo root>")
src = PLUGIN_ROOT / "scripts" / "statusline.sh"
dest = pathlib.Path.home() / ".claude" / "headroom-statusline.sh"
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src, dest)
dest.chmod(0o755)
hook_src = PLUGIN_ROOT / "scripts" / "dangi-hook.sh"
hook_dest = pathlib.Path.home() / ".claude" / "dangi-hook.sh"
shutil.copyfile(hook_src, hook_dest)
hook_dest.chmod(0o755)
hcat_dest = pathlib.Path.home() / ".claude" / "hcat"
shutil.copyfile(PLUGIN_ROOT / "bin" / "hcat", hcat_dest)
hcat_dest.chmod(0o755)
gate_dest = pathlib.Path.home() / ".claude" / "hcat-gate.sh"
shutil.copyfile(PLUGIN_ROOT / "scripts" / "hcat-gate.sh", gate_dest)
gate_dest.chmod(0o755)
probe_dest = pathlib.Path.home() / ".claude" / "session-probe.sh"
shutil.copyfile(PLUGIN_ROOT / "scripts" / "session-probe.sh", probe_dest)
probe_dest.chmod(0o755)
ledger_dest = pathlib.Path.home() / ".claude" / "ledger-hook.sh"
shutil.copyfile(PLUGIN_ROOT / "scripts" / "ledger-hook.sh", ledger_dest)
ledger_dest.chmod(0o755)
# Shared libs (v2.7): the hooks/hcat/statusline/ledger all source these as flat
# siblings in a legacy layout ($here/headroom-state.sh, $here/attribution.jq).
# WITHOUT them, ambient-health (broken badge) and offender-learning silently
# degrade to no-ops and the badge/ledger attribution reads zero — so copy them.
for _lib in ("headroom-state.sh", "attribution.jq"):
shutil.copyfile(PLUGIN_ROOT / "scripts" / "lib" / _lib,
pathlib.Path.home() / ".claude" / _lib)
p = pathlib.Path.home() / ".claude" / "settings.json"
data = json.loads(p.read_text()) if p.exists() else {}
MARK = "headroom-statusline.sh" # v2 marker
OLD_MARK = "mcp__headroom__headroom_compress" # v1 one-liner marker
HR = 'bash "' + str(dest) + '"'
existing = data.get("statusLine"); backup = data.get("_headroomStatusLineBackup"); base = None
if isinstance(backup, dict) and backup.get("type") == "command" and backup.get("command"):
base = backup # re-run/upgrade after a merge → re-merge onto true original
elif isinstance(existing, dict) and existing.get("type") == "command" and existing.get("command") \
and MARK not in existing["command"] and OLD_MARK not in existing["command"]:
base = existing # a real pre-existing custom status line
if base is not None:
data["_headroomStatusLineBackup"] = base
cmd = ('in=$(cat); left=$(printf \'%s\' "$in" | { ' + base["command"] + '; }); '
'hr=$(printf \'%s\' "$in" | ' + HR + '); printf \'%s %s\' "$left" "$hr"')
mode = "merged (appended to your existing status line)"
else:
cmd = HR
mode = "installed (standalone)"
data["statusLine"] = {"type": "command", "command": cmd, "refreshInterval": 1}
HOOK_MARK = "dangi-hook.sh"
hooks = data.get("hooks")
hooks = data["hooks"] = hooks if isinstance(hooks, dict) else {}
ptu = hooks.get("PostToolUse")
ptu = hooks["PostToolUse"] = ptu if isinstance(ptu, list) else []
if not any(HOOK_MARK in json.dumps(e) for e in ptu):
ptu.append({
"matcher": "*",
"hooks": [{"type": "command", "command": 'bash "' + str(hook_dest) + '"', "timeout": 10}],
})
GATE_MARK = "hcat-gate.sh"
pre = hooks.get("PreToolUse")
pre = hooks["PreToolUse"] = pre if isinstance(pre, list) else []
if not any(GATE_MARK in json.dumps(e) for e in pre):
pre.append({
"matcher": "Read",
"hooks": [{"type": "command", "command": 'bash "' + str(gate_dest) + '"', "timeout": 10}],
})
PROBE_MARK = "session-probe.sh"
ss = hooks.get("SessionStart")
ss = hooks["SessionStart"] = ss if isinstance(ss, list) else []
if not any(PROBE_MARK in json.dumps(e) for e in ss):
ss.append({
"hooks": [{"type": "command", "command": 'bash "' + str(probe_dest) + '"', "timeout": 10}],
})
LEDGER_MARK = "ledger-hook.sh"
for event_name in ("Stop", "SessionEnd"):
lst = hooks.get(event_name)
lst = hooks[event_name] = lst if isinstance(lst, list) else []
if not any(LEDGER_MARK in json.dumps(e) for e in lst):
lst.append({
"hooks": [{"type": "command", "command": 'bash "' + str(ledger_dest) + '"', "timeout": 10}],
})
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
print("headroom status line", mode, "+ dangi hook + hcat gate + session-probe + ledger hook registered")
PY
To remove a legacy install: restore the status line as above, remove the hooks.PostToolUse entry referencing dangi-hook.sh, the hooks.PreToolUse entry referencing hcat-gate.sh, the hooks.SessionStart entry referencing session-probe.sh, and the hooks.Stop/hooks.SessionEnd entries referencing ledger-hook.sh from settings.json; then delete ~/.claude/dangi-hook.sh, ~/.claude/hcat-gate.sh, ~/.claude/hcat, ~/.claude/session-probe.sh, ~/.claude/ledger-hook.sh, and the shared libs ~/.claude/headroom-state.sh and ~/.claude/attribution.jq.
Verify Before Trusting It
Run the plugin's test suite from the plugin root — it drives the script with synthetic transcripts (active badge, stats-only false positive, money math, unknown-model fallback, cache behavior, lifetime totals):
./test.sh
Or drive the installed copy by hand:
NOW=$(date -u +%Y-%m-%dT%H:%M:%S.000Z)
printf '%s\n' \
"{\"timestamp\":\"$NOW\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t1\",\"name\":\"mcp__headroom__headroom_compress\"}]}}" \
'{"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"{\"tokens_saved\": 500}"}]}]}}' > /tmp/hr.jsonl
printf '{"transcript_path":"/tmp/hr.jsonl","model":{"id":"claude-opus-4-8"},"session_id":"verify"}' \
| bash ~/.claude/headroom-statusline.sh; echo # → green ● … ~500 tok · 0.25¢ · 1×
rm -f /tmp/hr.jsonl
Common Mistakes
| Mistake | Fix |
|---|
grep-ing the transcript for the tool name or tokens_saved | Use jq on .type=="tool_use" / link results by tool_use_id (as the script does). Raw strings appear in prose, stats results (total_tokens_saved), and your own outputs — all false positives. |
Counting headroom_stats/retrieve too | Count only headroom_compress — inspecting headroom shouldn't flip it to active. |
Hand-editing ~/.claude/headroom-statusline.sh | It's a copy; re-running the installer overwrites it. Edit scripts/statusline.sh in the plugin and re-run the installer. |
| Guessing a price for an unknown model | Don't — the script deliberately falls back to tokens-only. Add the model to price_per_mtok() instead. |
| Clobbering an existing status line | Use the merge-aware installer; never blindly overwrite statusLine. |
| A merged status line reading stdin twice | stdin is consumable once. The merged command does in=$(cat) first and feeds $in to both segments. |
| Timestamp/size parsing breaking on one OS | The script ships GNU→BSD fallbacks for date and stat — keep both when editing. |
Adding echo / debug prints to dangi-hook.sh | Anything on stdout besides the single JSON object corrupts the hook output for every tool call. Debug to a file (>> /tmp/dangi.log) instead. |
Reload Caveat
Claude Code's settings watcher reliably reloads files that existed at session start. After installing, the line should appear on the next render; if not, open /statusline or /config once to force a reload, or it will be there next session.
Customize
All knobs live in scripts/statusline.sh (edit, then re-run the installer):
- Decay window: the
60 in [ "$age" -le 60 ].
- Prices: edit
data/model-prices.json (ordered {match, usd_per_mtok} list, first substring match wins) — data, not code. The price_per_mtok() case table is only the offline fallback when that file is missing. HEADROOM_PRICES_FILE overrides the path.
- State dir:
HEADROOM_STATE_DIR env var (used by tests).
- Colors:
\033[32m green (active), \033[90m dim (decayed), \033[31m red (never used).
- Different MCP tool: change
TOOL= (drop the tokens_saved sum if that tool doesn't report one).
- Nudge threshold:
NUDGE_BYTES (default 4096) — minimum tool-result size that counts as a missed compression opportunity. Raise it if code-file reads trigger false nags.
- Dangi cooldowns:
NUDGE_COOLDOWN (60 s between context nudges) and NOTIFY_COOLDOWN (300 s between notifications) in dangi-hook.sh; set DANGI_NO_NOTIFY=1 in your environment to disable notifications entirely.
- Dangi delegation tier (v2.7):
DANGI_HUGE_BYTES (default 131072, 128 KiB) — the true on-disk size at/above which Dangi's nudge switches from "compress it" to "delegate to a disposable subagent."
- Offender TTL (v2.7):
HEADROOM_OFFENDER_TTL (default 1209600, 14 days) — how long a file Dangi flagged stays gate-eligible in $STATE_DIR/offenders, shared by dangi-hook.sh and hcat-gate.sh.
- Gate threshold:
HCAT_GATE_BYTES (default 16384) — minimum file size the gate fires on; HCAT_GATE_OFF=1 disables the gate entirely; the gated extension list is the case in hcat-gate.sh.
- Gate rewrite (v2.7):
HCAT_GATE_NO_REWRITE=1 — makes a bare Bash cat <file> deny-and-suggest like Read does, instead of the default in-place rewrite to .