CC version pins forever when PATH's `claude` is a wrapper script — the cache keys on the launcher, but stdin already carries the authoritative version

Author: GodCC6Created Sep 5, 2026Updated Sep 5, 2026

Summary

CC v… pins to whatever version was current the first time the HUD ran, and never moves again, when the claude on PATH is a launcher script (a cmux / mise / asdf shim, or a corporate wrapper) instead of a symlink into versions/<ver>.

This is the sibling case of #429. That fix keyed cache invalidation on the launcher's resolved path and mtime, which correctly catches the native installer (~/.local/bin/claude is a symlink, so an upgrade repoints it to a new real path). But when the launcher is a script, realpathSync stops at the script itself, and every signal the cache keys on is a property of the launcher, not of the binary it execs. The wrapper's path and mtime never change across a Claude Code upgrade, so getClaudeCodeVersion() can never invalidate — by construction, not by accident.

I sat on a stale CC v2.1.251 for four consecutive upgrades before noticing.

There is a much better source available, and it needs no subprocess at all: Claude Code already sends its own version on stdin. The documented statusLine payload has a top-level version (docs show "version": "2.1.90"), and it is present in the shipped binary — from 2.1.261's statusline payload builder:

javascript
version: { ISSUES_EXPLAINER: …, VERSION: "2.1.261", … }.VERSION,

That value is authoritative in a way claude --version is not: it comes from the process that actually asked for this render, so it is right regardless of how claude was launched — and it is correct even when the binary being run isn't the one PATH would resolve to. The HUD currently ignores it and spawns a ~199 MB binary with a 2 s timeout to re-derive it.

Steps to Reproduce

claude on PATH must be a wrapper script whose own mtime is stable:

bash
mkdir -p /tmp/shimdir && cat > /tmp/shimdir/claude <<'SH'
#!/bin/sh
echo "2.1.261 (Claude Code)"
SH
chmod +x /tmp/shimdir/claude
touch -t 202608301853 /tmp/shimdir/claude    # wrapper is never touched by a CC upgrade

Seed the on-disk cache the way it looks after the HUD first ran under an older Claude Code — same path, same mtime, older version:

json
{"resolvedFromPath":"/tmp/shimdir/claude","binaryPath":"/tmp/shimdir/claude",
 "binaryMtimeMs":<mtime of that file>,"version":"2.1.258"}

Then render with a payload that says "version": "2.1.261":

bash
echo '{"session_id":"e","cwd":"/tmp","version":"2.1.261","model":{"display_name":"Opus 5"},
"context_window":{"context_window_size":200000,"current_usage":{"input_tokens":42000}}}' \
  | env -i HOME=$T CLAUDE_CONFIG_DIR=$T/.claude-alt PATH=/tmp/shimdir:/usr/bin:/bin \
    node dist/index.js

Expected Behavior

CC v2.1.261.

Actual Behavior

CC v2.1.258, for as long as the wrapper file is untouched. Verified on main @ 939eb66.

A unit-level repro against dist/version.js, modelling the fact that each statusline render is a fresh process (so only the disk cache carries over):

javascript
_setResolveClaudeBinaryForTests(() => ({ path: wrapperPath, mtimeMs: FIXED }));   // launcher never changes
_setExecFileImplForTests(async () => ({ stdout: n++ ? '2.1.261 …' : '2.1.258 …' }));

await getClaudeCodeVersion();   // '2.1.258'
_resetVersionCache();           // next render = new process
await getClaudeCodeVersion();   // '2.1.258'  ← expected '2.1.261'

Suggested Fix

Prefer the stdin value, keep the existing exec + cache path as the fallback for payloads that omit it:

typescript
const claudeCodeVersion = config.display.showClaudeCodeVersion
  ? (resolveStdinClaudeCodeVersion(stdin.version) ?? await deps.getClaudeCodeVersion())
  : undefined;

This lines up with the rule already written into CONTRIBUTING.md"Those need a same-invocation stdin fixture from Claude Code" — by replacing a HUD-side inference with the same-invocation value, and it removes a subprocess spawn from the common path.

Note this deliberately leaves the wrapper limitation in the fallback, which is then only reachable on builds that don't send version. Happy to also add a TTL there if you'd prefer belt-and-braces, but that changes cache semantics so I left it out.

I have this implemented with tests (3 unit + 1 in index.test.js; full suite 1114 pass / 1 fail, the one failure being the pre-existing estimateSessionCost prices Claude 5 point releases like their base model on main). Three-arm check on a fixture where the stale cache genuinely validates:

build stdin version rendered
main 2.1.261 CC v2.1.258 ← bug
patched absent CC v2.1.258 ← fallback unchanged
patched 2.1.261 CC v2.1.261 ← fixed

Glad to open a PR if you want it.

Environment

  • OS: macOS 26.6.2 (Darwin 25.6.0, arm64)
  • Node/Bun version: Bun 1.3.14 (statusline runtime), Node v26.7.0 (tests)
  • Claude Code version: 2.1.261
  • claude-hud: 0.6.0 installed; bug verified on main @ 939eb66 (0.8.0) — src/version.ts is byte-identical between the two