Git Detective
node
Bucketed git status counts, ahead-behind arrows, prompt-cache stats with an expiry countdown, and a heads-up when CLAUDE.md, AGENTS.md and GEMINI.md fall out of sync. Dependency-free Node.
MIT licensed · original source
Preview
Clean repo
Op 4.8 │ app (main) │ █░░░░ 44k/200k │ cache 5% ↓2k +5k │ 5h:26%(2h7m) │ 7d:7%(2d)New session
Op 4.8 │ app (main)Dirty branch
So 4.6 │ app (feat/auth) │ 1M 1? │ ██░░░ 96k/200k │ cache 2% ↓2k +5k │ 5h:40%(1h12m) │ 7d:18%(2d)Near-full
Op 4.8 │ app (main) │ 💀 ████▌ 182k/200k │ cache 1% ↓2k +5k │ 5h:88%(18m) │ 7d:61%(20h)1M context
Fable 5 │ app (main) │ ███░░ 640k/1M │ cache 0% ↓2k +5k │ 5h:33%(3h30m) │ 7d:12%(5d)Post-compact
Ha 4.5 │ app (main) │ 5h:52%(4h2m)Worktree
Op 4.8 │ feature (worktree-feature) │ █▌░░░ 74k/200k │ cache 3% ↓2k +5k │ 5h:44%(2h50m) │ 7d:20%(1d7h)Non-git
Op 4.8 │ scratch │ █░░░░ 44k/200k │ cache 5% ↓2k +5kSource
#!/usr/bin/env node
// Claude Code Statusline
// Shows: model | directory | git sync | context usage
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input);
const shortModel = (name) => {
const m = name.match(/^(?:claude-)?(Opus|Sonnet|Haiku|Mythos)[\s-]+(\d+(?:[.-]\d+)?)(?:\s*\(([^)]+)\))?/i);
if (!m) return name;
const family = m[1].charAt(0).toUpperCase() + m[1].charAt(1).toLowerCase();
const version = m[2].replace('-', '.');
const ctx = m[3];
let suffix = `${family} ${version}`;
if (ctx) {
const ctxMatch = ctx.match(/(\d+)\s*([KMG])/i);
if (ctxMatch) suffix += ` (${ctxMatch[1]}${ctxMatch[2].toLowerCase()})`;
}
return suffix;
};
const model = shortModel(data.model?.display_name || 'Claude');
const dir = data.workspace?.current_dir || process.cwd();
const session = data.session_id || '';
const rawRemaining = data.context_window?.remaining_percentage;
const remaining = (rawRemaining == null || rawRemaining === '') ? NaN : Number(rawRemaining);
const homeDir = os.homedir();
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(homeDir, '.claude');
const fmt = (n) => {
if (n < 1000) return String(n);
if (n < 10000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
if (n < 1000000) return Math.round(n / 1000) + 'k';
return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
};
// --- Context window ---
let ctx = '';
if (Number.isFinite(remaining)) {
// Absolute token counts (e.g. "480k/1M"). Numerator is total_input_tokens
// (input + cache_creation + cache_read); denominator is context_window_size.
// When both are present we drive the bar from them directly \u2014 more honest
// than remapping `remaining_percentage` through a hardcoded compact buffer
// that doesn't scale across context window sizes.
const totalInput = Number(data.context_window?.total_input_tokens) || 0;
const ctxSize = Number(data.context_window?.context_window_size) || 0;
const haveAbs = totalInput > 0 && ctxSize > 0;
const used = haveAbs
? Math.max(0, Math.min(100, Math.round((totalInput / ctxSize) * 100)))
: Math.max(0, Math.min(100, Math.round(100 - remaining)));
// Bridge file for context-monitor PostToolUse hook
if (session) {
try {
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${session}.json`);
fs.writeFileSync(bridgePath, JSON.stringify({
session_id: session,
remaining_percentage: remaining,
used_pct: used,
timestamp: Math.floor(Date.now() / 1000)
}));
} catch (e) {}
}
const steps = Math.max(0, Math.min(10, Math.floor(used / 10)));
let bar = '';
for (let i = 0; i < 5; i++) {
const cell = Math.max(0, Math.min(2, steps - i * 2));
bar += cell === 2 ? '\u2588' : cell === 1 ? '\u258c' : '\u2591';
}
const abs = haveAbs ? ` ${fmt(totalInput)}/${fmt(ctxSize)}` : '';
let color, prefix = '';
if (used < 50) color = '\x1b[38;2;255;125;218m';
else if (used < 65) color = '\x1b[33m';
else if (used < 80) color = '\x1b[38;2;255;140;0m';
else { color = '\x1b[31m'; prefix = '\uD83D\uDC80 '; }
// Absolute token cap: 250k is the pricing/quality cliff regardless of how
// wide the context window is. Bump pink \u2192 yellow once we cross it, so a
// 1M session at 250k/1M (25% used) doesn't render as "still cozy pink".
if (haveAbs && totalInput >= 250000 && color === '\x1b[38;2;255;125;218m') {
color = '\x1b[33m';
}
ctx = ` ${color}${prefix}${bar}${abs}\x1b[0m`;
}
// --- Git status (live, local, no network) ---
let gitInfo = '';
let branch = '';
let detachedSha = '';
try {
const gitExec = (args) => {
try {
return execFileSync('git', args, { encoding: 'utf8', cwd: dir, windowsHide: true, timeout: 1000, stdio: ['pipe', 'pipe', 'ignore'] }).trim();
} catch (e) { return null; }
};
const parts = [];
// Uncommitted changes — bucketed by VS Code-style status codes
const status = gitExec(['status', '--porcelain']);
if (status) {
const buckets = { M: 0, A: 0, D: 0, R: 0, '?': 0, '!': 0 };
for (const line of status.split('\n')) {
if (!line) continue;
const X = line.charAt(0);
const Y = line.charAt(1);
if (X === '?' && Y === '?') buckets['?']++;
else if (X === 'U' || Y === 'U' || (X === 'D' && Y === 'D') || (X === 'A' && Y === 'A')) buckets['!']++;
else if (Y === 'D' || X === 'D') buckets.D++;
else if (X === 'R') buckets.R++;
else if (X === 'A' || X === 'C') buckets.A++;
else buckets.M++;
}
const dimBuckets = [];
for (const k of ['M', 'A', 'D', 'R', '?']) {
if (buckets[k] > 0) dimBuckets.push(`${buckets[k]}${k}`);
}
if (dimBuckets.length > 0) parts.push(`\x1b[2m${dimBuckets.join(' ')}\x1b[0m`);
if (buckets['!'] > 0) parts.push(`\x1b[31m${buckets['!']}!\x1b[0m`);
}
// Behind/ahead origin
branch = gitExec(['branch', '--show-current']) || '';
if (!branch) {
detachedSha = gitExec(['rev-parse', '--short', 'HEAD']) || '';
}
if (branch) {
const remoteRef = `refs/remotes/origin/${branch}`;
const behind = parseInt(gitExec(['rev-list', '--count', `HEAD..${remoteRef}`]) || '0', 10);
const ahead = parseInt(gitExec(['rev-list', '--count', `${remoteRef}..HEAD`]) || '0', 10);
if (behind > 0) parts.push(`\x1b[31m\u2193${behind} pull\x1b[0m`);
if (ahead > 0) parts.push(`\x1b[33m\u2191${ahead} push\x1b[0m`);
}
// MD sync check: drift between CLAUDE.md ↔ AGENTS.md ↔ GEMINI.md
try {
const claudeMd = path.join(dir, 'CLAUDE.md');
const agentsMd = path.join(dir, 'AGENTS.md');
const geminiMd = path.join(dir, 'GEMINI.md');
if (fs.existsSync(claudeMd) && fs.existsSync(agentsMd) && fs.existsSync(geminiMd)) {
const SYNC_LINE_RE = /^> \*\*(?:Синхронизация|Sync):\*\*.*$/m;
const norm = (p) => fs.readFileSync(p, 'utf8').replace(/\r\n/g, '\n');
const cs = norm(claudeMd).replace(SYNC_LINE_RE, '');
const as = norm(agentsMd).replace(SYNC_LINE_RE, '');
const gs = norm(geminiMd).replace(SYNC_LINE_RE, '');
if (cs !== as || cs !== gs) {
parts.push('\x1b[31m\u26A0 md drift\x1b[0m');
}
}
} catch (e) {}
if (parts.length > 0) {
gitInfo = ' ' + parts.join(' ');
}
} catch (e) {}
// --- Prompt cache state ---
// Counters come from stdin (current_usage of the latest API call).
// TTL bucket and last-touch timestamp come from the transcript — stdin
// exposes neither ephemeral_1h vs ephemeral_5m nor a per-message timestamp.
let cacheSegment = '';
try {
const usage = data.context_window?.current_usage;
const usageNull = usage === null || usage === undefined;
const read = (usage && Number(usage.cache_read_input_tokens)) || 0;
const write = (usage && Number(usage.cache_creation_input_tokens)) || 0;
const freshInput = (usage && Number(usage.input_tokens)) || 0;
let lastTouchTs = null;
let lastWriteTtl = null;
let hadCacheBefore = false;
// Parse transcript when we need TTL/timestamp OR want to detect a /compact
// reset (current_usage is null but earlier messages had cache activity).
if (session && (read > 0 || write > 0 || usageNull)) {
try {
// Claude Code slug encoding: drive colon, separators, AND dots → '-'
// (e.g. C:\Users\ilyap\.openclaw → C--Users-ilyap--openclaw).
// Without the dot replacement, transcripts inside hidden dirs aren't
// found and the cache TTL/timestamp segment silently drops.
const slug = dir.replace(/[:\\\/.]/g, '-');
const transcriptPath = path.join(claudeDir, 'projects', slug, `${session}.jsonl`);
if (fs.existsSync(transcriptPath)) {
const stat = fs.statSync(transcriptPath);
// Read 1 extra byte before the window so the first \n distinguishes
// a partial line from a clean line boundary.
const desired = Math.min(stat.size, 16384);
const startOffset = Math.max(0, stat.size - desired - 1);
const readBytes = stat.size - startOffset;
const buf = Buffer.alloc(readBytes);
const fd = fs.openSync(transcriptPath, 'r');
fs.readSync(fd, buf, 0, readBytes, startOffset);
fs.closeSync(fd);
let content = buf.toString('utf8');
if (startOffset > 0) {
const nl = content.indexOf('\n');
if (nl >= 0) content = content.slice(nl + 1);
}
const lines = content.split('\n').filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
try {
const rec = JSON.parse(lines[i]);
const u = rec && rec.type === 'assistant' && rec.message ? rec.message.usage : null;
if (!u || !rec.timestamp) continue;
const r = u.cache_read_input_tokens || 0;
const w = u.cache_creation_input_tokens || 0;
if (r === 0 && w === 0) continue;
hadCacheBefore = true;
if (!lastTouchTs) {
const ts = new Date(rec.timestamp).getTime();
lastTouchTs = Number.isFinite(ts) ? ts : 0;
}
if (!lastWriteTtl && w > 0 && u.cache_creation) {
if (u.cache_creation.ephemeral_1h_input_tokens > 0) lastWriteTtl = '1h';
else if (u.cache_creation.ephemeral_5m_input_tokens > 0) lastWriteTtl = '5m';
}
if (lastTouchTs && lastWriteTtl) break;
} catch (e) {}
}
}
} catch (e) {}
}
if (read > 0 || write > 0) {
const parts = ['\x1b[2mcache\x1b[0m'];
// Hit ratio: read / (input + cache_creation + cache_read).
// Matches the input-only formula used for used_percentage.
// ratio === 0 means a full cache miss / invalidation — surface it loudly
// instead of hiding the segment, since it's a costly event worth noticing.
const denom = read + write + freshInput;
if (denom > 0) {
const ratio = read > 0 ? Math.round(read / denom * 100) : 0;
let ratioColor;
if (ratio === 0) ratioColor = '\x1b[1;31m';
else if (ratio >= 90) ratioColor = '\x1b[1;32m';
else if (ratio >= 75) ratioColor = '\x1b[32m';
else if (ratio >= 50) ratioColor = '\x1b[33m';
else ratioColor = '\x1b[38;2;255;140;0m';
parts.push(`${ratioColor}${ratio}%\x1b[0m`);
}
if (read > 0) {
parts.push(`\x1b[1;32m↓${fmt(read)}\x1b[0m`);
}
if (write > 0) {
const sym = read > 0 ? '+' : '↑';
parts.push(`\x1b[33m${sym}${fmt(write)}\x1b[0m`);
}
if (lastWriteTtl && lastTouchTs) {
const ttlMs = lastWriteTtl === '5m' ? 300000 : 3600000;
const remainingSec = (ttlMs - (Date.now() - lastTouchTs)) / 1000;
const bucketColor = lastWriteTtl === '5m' ? '\x1b[33m' : '\x1b[2;36m';
let suffix = `${bucketColor}${lastWriteTtl}\x1b[0m`;
let timeStr;
if (remainingSec <= 0) timeStr = '0m';
else if (remainingSec < 60) timeStr = Math.ceil(remainingSec) + 's';
else if (remainingSec < 3600) timeStr = Math.ceil(remainingSec / 60) + 'm';
else {
const h = Math.floor(remainingSec / 3600);
const m = Math.floor((remainingSec % 3600) / 60);
timeStr = h + 'h' + (m > 0 ? m + 'm' : '');
}
const pct = remainingSec > 0 ? (remainingSec * 1000) / ttlMs : 0;
let countColor;
if (pct <= 0) countColor = '\x1b[31m';
else if (pct < 0.1) countColor = '\x1b[38;2;255;140;0m';
else if (pct < 0.25) countColor = '\x1b[33m';
else countColor = '\x1b[2m';
suffix += `${countColor}:${timeStr}\x1b[0m`;
parts.push(suffix);
}
cacheSegment = parts.join(' ');
} else if (usageNull && hadCacheBefore) {
// current_usage is null after /compact until the next API call repopulates it.
// Show that the live cache view is reset, not just absent.
cacheSegment = '\x1b[2mcache:\x1b[0m\x1b[33mreset\x1b[0m';
}
} catch (e) {}
// --- Rate limits (subscription) ---
const limitParts = [];
const rl = data.rate_limits;
if (rl) {
const colorPct = (pct) => {
if (pct < 50) return `\x1b[38;2;255;125;218m${pct}%\x1b[0m`;
if (pct < 65) return `\x1b[33m${pct}%\x1b[0m`;
if (pct < 80) return `\x1b[38;2;255;140;0m${pct}%\x1b[0m`;
return `\x1b[31m${pct}%\x1b[0m`;
};
const formatReset = (resetTs, opts) => {
if (!Number.isFinite(resetTs)) return null;
const coarse = !!(opts && opts.coarse);
const resetMin = Math.max(0, Math.ceil((resetTs * 1000 - Date.now()) / 60000));
if (resetMin >= 1440) {
const d = Math.floor(resetMin / 1440);
const h = Math.floor((resetMin % 1440) / 60);
if (coarse && d >= 2) return `${d}d`;
if (d === 1 && h === 0) return '24h';
return `${d}d${h}h`;
}
if (resetMin >= 60) {
const h = Math.floor(resetMin / 60);
const m = resetMin % 60;
return m > 0 ? `${h}h${m}m` : `${h}h`;
}
return `${resetMin}m`;
};
const withReset = (label, bucket, opts) => {
const rawUsedPct = bucket.used_percentage;
const usedPct = (rawUsedPct == null || rawUsedPct === '') ? NaN : Number(rawUsedPct);
if (!Number.isFinite(usedPct)) return null;
const pct = Math.round(usedPct);
const reset = formatReset(bucket.resets_at, opts);
const main = `${label}:${colorPct(pct)}`;
return reset ? `${main}\x1b[2m(${reset})\x1b[0m` : main;
};
const parts = [];
if (rl.five_hour) {
const p = withReset('5h', rl.five_hour);
if (p) parts.push(p);
}
if (rl.seven_day) {
const p = withReset('7d', rl.seven_day, { coarse: true });
if (p) parts.push(p);
}
for (const p of parts) limitParts.push(p);
}
// --- Output ---
const dirRaw = path.basename(dir);
const dirShort = dirRaw.length > 15
? dirRaw.slice(0, 7) + '…' + dirRaw.slice(-7)
: dirRaw;
const buildDirSegment = (name) => {
let s = `\x1b[2m${name}\x1b[0m`;
if (branch) s += ` \x1b[36m(${branch})\x1b[0m`;
else if (detachedSha) s += ` \x1b[31m(HEAD@${detachedSha})\x1b[0m`;
return s;
};
const segments = [`\x1b[2m${model}\x1b[0m`];
const dirIndex = segments.length;
segments.push(buildDirSegment(dirRaw));
if (gitInfo) segments.push(gitInfo.trim());
if (ctx) segments.push(ctx.trim());
if (cacheSegment) segments.push(cacheSegment);
for (const lp of limitParts) segments.push(lp);
// Truncate dirname only if the line crosses 100 visible columns.
if (dirRaw !== dirShort) {
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
let visible = -3; // " \u2502 " separator is 3 chars; pre-subtract one to undo over-counting.
for (const seg of segments) visible += 3 + stripAnsi(seg).length;
if (visible > 100) segments[dirIndex] = buildDirSegment(dirShort);
}
process.stdout.write(segments.join(' \u2502 '));
} catch (e) {}
});
What it shows
- Abbreviated model name (Opus 4.8 renders as Op 4.8; names outside Opus/Sonnet/Haiku/Mythos, like Fable 5, print unchanged)
- Current directory basename
- Current git branch in cyan, or the short commit SHA in red when HEAD is detached
- Counts of uncommitted changes bucketed by type (modified, added, deleted, renamed, untracked) plus a separate red count for merge conflicts
- Commits behind and ahead of the branch's origin counterpart, shown as red '↓N pull' and yellow '↑N push'
- A red '⚠ md drift' warning when CLAUDE.md, AGENTS.md and GEMINI.md all exist but their contents differ
- Context window usage as a five-cell block bar with absolute token counts (e.g. 44k/200k)
- Prompt cache stats for the latest API call: hit ratio percentage, cache reads (↓) and cache writes (+ or ↑)
- Cache TTL bucket (5m or 1h) with a countdown until expiry, when the session transcript file is readable
- A 'cache:reset' marker after /compact when live cache counters are null but the transcript shows earlier cache activity
- 5-hour and 7-day rate limit usage percentages with the time until each bucket resets
Requirements
- Node.js runtime; the script uses only built-in modules, no npm packages
- The git CLI on PATH for the branch and status segments; without it the script still runs and those segments drop out
- A terminal that renders ANSI colors including 24-bit truecolor escape sequences
- Standard Unicode support for block characters, arrows, the vertical-bar separator and the skull emoji; no Nerd Font glyphs required
- No network access; all git commands run locally
- Read access to the Claude Code transcript directory for the cache TTL countdown, and write access to the OS temp directory, where it writes a per-session claude-ctx-<session>.json bridge file for a companion hook
Behavior notes
- In a brand-new session with null context data and no rate limits, only the model, directory and branch print
- The context bar changes color with usage: pink under 50%, yellow to 65%, orange to 80%, then red with a skull prefix as in the 91% scenario
- Pink is bumped to yellow once total input tokens reach 250k even at a low percentage, so wide 1M-context sessions do not look deceptively healthy
- Git change counts such as '1M 1?' appear only when the working tree is dirty; a clean repo shows just the branch
- Only the rate limit buckets present in the input are shown; the Haiku scenario prints the 5h bucket alone while other scenarios show both 5h and 7d
- 7-day reset times of two or more days display coarsely as whole days ('2d', '5d'), while the 5h bucket keeps hour-and-minute precision
- The cache hit ratio is colored by value: bold red at 0% as in the Fable scenario, orange when low, green when high
- Cache writes print as yellow '+' when reads also occurred, or '↑' on a write with no reads
- In the worktree scenario the directory segment switches to the worktree folder name and its branch
- The cache TTL countdown never appears in the previews because it requires the session transcript file, which the sandbox lacks
- The directory name is truncated with a middle ellipsis only when it exceeds 15 characters and the full line would pass 100 visible columns
- On malformed JSON, or if stdin does not finish within 3 seconds, the script exits silently with no output
More status lines
app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0hOpus 4.8 | main | ██░░░░░░░░ 22.0% | 44k / 200k | 02:07 26% | Wed 7%
Opus 4.8 │ main │ app │ ▓▓░░░░░░░░ 44k/200k │ $0.41
app ⏱ 10m ▲ +128 -34 ▓░░░░░░░ 22% $0.41 ◆ Opus Opus 4.8 │ ▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱ 22% │ $0.41
app │ main
Opus 4.8 high ✦ │ ctx 22% │ app (main) │ PR #1287 ● │ $0.41 · 10m +128 -34
Session ● ● ● ● ● ● ● ● ○ ○ ○ 74% left Resets in 2h 7m
Weekly ● ● ● ● ● ● ● ● ● ● ○ 93% left Resets in 2d 1h
◐ Read:index.ts ✓ Read×1 ✓ Bash×1 ✓ Edit×1 ✓ Grep×1
◐ code-reviewer Review the change for regressions
New to Claude Code status lines? Read the setup guide.