claude-hud is a small (72 files, ~270k characters), pure-TypeScript Claude
Code plugin with zero runtime dependencies. It is built into a single
ESM bundle (dist/index.js) by the TypeScript compiler, ships through the
Claude Code plugin marketplace, and runs as the statusline subprocess on
every Claude Code prompt. There is no service, no API, no daemon, and no
network call. This guide covers the architecture, every config knob, the
deployment shape, the operational characteristics, performance behaviour,
the security profile (transcript exposure is the headline concern), the
code organisation for contributors, and the integration points (Claude Code
stdin schema, git, OS).
It is the companion to the User Guide; usage / recipes / FAQ live there.
1. Architecture Overview
claude-hud is a synchronous-feeling but internally async pipeline that
runs once per Claude Code prompt and exits. The driver is main() in
src/index.ts (the full file is ~155 lines). Everything else is a leaf
function or a small module with a single responsibility.
High-level data flow
┌──────────────────┐
Claude Code ──► │ stdin (JSON 1x) │
└────────┬─────────┘
│
▼
┌─────────────────────────┐
│ src/index.ts: main() │
│ 1. readStdin() │
│ 2. parseTranscript() │
│ 3. countConfigs() │
│ 4. loadConfig() │
│ 5. getGitStatus() │
│ 6. getUsageFromStdin() │
│ 7. parseExtraCmdArg() │
│ 8. runExtraCmd() │
│ 9. getClaudeCodeVer() │
│ 10. getMemoryUsage() │
└────────┬────────────────┘
│ RenderContext
▼
┌────────────────────────┐
│ src/render/index.ts │
│ render(ctx) │
│ ├── renderExpanded() │
│ │ - project │
│ │ - context │
│ │ - usage │
│ │ - memory │
│ │ - environment │
│ │ - tools │
│ │ - agents │
│ │ - todos │
│ ├── renderCompact() │
│ │ - sessionLine │
│ ├── ANSI-aware wrap │
│ └── console.log()×N │
└────────────────────────┘
The pipeline is fully testable through dependency injection — main()
accepts a Partial<MainDeps> overrides bag, and every leaf function the
driver calls is also in MainDeps. The tests/ directory exercises this
extensively (15 test files, including tests/index.test.js which mocks
readStdin, parseTranscript, etc.).
Component inventory
| Component | Path | Role |
|---|---|---|
| Driver | src/index.ts |
main() orchestrates all leaf calls into RenderContext. Also exports formatSessionDuration(). |
| Stdin reader | src/stdin.ts |
Drains JSON from stdin with a 250ms first-byte timeout, 30ms idle timeout, 256KB max. Also: getTotalTokens, getContextPercent, getBufferedPercent, getModelName, formatModelName, getProviderLabel, getUsageFromStdin. |
| Transcript parser | src/transcript.ts |
Streams the JSONL transcript line-by-line, builds tool/agent/todo state, deduplicates via maps, keeps the last 20 tools / 10 agents. Cached by mtime+size at transcript-cache/<sha256>.json. |
| Config loader | src/config.ts |
Defines HudConfig, DEFAULT_CONFIG, mergeConfig(). Validates every field; clamps numbers; migrates legacy layout strings. |
| Config-counts probe | src/config-reader.ts |
countConfigs() — counts CLAUDE.md / rules / MCPs / hooks across user and project scopes with deduplication. mtime-keyed cache at config-cache/<sha256>.json. |
| Plugin-dir resolver | src/claude-config-dir.ts |
getClaudeConfigDir(), getClaudeConfigJsonPath(), getHudPluginDir() — single source of truth for ~/.claude/ resolution and CLAUDE_CONFIG_DIR honouring. |
| Git probe | src/git.ts |
getGitStatus() — fork-execs git rev-parse, git status --porcelain, git diff --numstat HEAD, git rev-list --left-right --count, git remote get-url origin. 1–2s timeouts. |
| Cost calculator | src/cost.ts |
resolveSessionCost() — prefers native cost.total_cost_usd, falls back to a regex-table model match (Opus 4 / Sonnet 4 / Sonnet 3.7 / Sonnet 3.5 / Haiku 3.5) and per-token estimate. Bedrock suppresses cost. |
| Memory probe | src/memory.ts |
getMemoryUsage() — /usr/bin/vm_stat on macOS (parseVmStat), os.totalmem/os.freemem elsewhere. |
| Speed tracker | src/speed-tracker.ts |
getOutputSpeed() — file-cached output-token counter with a 2-second sliding window. |
| Version probe | src/version.ts |
getClaudeCodeVersion() — resolves claude binary on PATH, runs --version (or cmd.exe /c on Windows .cmd/.bat), caches result keyed by binary path + mtime. |
| Extra-cmd | src/extra-cmd.ts |
parseExtraCmdArg() + runExtraCmd() — sanitised user-provided shell command that returns {label}. |
| Constants | src/constants.ts |
AUTOCOMPACT_BUFFER_PERCENT = 0.165. |
| Debug | src/debug.ts |
createDebug(namespace) — stderr-only, gated on DEBUG env var. |
| i18n | src/i18n/ (4 files) |
setLanguage, getLanguage, t(key). en and zh locales; English fallback for missing keys. |
| Renderer (orchestrator) | src/render/index.ts |
render(ctx) — dispatches on lineLayout, runs ANSI-aware grapheme-aware width calculation and line wrapping. |
| Header lines | src/render/lines/* (8 files) |
renderProjectLine, renderIdentityLine (context), renderUsageLine, renderMemoryLine, renderEnvironmentLine, renderSessionTokensLine, renderGitFilesLine, renderCostEstimate. |
| Compact session line | src/render/session-line.ts |
The kitchen-sink single-line renderer for lineLayout: "compact". |
| Activity lines | src/render/{tools,agents,todos}-line.ts |
Three small renderers, each ≤80 lines. |
| Colors | src/render/colors.ts |
ANSI escape constants, hex-to-truecolor, named-preset table, threshold-aware colour pickers (getContextColor, getQuotaColor), bar generators (coloredBar, quotaBar). |
| Terminal width | src/utils/terminal.ts |
UNKNOWN_TERMINAL_WIDTH = 40, getAdaptiveBarWidth(). |
| Tests | tests/*.test.js (14 files) |
node --test against the compiled dist/. |
Technology stack
| Layer | Choice |
|---|---|
| Language | TypeScript (target ESNext, ESM modules) |
| Build | tsc only — no bundler, no Babel, no esbuild |
| Test runner | Node's built-in node:test (node --test) |
| Coverage | c8 (V8 source-map-aware, dev-only) |
| Runtime | Node.js ≥18 (no native modules, all node:* builtins) |
| Distribution | Claude Code plugin marketplace (/plugin install ...) |
| License | MIT |
Data flow for the main "render once" workflow
- Claude Code calls the configured
statusLine.command. - The command runs
node ~/.claude/plugins/claude-hud/dist/index.jswith the JSON of the current session piped to stdin. readStdin()reads, parses, and returns aStdinDataobject (ornullif stdin is a TTY or empty within the timeouts).parseTranscript(stdin.transcript_path)either reads from the on-disk JSONL transcript or returns the cached parse result if transcript mtime + size haven't changed.countConfigs(stdin.cwd)reads or returns from cache.loadConfig()reads the user'sconfig.jsonor returns defaults.getGitStatus(stdin.cwd)runs git subprocesses ifgitStatus.enabled.getUsageFromStdin(stdin)extracts the rate-limit windows.parseExtraCmdArg()reads--extra-cmdfromprocess.argv; if present,runExtraCmd()executes it.formatSessionDuration()formats start-time delta.getClaudeCodeVersion()runs only ifdisplay.showClaudeCodeVersion.getMemoryUsage()runs only ifdisplay.showMemoryUsageANDlineLayout === "expanded".- The full
RenderContextis handed torender(ctx), which iterates enabled elements inelementOrder, computes line widths with grapheme awareness, wraps to terminal width, andconsole.log()s each line. - The process exits.
There is no shared state across invocations except the on-disk caches.
Note
The driver is wrapped in a single
try / catch. Any uncaught error falls through to[claude-hud] Error: <message>on stdout. The plugin is designed to never block Claude Code's prompt cycle, even if every probe fails.
Cache topology
| Cache | Location | Key | TTL / Invalidation |
|---|---|---|---|
| Transcript parse | <plugin>/transcript-cache/<sha256-of-resolved-transcript-path>.json |
transcript path + mtime + size | Auto (mtime/size change) |
| Config counts | <plugin>/config-cache/<sha256-of-{cwd,claudeConfigDir}>.json |
cwd + Claude config dir + per-file sentinels (CLAUDE.md, rules dir, settings.json, settings.local.json, .mcp.json, etc.) | Auto (any sentinel mtime/size change) |
| Claude Code version | <plugin>/.claude-code-version-cache.json |
binary realpath + mtime | Auto (binary mtime change) — known bug: in-place updates that preserve mtime are not detected (issue #429) |
| Output speed | <plugin>/.speed-cache.json |
(singleton) | Sliding 2-second window |
2. System Requirements
Runtime requirements
| Component | Required version | Why |
|---|---|---|
| Node.js | ≥18.0.0 | Declared in package.json engines field. ESM, Intl.Segmenter, node:test all rely on 18+. |
| Claude Code | Any modern release; 2.0+ recommended; 2.1.6+ for native context % | claude-hud adapts to whatever stdin shape Claude Code emits, but newer fields like context_window.used_percentage and cost.total_cost_usd are only honoured when present. |
| Operating system | macOS / Linux / Windows | All three are supported. macOS-specific code paths exist (vm_stat, init message reminding users to restart Claude Code). Windows-specific code paths exist (PATHEXT, cmd.exe /c for .cmd/.bat Claude binaries, drive-root path normalisation). |
| Terminal | UTF-8, ANSI colour | The HUD prints unicode bars, emojis, OSC-8 hyperlinks, and 256-color/truecolor escape codes. |
Build-time requirements (for contributors)
| Component | Version | Why |
|---|---|---|
| TypeScript | ^6.0.2 | tsc is the only compiler. No bundler. |
@types/node |
^25.5.2 | Type definitions for node:*. |
c8 |
^11.0.0 | Coverage during npm run test:coverage. |
devDependencies live in package.json; there are zero runtime
dependencies. The published plugin contains the compiled dist/ plus
src/, commands/, and .claude-plugin/.
Disk footprint
The plugin itself is well under 1 MB compiled (the source is ~270 KB, the emitted JS is similar). Caches add up over time but each cache file is small (≤ a few KB):
transcript-cache/— one file per distinct transcript path (one per session). Pruning is opportunistic — files become unreadable and are silently rebuilt when transcript mtime/size mismatch.config-cache/— one file per distinct(cwd, claudeConfigDir)pair.
Important
There is no automatic cache cleanup. If you switch between many projects, the
config-cache/andtranscript-cache/directories will grow unboundedly. This is generally fine — files are tiny — butrm -rf ~/.claude/plugins/claude-hud/{config,transcript}-cacheis a safe no-op recovery if you ever want to start fresh.
3. Configuration Reference
This section is the complete reference. Every key, every default, every type, every clamp.
Configuration files
| File | Format | Location | Purpose |
|---|---|---|---|
config.json |
JSON | <plugin>/config.json (where <plugin> is ~/.claude/plugins/claude-hud/ by default, or $CLAUDE_CONFIG_DIR/plugins/claude-hud/) |
User-facing config: layout, display flags, colours, thresholds. Read by loadConfig(). |
~/.claude/settings.json |
JSON | User home (or $CLAUDE_CONFIG_DIR/settings.json) |
Where Claude Code itself stores statusLine.command. claude-hud does not read this except via countConfigs() to count MCPs/hooks/outputStyle. |
~/.claude.json |
JSON | User home | Claude Code's user MCP servers + disabledMcpServers. Read by countConfigs(). |
<cwd>/.mcp.json |
JSON | Per-project | Project-level MCP server registrations. Read by countConfigs(). |
<cwd>/.claude/settings.{json,local.json} |
JSON | Per-project | Project Claude Code settings, including hooks and disabledMcpjsonServers. Read by countConfigs(). |
~/.claude/CLAUDE.md, <cwd>/CLAUDE.md, <cwd>/CLAUDE.local.md, ~/.claude/rules/**/*.md, <cwd>/.claude/rules/**/*.md |
Markdown | Various | Counted by countConfigs(); never parsed for content. |
Environment variables
claude-hud reads only a small set of env vars; all others are ignored.
| Variable | Type | Default | Required | Description |
|---|---|---|---|---|
CLAUDE_CONFIG_DIR |
path | ~/.claude |
No | Override the Claude config directory. Affects every cache and the plugin install location. Resolves ~ prefixes. |
COLUMNS |
int | unset | No | Last-resort terminal width when neither process.stdout.columns nor process.stderr.columns is available. Default fallback is UNKNOWN_TERMINAL_WIDTH = 40. |
COMSPEC |
path | cmd.exe |
No (Windows only) | Used when invoking claude --version against a .cmd or .bat binary on Windows. |
PATHEXT |
semicolon-separated string | .COM;.EXE;.BAT;.CMD |
No (Windows only) | Used when resolving claude on PATH on Windows. |
PATH |
colon/semicolon-separated paths | inherited | Yes (for claude --version) |
Searched by resolveClaudeBinaryFromPath(). |
DEBUG |
string | unset | No | If it includes claude-hud (or equals *), namespaced debug output goes to stderr. |
LANG |
locale | system | No | Read only by tests to enforce English output. |
CLI flags
The HUD has exactly one user-facing CLI flag.
| Flag | Type | Default | Description |
|---|---|---|---|
--extra-cmd <command> or --extra-cmd=<command> |
string | unset | A user-defined shell command. Output is parsed as JSON { "label": "..." }; the label is sanitised, truncated to 50 chars, and rendered on the project line. 3-second timeout, 10 KB stdout cap. |
There are no other flags. The plugin does not respond to --help,
--version, or -h.
HudConfig shape (every key)
Top level
| Key | Type | Default | Validator | Notes |
|---|---|---|---|---|
language |
'en' | 'zh' |
'en' |
validateLanguage |
Anything else → default. |
lineLayout |
'compact' | 'expanded' |
'expanded' |
validateLineLayout |
Anything else → default. |
showSeparators |
boolean | false |
type check | Non-boolean → default. |
pathLevels |
1 | 2 | 3 |
1 |
validatePathLevels |
Anything else → default. |
elementOrder |
HudElement[] |
['project', 'context', 'usage', 'memory', 'environment', 'tools', 'agents', 'todos'] |
validateElementOrder |
Drops unknown items, dedupes, falls back to default if empty. |
gitStatus
| Key | Type | Default | Validator |
|---|---|---|---|
enabled |
boolean | true |
type check |
showDirty |
boolean | true |
type check |
showAheadBehind |
boolean | false |
type check |
showFileStats |
boolean | false |
type check |
pushWarningThreshold |
number ≥0 (integer) | 0 |
validateCountThreshold (clamps NaN/negative to 0, floors floats) |
pushCriticalThreshold |
number ≥0 (integer) | 0 |
validateCountThreshold |
display
| Key | Type | Default | Validator |
|---|---|---|---|
showModel |
boolean | true |
type check |
showProject |
boolean | true |
type check |
showContextBar |
boolean | true |
type check |
contextValue |
'percent' | 'tokens' | 'remaining' | 'both' |
'percent' |
validateContextValue |
showConfigCounts |
boolean | false |
type check |
showCost |
boolean | false |
type check |
showDuration |
boolean | false |
type check |
showSpeed |
boolean | false |
type check |
showTokenBreakdown |
boolean | true |
type check |
showUsage |
boolean | true |
type check |
usageBarEnabled |
boolean | true |
type check |
showTools |
boolean | false |
type check |
showAgents |
boolean | false |
type check |
showTodos |
boolean | false |
type check |
showSessionName |
boolean | false |
type check |
showClaudeCodeVersion |
boolean | false |
type check |
showMemoryUsage |
boolean | false |
type check (rendered only in expanded layout) |
showSessionTokens |
boolean | false |
type check |
showOutputStyle |
boolean | false |
type check |
autocompactBuffer |
'enabled' | 'disabled' |
'enabled' |
validateAutocompactBuffer |
usageThreshold |
number 0-100 | 0 |
validateThreshold (clamps to [0,100]) |
sevenDayThreshold |
number 0-100 | 80 |
validateThreshold |
environmentThreshold |
number 0-100 | 0 |
validateThreshold |
modelFormat |
'full' | 'compact' | 'short' |
'full' |
validateModelFormat |
modelOverride |
string ≤80 chars | '' |
type check + slice(0, 80) |
customLine |
string ≤80 chars | '' |
type check + slice(0, 80) |
colors
Each value is a HudColorValue: a HudColorName (dim, red, green,
yellow, magenta, cyan, brightBlue, brightMagenta), an integer
0-255, or #rrggbb. Anything else falls back to the default.
| Key | Default | Validator |
|---|---|---|
context |
green |
validateColorValue |
usage |
brightBlue |
validateColorValue |
warning |
yellow |
validateColorValue |
usageWarning |
brightMagenta |
validateColorValue |
critical |
red |
validateColorValue |
model |
cyan |
validateColorValue |
project |
yellow |
validateColorValue |
git |
magenta |
validateColorValue |
gitBranch |
cyan |
validateColorValue |
label |
dim |
validateColorValue |
custom |
208 (Claude orange) |
validateColorValue |
Important
mergeConfig()is lenient by design: every invalid value falls back to the default rather than raising. There is no way to tell "config saved but values were rejected" from the HUD itself — setDEBUG=claude-hudand run a smoke test if you suspect silent rejection.
Legacy migrations
| Source | Target |
|---|---|
layout: "default" (string) |
lineLayout: "compact", showSeparators: false, drop layout |
layout: "separators" (string) |
lineLayout: "compact", showSeparators: true, drop layout |
layout: { lineLayout, showSeparators, pathLevels } (object) |
Lift each into top level if not already set, drop layout |
migrateConfig() is invoked unconditionally on load.
4. Deployment & Scaling
claude-hud runs as a per-prompt subprocess. It has no notion of
"deployment" in the cluster sense — but operators wiring it into shared
dev images, devcontainers, or CI smoke tests should know how the install
shape behaves.
Production deployment shape
Developer machine
└── ~/.claude/plugins/claude-hud/ (plugin install root)
├── dist/index.js (compiled entrypoint)
├── src/ (TypeScript sources, shipped)
├── commands/configure.md (slash command source)
├── commands/setup.md (slash command source)
├── .claude-plugin/ (plugin manifest)
├── config.json (USER WRITES THIS)
├── config-cache/<sha256>.json (auto)
├── transcript-cache/<sha256>.json (auto)
├── .claude-code-version-cache.json (auto)
└── .speed-cache.json (auto)
The package.json files field intentionally publishes dist/, src/,
commands/, and .claude-plugin/ so users can read sources directly from
their plugin install — useful both for transparency and for tweaking
commands/setup.md locally.
Devcontainer / dotfiles automation
Because the marketplace install requires a Claude Code session, fully unattended provisioning isn't practical. Closest patterns:
- Pre-bake the install — run
/plugin marketplace addand/plugin installonce interactively, commit the resulting~/.claude/plugins/claude-hud/tree to your dotfiles, and restore it on boot./claude-hud:setupstill has to run interactively to write~/.claude/settings.json. - Pre-bake
settings.json— write thestatusLine.commandentry yourself in your dotfiles/devcontainer Dockerfile, pointing at~/.claude/plugins/claude-hud/dist/index.js, and skip/claude-hud:setupentirely. The downside: you lose the dynamic version resolution that/claude-hud:setupwrites.
Tip
If you go the pre-baked-
settings.jsonroute, prependCOLUMNS=$(tput cols)to the command — see issue #416. Without it you'll hit theUNKNOWN_TERMINAL_WIDTH = 40fallback every time Claude Code spawns the subprocess.
Scaling behaviour
claude-hud runs once per Claude Code prompt. There's nothing to scale
in the traditional sense, but performance characteristics matter because
the subprocess executes synchronously inside Claude Code's prompt cycle:
- Cold path (no caches): one Node startup, one transcript JSONL
read, several git subprocesses, optional
claude --version, optionalvm_stat. Typical latency: tens to a couple hundred milliseconds on modern hardware. - Warm path (caches hit): all of the above short-circuit immediately; only the stdin read, the transcript mtime check, and the always-fresh rendering happen. Typical latency: a few milliseconds.
Note
Because Claude Code spawns the statusline subprocess on every prompt, Node.js startup time (typically 30-80 ms) dominates total latency on small projects. There's no way to avoid this without changing Claude Code's plugin protocol — the entire plugin model is "spawn fresh".
Multi-user systems
Each user has their own ~/.claude/plugins/claude-hud/. Caches are
per-user. There's no cross-user state; nothing to coordinate.
Disabling without uninstalling
Set ~/.claude/settings.json to remove or comment out the statusLine
entry. The plugin remains on disk but never executes.
5. Operations
Health check / smoke test
Run the canonical stdin smoke test from anywhere:
echo '{"model":{"display_name":"Opus"},"context_window":{"current_usage":{"input_tokens":45000},"context_window_size":200000},"transcript_path":"/tmp/test.jsonl"}' \
| node ~/.claude/plugins/claude-hud/dist/index.js
Expected: a single colourised line (default-config compact would be the session line). Non-zero exit code or missing output indicates a broken install.
The plugin source ships the same smoke test as npm run test:stdin in
package.json.
Debug telemetry
There is no metrics endpoint, no log file, no remote telemetry. The only
runtime observability is DEBUG=claude-hud (or DEBUG=*) on stderr.
Namespaces in use:
| Namespace | Source | Meaning |
|---|---|---|
[claude-hud:config] |
src/config-reader.ts |
A settings.json/.mcp.json read or parse failed; the count silently fell to zero. |
[claude-hud:context] |
src/render/session-line.ts, src/render/lines/identity.ts |
Active when autocompactBuffer === "disabled"; reports raw vs buffered percent. |
[claude-hud:extra-cmd] |
src/extra-cmd.ts |
--extra-cmd parse / execution failures (timeout, JSON parse error, missing label). |
Note
Stderr is not captured by Claude Code's statusline pipe — it goes to the parent terminal. That's deliberate (you can keep
DEBUG=claude-hudon without breaking the HUD output) but means debug logs aren't preserved across sessions. Pipe stderr to a file in thestatusLine.commandif you need durable logs.
Routine operations
| Task | How |
|---|---|
| Upgrade plugin | /plugin marketplace update then /plugin install claude-hud |
| Force version recheck | rm ~/.claude/plugins/claude-hud/.claude-code-version-cache.json |
| Force config recount | rm -rf ~/.claude/plugins/claude-hud/config-cache |
| Force transcript reparse | rm -rf ~/.claude/plugins/claude-hud/transcript-cache |
| Reset config | rm ~/.claude/plugins/claude-hud/config.json |
| Disable temporarily | Comment out statusLine in ~/.claude/settings.json |
| Permanently uninstall | /plugin uninstall claude-hud then remove statusLine |
Operational checklist
- [ ] Plugin installed via
/plugin install claude-hud - [ ]
/claude-hud:setupcompleted (orstatusLineentry written manually) - [ ]
node ~/.claude/plugins/claude-hud/dist/index.js < smoke.jsonreturns a styled line - [ ]
claude --versionreturns a number when run from your shell - [ ] Terminal supports UTF-8 and ANSI colour
- [ ] If running over a pipe (statusline subprocess),
COLUMNS=$(tput cols)is prepended insettings.json(issue #416) - [ ] On macOS, Claude Code has been fully restarted post-install (issue #375)
Common operational signals
| Symptom | Most likely cause | Remediation |
|---|---|---|
| HUD blank | Missing statusLine entry |
Re-run /claude-hud:setup |
| HUD wraps | Terminal width unknown | Prepend COLUMNS=$(tput cols) (issue #416/#436) |
CC v... shows old version |
Version cache hit despite update | Touch the binary or delete the version cache (issue #429) |
| Cost line missing | Model not in pricing table | Wait for upstream fix (issue #452) |
| Compact looks like expanded | v0.0.12 regression | Pin v0.0.11 or use expanded (issues #408/#412) |
style: doesn't appear |
outputStyle not set in any settings.json |
Set in user or project settings.json |
6. Performance
claude-hud is engineered to never noticeably slow Claude Code's prompt
cycle, even on large projects.
Performance budget (per prompt)
| Phase | Cold | Warm | Notes |
|---|---|---|---|
| Node.js startup | ~30-80 ms | ~30-80 ms | Unavoidable; Claude Code spawns fresh. |
readStdin |
≤30 ms idle timeout | same | First-byte timeout 250 ms (Claude Code is much faster in practice). |
parseTranscript |
linear in JSONL size; bounded by file mtime cache | ~1 ms | Streamed via readline.createInterface. |
countConfigs |
O(rules markdown files + a few stat()s) | ~1 ms (sentinel match) | mtime-keyed cache. |
loadConfig |
one JSON read + validate | same | Tiny file. |
getGitStatus |
up to 5 git fork-execs, each capped at 1-2 s | same | Returns null if cwd is missing. |
getClaudeCodeVersion |
one fork-exec capped at 2 s | ~1 ms | Cached. |
getMemoryUsage (macOS) |
one vm_stat fork-exec capped at 5 s |
same | No cache. |
| Render | linear in line count, with grapheme + ANSI awareness | same | Bounded — element order ≤8, activity items ≤(2 running tools + 4 frequency + 3 agents + 1 todo). |
Caching strategy
The four mtime-keyed caches (transcript, config, version, speed) are the
heart of warm-path performance. Cache writes are best-effort
(try/catch swallowed) and cache reads validate every key field
before trusting the entry.
// From src/transcript.ts: cache invalidation logic
if (
parsed.transcriptPath !== path.resolve(transcriptPath)
|| parsed.transcriptState?.mtimeMs !== state.mtimeMs
|| parsed.transcriptState?.size !== state.size
) {
return null;
}
The config-counts cache is more elaborate — it sentinels every input
file (CLAUDE.md, settings.json, settings.local.json, .mcp.json,
~/.claude.json) AND the entire rules/ directory tree. A sentinel is
{mtimeMs, size}; when any sentinel changes, the cache is rebuilt.
Tip
The cache is stored under
~/.claude/plugins/claude-hud/config-cache/, which lives under the same~/.claude/directory whose mtime would otherwise invalidate the cache itself. The implementation deliberately sentinels~/.claude/CLAUDE.mddirectly, not the parent directory, to avoid a self-invalidation loop.
Width calculation
The renderer's grapheme-aware width logic (in src/render/index.ts)
handles:
- ANSI escape sequences (CSI + OSC) — width 0
- Combining marks, ZWJ, variation selectors — width 0
- Extended pictographic glyphs (most emoji) — width 2
- East Asian wide characters (CJK, Hangul, fullwidth forms) — width 2
- Everything else — width 1
It uses Intl.Segmenter when available (Node 16+), falling back to
Array.from() otherwise. The wrapper function (wrapLineToWidth)
prefers | and │ separators as wrap points and keeps the leading
[model | provider] block together so the badge isn't split.
Resource limits
| Resource | Limit | Source |
|---|---|---|
| Stdin total bytes | 256 KB | DEFAULT_MAX_STDIN_BYTES |
| Stdin first-byte timeout | 250 ms | DEFAULT_FIRST_BYTE_TIMEOUT_MS |
| Stdin idle timeout | 30 ms | DEFAULT_IDLE_TIMEOUT_MS |
| Git subprocess timeout | 1 s (most), 2 s (numstat) | hardcoded in getGitStatus() |
claude --version timeout |
2 s | hardcoded in getClaudeCodeVersion() |
vm_stat timeout |
5 s | hardcoded in readMacOSMemory() |
--extra-cmd timeout |
3 s | TIMEOUT_MS |
--extra-cmd stdout cap |
10 KB | MAX_BUFFER |
--extra-cmd label length |
50 chars | MAX_LABEL_LENGTH |
| Transcript tools shown | last 20 | transcript.ts |
| Transcript agents shown | last 10 | transcript.ts |
| Project label segments | 1, 2, or 3 | pathLevels |
modelOverride length |
80 chars | mergeConfig() |
customLine length |
80 chars | mergeConfig() |
Note
The 256 KB stdin cap is more than enough for Claude Code's status payload (typically <10 KB). If Claude Code ever ships a much larger payload, the cap would need raising — currently
claude-hudwould refuse to parse and return null silently.
7. Security
claude-hud is a local-only tool, but it lives at the intersection of
Claude Code transcripts (which can contain anything you typed or the
model output, including credentials, source code, customer data, PII,
internal docs) and the user's shell. Treat it as highly trusted local
software and read this section before deploying it to anyone but
yourself.
Threat model
| Threat | Severity | Mitigation |
|---|---|---|
| Transcript content leaking via the HUD | High | The HUD itself prints summarised metadata only — no transcript prose, no tool input/output, no model response. |
| Transcript content leaking via caches | High | Caches store only the parsed summary (tool names, agent counts, todo titles, token totals) — never the raw transcript text. Still: the cache files live alongside everything else under ~/.claude/, which deserves filesystem permissions hardening. |
Malicious --extra-cmd running as the user |
High | --extra-cmd is treated as user input by design; the comment in src/extra-cmd.ts is explicit about this. The mitigation is "don't paste an --extra-cmd somebody else gave you". Output is sanitised (ANSI, control chars, bidi) before display. |
| Malicious project triggering arbitrary git commands | Low | Only git rev-parse, git status, git diff --numstat, git rev-list, git remote get-url are run, all with --no-optional-locks for the porcelain status. None of them execute hooks. |
| Terminal-escape injection from filenames or branch names | Medium | The renderer's wrapping/truncation preserves ANSI escapes the renderer itself produced, but does not sanitise external strings entering it (branch names, filenames, custom titles). A maliciously named branch could in theory contain ANSI escapes; in practice git rejects most of these. Treat untrusted repos with the usual care. |
| Stdin payload too large / malformed | Low | 256 KB cap, JSON parse wrapped in try. Failures return null and the HUD skips rendering. |
| Cache file tampering | Low | All caches are validated on read (type checks for every field). Unrecognised content returns null and the cache is rebuilt. The version cache could be poisoned to lie about the Claude Code version, but the consequence is purely cosmetic. |
Warning
The Claude Code transcript JSONL file (
~/.claude/transcripts/...) is the most sensitive piece of data on the machine — it contains the verbatim text of every prompt, every tool call, every model response.claude-hudreads this file. If you are in any environment where transcript content must be protected (regulated industries, customer data, secrets in transit), the whole Claude Code installation needs handling —claude-hudis one of many tools touching it.
Network behaviour
claude-hud makes zero outbound network calls. It does not phone
home, does not send telemetry, does not download anything at runtime.
The only subprocesses it spawns are local (git, claude, vm_stat,
cmd.exe on Windows), plus the user's --extra-cmd if configured.
Auditors can confirm with lsof or strace.
Recommended posture
| Use | Recommendation |
|---|---|
| Personal dev machine | Default install. No additional hardening needed. |
| Shared lab machine / multi-user host | Use CLAUDE_CONFIG_DIR to isolate per-user. Ensure ~/.claude is mode 0700 to keep transcripts and caches per-user-readable. |
| CI runner | Don't install. Statusline plugins offer no value in non-interactive contexts. |
| Devcontainer for an external user | OK to pre-install, but never bake an --extra-cmd you didn't author into settings.json. |
| Pair-programming / screen-share | Be aware the HUD displays the cwd path, the git branch, and (if enabled) session token totals. Those may leak project identity even if you keep the model response private. |
Logging policy
claude-hud writes to two places only:
- stdout — the HUD output Claude Code displays.
- stderr — debug messages when
DEBUG=claude-hudis set.
It never writes to a third-party log aggregator or external service. Never wire stderr to a third-party log shipper without auditing — debug logs may include cwd paths, MCP server names, configuration counts, and similar metadata.
Caution
If you must enable
--extra-cmd, treat the value as code — review exactly what it does. Anything you put after--extra-cmdruns as your user, with your permissions, on every Claude Code prompt. A 3-second cap and 10 KB cap are the only built-in guardrails.
Secrets handling
claude-hud stores no secrets. It does not read API keys. It does not
read .env files. It does not parse environment variables for tokens.
The only env vars consulted are listed in Section 3 (none secret).
CVE / disclosure
The repo includes a SECURITY.md for vulnerability reporting. Use that
file's contact path rather than filing a public issue.
8. Developer Internals
This section is for contributors and operators forking the plugin.
Repository layout
claude-hud/
├── .editorconfig
├── .gitignore
├── .claude-plugin/ (Claude Code plugin manifest, shipped to users)
├── CHANGELOG.md
├── CLAUDE.README.md (notes on running Claude Code against this repo)
├── CLAUDE.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE (MIT)
├── MAINTAINERS.md
├── README.md
├── RELEASING.md
├── SECURITY.md
├── SUPPORT.md
├── TESTING.md
├── claude-hud-preview-*.png
├── package.json
├── package-lock.json
├── tsconfig.json
├── commands/
│ ├── configure.md (slash command source: /claude-hud:configure)
│ └── setup.md (slash command source: /claude-hud:setup)
├── src/
│ ├── claude-config-dir.ts
│ ├── config-reader.ts
│ ├── config.ts
│ ├── constants.ts
│ ├── cost.ts
│ ├── debug.ts
│ ├── extra-cmd.ts
│ ├── git.ts
│ ├── index.ts (entry point)
│ ├── memory.ts
│ ├── speed-tracker.ts
│ ├── stdin.ts
│ ├── transcript.ts
│ ├── types.ts
│ ├── version.ts
│ ├── i18n/
│ │ ├── en.ts
│ │ ├── index.ts
│ │ ├── types.ts
│ │ └── zh.ts
│ ├── render/
│ │ ├── agents-line.ts
│ │ ├── colors.ts
│ │ ├── index.ts
│ │ ├── session-line.ts
│ │ ├── todos-line.ts
│ │ ├── tools-line.ts
│ │ └── lines/
│ │ ├── cost.ts
│ │ ├── environment.ts
│ │ ├── identity.ts
│ │ ├── index.ts
│ │ ├── memory.ts
│ │ ├── project.ts
│ │ ├── session-tokens.ts
│ │ └── usage.ts
│ └── utils/
│ └── terminal.ts
└── tests/
├── *.test.js (14 test files)
└── fixtures/
├── transcript-basic.jsonl
├── transcript-render.jsonl
└── expected/
└── render-basic.txt
Build, test, develop
# Install dev dependencies
npm install
# One-shot build
npm run build # tsc
# Watch mode while editing
npm run dev # tsc --watch
# Run tests against compiled dist/
npm test # builds, then node --test
# Run with coverage
npm run test:coverage # c8 + node --test
# Manual stdin smoke test (uses default config)
npm run test:stdin
The prepare script is intentionally absent — the plugin marketplace
ships the prebuilt dist/ directly. CI / publishing pipelines should
run tsc before tagging releases.
Module reference (every HIGH-priority module)
src/index.ts — driver
The main() function takes Partial<MainDeps> for testing. MainDeps
covers every external function (readStdin, parseTranscript, etc.)
plus now: () => number and log: (...args: unknown[]) => void. The
default deps are the real implementations. Tests override one or more
deps to assert specific paths.
The "TTY guard" at the bottom of the file:
const scriptPath = fileURLToPath(import.meta.url);
const argvPath = process.argv[1];
const isSamePath = (a: string, b: string): boolean => {
try {
return realpathSync(a) === realpathSync(b);
} catch {
return a === b;
}
};
if (argvPath && isSamePath(argvPath, scriptPath)) {
void main();
}
ensures main() only runs when the file is the entry point — it is safe
to import main and formatSessionDuration from tests without
triggering a side-effecting render.
src/stdin.ts — JSON ingestion
Reads stdin with three timeouts (first byte, idle, total bytes). Idle timeout is the unusual one: it allows Claude Code to dribble JSON in multiple chunks, but parses as soon as the JSON is complete. If parsing succeeds, it short-circuits without waiting for stream end.
Native context percent (Claude Code 2.1.6+) wins over manual calculation
in both getContextPercent and getBufferedPercent. The Bedrock
provider is detected by model id substring anthropic.claude-; a friendly
display label like Claude Sonnet 4 is reconstructed via
normalizeBedrockModelLabel.
src/transcript.ts — JSONL streaming parse
Streams the JSONL file via readline, accumulates session token totals
across all assistant messages (message.usage). Tool / agent / todo
state is tracked via Maps keyed by tool-use id. The
TodoWrite/TaskCreate/TaskUpdate flow is non-trivial:
TodoWriterewrites the entire todo list. To preserve in-progress state when items are reordered, the parser builds a content-based reverse map (content -> task ids) before clearing, then re-establishes task-id-to-index mappings for items whose content matches.TaskCreateappends a todo with the supplied subject/description and registers eitherinput.taskIdor the block id as the lookup key.TaskUpdateresolves the index via the lookup map, with a fallback for numeric-string task ids that act as 1-based indices.
Issue #454 documents a duplicate-content edge case in this logic.
src/config-reader.ts — countConfigs()
The most complex single function in the codebase. Walks user scope and
project scope, dedupes scopes that overlap (when cwd/.claude resolves
to ~/.claude), counts CLAUDE.md files at multiple known paths,
recursively counts rules markdown, computes MCP server set differences,
applies disabled MCP filters, and writes a sentinel-keyed cache.
The cross-platform path comparison (pathsReferToSameLocation) tries:
- Normalise both paths (resolve, lowercase on Windows).
- If equal, return true.
- Otherwise, try
realpathon both and compare the results.
This catches symlink chains and \\ vs / Windows path variants.
src/git.ts — getGitStatus()
Executes 5 git subprocesses in sequence, each with execFile (no shell
interpolation). Tolerates missing remotes, no upstream, binary diffs
(skipped via Number.isNaN(added) || Number.isNaN(deleted) check), and
renames (old -> new parsed from porcelain). Returns null for any
non-git directory.
src/render/index.ts — width-aware rendering
Three responsibilities:
- Dispatch on
lineLayoutto eitherrenderExpandedorrenderCompact. - Run the grapheme-aware visual-length and wrapping logic.
- Emit
${RESET}${line}toconsole.logfor each visible line.
The terminal width resolution chain is process.stdout.columns -> process.stderr.columns -> process.env.COLUMNS -> UNKNOWN_TERMINAL_WIDTH (40). Stderr is the secret weapon — when Claude Code pipes stdout, the
real terminal width is still readable from stderr.
src/render/lines/* — eight one-job renderers
Every line renderer takes RenderContext and returns string | null.
They never throw, never share state, never mutate the context. Each is
≤200 lines. This makes them trivially unit-testable: pass a synthetic
context, assert on the output string.
The compact-layout session-line.ts is the exception — it's a much
larger renderer that combines what would otherwise be 8 lines into one,
in a specific order, with optional separators. Its complexity is driven
by feature-toggle interactions rather than logic depth.
src/render/colors.ts — ANSI factory
Maintains the named-preset table, the hex-to-truecolor encoder, and
threshold-aware colour pickers (getContextColor, getQuotaColor)
that drive the bar styling. coloredBar and quotaBar are the bar
generators — both clamp percentage to [0,100] and width to non-negative
integers, then emit █ for filled cells and dim ░ for empty.
Code patterns and conventions
- Pure functions everywhere except the driver and the renderer's I/O.
No classes, no global mutable state outside
version.ts's in-memory cache fields. - Explicit return-null-on-failure. Every probe returns
null(orundefined) on any error rather than throwing. The driver and the renderers all handle null gracefully. - Lenient validators.
mergeConfig()clamps invalid values to the default rather than rejecting. Bias is toward "render something" over "fail loudly" — a HUD that crashes is worse than a HUD that shows defaults. - Test seams via dependency injection. Every external call is
injectable. The compiled JS in
dist/is what tests import — this catches type → JS gaps. Tests live intests/*.test.jsand use Node's built-innode:test. - mtime + size sentinels for caches. Every cache validates by
re-stat'ing inputs; never by TTL alone. Trades a few extra
statsyscalls for instant invalidation. - No bundler.
tsccompiles to ESMdist/, period. Imports use.jsextensions in source (Node ESM convention) so the same paths work pre- and post-compile.
Testing strategy
The test suite is comprehensive for its size. 14 test files cover:
index.test.js— driver, with happy paths, error paths, dependency injection, and entry-point isolation.config.test.js—mergeConfigvalidation, migration, color value acceptance.core.test.js— small primitives.git.test.js— porcelain parsing, numstat parsing, file stats, rename handling.i18n.test.js— locale lookup with fallback chain.memory.test.js—vm_statparsing, formatBytes, dependency injection ofMemoryReader.render.test.js— full render output across layouts and toggles.render-width.test.js— grapheme + ANSI width edge cases (emoji, CJK, ZWJ, control chars, OSC sequences).speed-tracker.test.js— sliding-window math.stdin.test.js— timeout / parse / size / TTY behaviours.terminal.test.js— adaptive bar width.version.test.js— binary path resolution,.cmd/.batinvocation, cache key behaviour.extra-cmd.test.js— argv parsing, sanitisation, JSON parse, label truncation.integration.test.js— end-to-end render against fixture transcripts.
The fixture files live at tests/fixtures/transcript-basic.jsonl,
tests/fixtures/transcript-render.jsonl, and the expected output is
captured in tests/fixtures/expected/render-basic.txt. Update fixtures
with npm run test:update-snapshots.
Potential improvements
These are ideas surfaced by the open issues and reading the source — not endorsed roadmap items.
- Pricing-table autoupdate (issue #452). Move the model→pricing
table out of
src/cost.tsand either fetch it on first run or expose a config-level override map. - Compact-layout regression (issues #408, #412). v0.0.12's
compact rendering diverges from v0.0.11; trace the regression to
the
session-line.tsrewrite. COLUMNSinjection in setup (issue #416). Updatecommands/setup.mdto always prependCOLUMNS=$(tput cols)to the writtenstatusLine.command. Also worth detecting at runtime when the fallback fires and warning to stderr.- Cache eviction. Add a soft cap (e.g. 100 entries) to
transcript-cache/andconfig-cache/with LRU eviction. - Codex-hud (issue #215). Refactor
src/stdin.tstypes into a pluggable adapter so other Claude-Code-style tools can be wired up. - Native-vs-cache for
claude --version(issue #429). Hash the Claude Code binary content (or its--versionoutput) instead of trusting mtime alone.
Contributing
The repo includes CONTRIBUTING.md, MAINTAINERS.md, and RELEASING.md.
The project is MIT-licensed and accepts external PRs. Test coverage and
type strictness are non-negotiable — every PR runs the full test suite
via Node's built-in test runner.
Note
Type changes that touch
src/types.ts(especiallyStdinData) require updating both the i18n message catalog (src/i18n/types.ts) and the renderer expectations. The compiler will catch most of this, but the render fixtures need a manual snapshot update.
9. Integration Points
Claude Code (the primary integration)
claude-hud consumes JSON from Claude Code's statusline subprocess
contract. The shape it cares about (a subset of what Claude Code emits)
lives in src/types.ts's StdinData interface:
interface StdinData {
transcript_path?: string; // Path to the JSONL transcript
cwd?: string; // Working directory of the session
model?: {
id?: string; // e.g. "claude-opus-4-6"
display_name?: string; // e.g. "Opus 4.6 (1M context)"
};
context_window?: {
context_window_size?: number;
current_usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
} | null;
used_percentage?: number | null; // Claude Code 2.1.6+
remaining_percentage?: number | null; // Claude Code 2.1.6+ (unused but typed)
};
cost?: {
total_cost_usd?: number | null;
total_duration_ms?: number | null; // typed but unused by HUD
total_api_duration_ms?: number | null; // typed but unused by HUD
total_lines_added?: number | null; // typed but unused by HUD
total_lines_removed?: number | null; // typed but unused by HUD
} | null;
rate_limits?: {
five_hour?: { used_percentage?: number | null; resets_at?: number | null } | null;
seven_day?: { used_percentage?: number | null; resets_at?: number | null } | null;
} | null;
}
Every field is optional and every probe handles missing values gracefully.
Claude Code transcript JSONL
Read by parseTranscript() line-by-line. The shape it consumes:
interface TranscriptLine {
timestamp?: string; // ISO 8601
type?: string; // 'assistant', 'custom-title', etc.
slug?: string; // Auto-generated session slug
customTitle?: string; // /rename custom title
message?: {
content?: ContentBlock[];
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
};
};
}
interface ContentBlock {
type: string; // 'tool_use' | 'tool_result' | text
id?: string;
name?: string; // e.g. 'Read', 'Bash', 'TodoWrite', 'Task'
input?: Record<string, unknown>;
tool_use_id?: string;
is_error?: boolean;
}
Special-case tool names: Task/Agent populate the agents map;
TodoWrite/TaskCreate/TaskUpdate drive todos; Read/Write/Edit
extract file_path or path; Glob/Grep extract pattern; Bash
extracts the first 30 chars of command. Everything else lands in the
generic tools map.
Claude Code settings.json
Read indirectly through countConfigs() to count MCPs and hooks. The
relevant fields:
{
"mcpServers": {
"<name>": { /* opaque config */ }
},
"hooks": {
"<name>": { /* opaque config */ }
},
"outputStyle": "tech-leader",
"disabledMcpServers": ["names"],
"disabledMcpjsonServers": ["names"]
}
Where the parser looks:
| File | Role |
|---|---|
~/.claude/settings.json |
User-scope MCPs, hooks, output style |
~/.claude/settings.local.json |
User-scope local hooks, output style |
~/.claude.json |
User-scope MCPs + disabledMcpServers |
<cwd>/.mcp.json |
Project MCPs (gated by disabledMcpjsonServers) |
<cwd>/.claude/settings.json |
Project hooks, output style |
<cwd>/.claude/settings.local.json |
Project local hooks, output style + disabledMcpjsonServers |
Git CLI
| Subprocess | Why |
|---|---|
git rev-parse --abbrev-ref HEAD |
Branch name |
git --no-optional-locks status --porcelain |
Dirty / file stats |
git diff --numstat HEAD |
Per-file line diffs |
git rev-list --left-right --count @{upstream}...HEAD |
Ahead/behind |
git remote get-url origin |
Build branch URL for OSC-8 hyperlink |
All run via execFile (no shell). Timeouts: 1s for the first three,
2s for numstat, 1s for the last two.
claude CLI
getClaudeCodeVersion() runs claude --version (or cmd.exe /d /s /c "<path> --version" on Windows for .cmd/.bat shims) with a 2-second
timeout. Output is parsed by _parseClaudeCodeVersion's regex:
/\d+(?:\.\d+)+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/. The result
is cached on disk keyed by binary path + mtime. claude must be
on PATH and executable; on Windows it tries every PATHEXT extension.
macOS vm_stat
getMemoryUsage() runs /usr/bin/vm_stat (5-second timeout, encoding
utf8) and parses Pages active, Pages wired down, and page size of N bytes to compute used memory. Falls back to os.totalmem() /
os.freemem() on parse failure. Other platforms use the os module
directly.
OSC-8 hyperlinks
Both the project label and the git branch (when GitHub remote exists) are wrapped in OSC-8 escape sequences:
ESC ] 8 ; ; <uri> ESC \ <visible text> ESC ] 8 ; ; ESC \
In supporting terminals (iTerm2, modern Windows Terminal, kitty, recent
gnome-terminal), this makes the project name a clickable
file://<cwd> link and the branch name a clickable
https://github.com/<owner>/<repo>/tree/<branch> link.
--extra-cmd user integration
The single user-visible CLI flag. The contract:
- Parsed from
process.argvbyparseExtraCmdArg()— supports both--extra-cmd valueand--extra-cmd=value. - Executed via
child_process.execwith a 3-second timeout and 10 KB stdout cap. - Output must be JSON of the shape
{ "label": "string" }. Any other shape returns null. - The
labelvalue is sanitised (CSI/OSC/control/bidi stripped) and truncated to 50 chars before display.
Important
--extra-cmdruns through the user's shell. The repo comments explicitly note this is intentional user input — shell injection is not a concern only because the user types their own command. Never accept a--extra-cmdvalue from an untrusted source.
Integration table (one-liner summary)
| Surface | Direction | Description |
|---|---|---|
| Claude Code stdin | in | JSON status payload, max 256 KB |
| Claude Code transcript JSONL | in | Read line-by-line, mtime-cached |
| Claude Code settings.json / .mcp.json / settings.local.json / ~/.claude.json | in | Counts only |
claude --version subprocess |
in | Cached version string |
git subprocesses |
in | Branch / dirty / ahead-behind / file stats |
vm_stat subprocess (macOS) |
in | Memory usage |
--extra-cmd user subprocess |
in | Optional {label} JSON |
| Claude Code statusline (stdout) | out | Rendered HUD lines |
~/.claude/plugins/claude-hud/*-cache/ |
out | Mtime-keyed caches |
| stderr | out | DEBUG-gated trace |
There are no other external integrations — no HTTP, no IPC, no inter-process state, no telemetry.