Jesse A. Green / notebook

claude-hud Administrator & Developer Guide

Generated April 2026 from jarrodwatts/claude-hud at 64a8099

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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

  1. Claude Code calls the configured statusLine.command.
  2. The command runs node ~/.claude/plugins/claude-hud/dist/index.js with the JSON of the current session piped to stdin.
  3. readStdin() reads, parses, and returns a StdinData object (or null if stdin is a TTY or empty within the timeouts).
  4. 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.
  5. countConfigs(stdin.cwd) reads or returns from cache.
  6. loadConfig() reads the user's config.json or returns defaults.
  7. getGitStatus(stdin.cwd) runs git subprocesses if gitStatus.enabled.
  8. getUsageFromStdin(stdin) extracts the rate-limit windows.
  9. parseExtraCmdArg() reads --extra-cmd from process.argv; if present, runExtraCmd() executes it.
  10. formatSessionDuration() formats start-time delta.
  11. getClaudeCodeVersion() runs only if display.showClaudeCodeVersion.
  12. getMemoryUsage() runs only if display.showMemoryUsage AND lineLayout === "expanded".
  13. The full RenderContext is handed to render(ctx), which iterates enabled elements in elementOrder, computes line widths with grapheme awareness, wraps to terminal width, and console.log()s each line.
  14. 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/ and transcript-cache/ directories will grow unboundedly. This is generally fine — files are tiny — but rm -rf ~/.claude/plugins/claude-hud/{config,transcript}-cache is 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 — set DEBUG=claude-hud and 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:

  1. Pre-bake the install — run /plugin marketplace add and /plugin install once interactively, commit the resulting ~/.claude/plugins/claude-hud/ tree to your dotfiles, and restore it on boot. /claude-hud:setup still has to run interactively to write ~/.claude/settings.json.
  2. Pre-bake settings.json — write the statusLine.command entry yourself in your dotfiles/devcontainer Dockerfile, pointing at ~/.claude/plugins/claude-hud/dist/index.js, and skip /claude-hud:setup entirely. The downside: you lose the dynamic version resolution that /claude-hud:setup writes.

Tip

If you go the pre-baked-settings.json route, prepend COLUMNS=$(tput cols) to the command — see issue #416. Without it you'll hit the UNKNOWN_TERMINAL_WIDTH = 40 fallback 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, optional vm_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-hud on without breaking the HUD output) but means debug logs aren't preserved across sessions. Pipe stderr to a file in the statusLine.command if 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:setup completed (or statusLine entry written manually)
  • [ ] node ~/.claude/plugins/claude-hud/dist/index.js < smoke.json returns a styled line
  • [ ] claude --version returns 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 in settings.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.md directly, 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-hud would 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-hud reads 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-hud is 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.

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:

  1. stdout — the HUD output Claude Code displays.
  2. stderr — debug messages when DEBUG=claude-hud is 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-cmd runs 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:

  • TodoWrite rewrites 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.
  • TaskCreate appends a todo with the supplied subject/description and registers either input.taskId or the block id as the lookup key.
  • TaskUpdate resolves 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:

  1. Normalise both paths (resolve, lowercase on Windows).
  2. If equal, return true.
  3. Otherwise, try realpath on 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:

  1. Dispatch on lineLayout to either renderExpanded or renderCompact.
  2. Run the grapheme-aware visual-length and wrapping logic.
  3. Emit ${RESET}${line} to console.log for 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 (or undefined) 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 in tests/*.test.js and use Node's built-in node:test.
  • mtime + size sentinels for caches. Every cache validates by re-stat'ing inputs; never by TTL alone. Trades a few extra stat syscalls for instant invalidation.
  • No bundler. tsc compiles to ESM dist/, period. Imports use .js extensions 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 — mergeConfig validation, 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_stat parsing, formatBytes, dependency injection of MemoryReader.
  • 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/.bat invocation, 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.ts and 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.ts rewrite.
  • COLUMNS injection in setup (issue #416). Update commands/setup.md to always prepend COLUMNS=$(tput cols) to the written statusLine.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/ and config-cache/ with LRU eviction.
  • Codex-hud (issue #215). Refactor src/stdin.ts types 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 --version output) 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 (especially StdinData) 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.

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.argv by parseExtraCmdArg() — supports both --extra-cmd value and --extra-cmd=value.
  • Executed via child_process.exec with a 3-second timeout and 10 KB stdout cap.
  • Output must be JSON of the shape { "label": "string" }. Any other shape returns null.
  • The label value is sanitised (CSI/OSC/control/bidi stripped) and truncated to 50 chars before display.

Important

--extra-cmd runs 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-cmd value 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.