Hooks
What context-monitor runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add fomyio/claude-context-monitor > /plugin install context-monitor@claude-context-monitor
Ships with context-monitor. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
${CLAUDE_PLUGIN_ROOT}/hooks/session-init.sh
UserPromptSubmit
Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.
${CLAUDE_PLUGIN_ROOT}/hooks/check.sh
Stop
${CLAUDE_PLUGIN_ROOT}/hooks/update-state.sh
PreCompact
${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.sh
PostCompact
${CLAUDE_PLUGIN_ROOT}/hooks/post-compact.sh
Where it lives
- hooks/check.shRunsGitHub
Read the script
#!/usr/bin/env bash # hooks/check.sh — UserPromptSubmit orchestrator # Reads hook JSON from stdin, analyzes token usage, renders token bar, # and invokes advisor.js above the relevance eval threshold. # # Exit codes: # 0 — allow prompt (may inject status text into stdout for Claude's context) # 2 — block prompt (compact_score > block threshold) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$(dirname "$SCRIPT_DIR")}" CONFIG="$PLUGIN_DIR/config.json" ANALYZE="$PLUGIN_DIR/src/analyze.js" ADVISOR="$PLUGIN_DIR/src/advisor.js" NOTIFY="$PLUGIN_DIR/src/notify.sh" INPUT="$(cat)" SESSION_ID="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.session_id ?? d.sessionId ?? ''); " 2>/dev/null || echo '')" TRANSCRIPT_PATH="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.transcript_path ?? d.transcriptPath ?? ''); " 2>/dev/null || echo '')" NEW_PROMPT="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); // prompt may be in d.prompt or d.message const p = d.prompt ?? (typeof d.message === 'string' ? d.message : '') ?? ''; console.log(p.substring(0, 500)); " 2>/dev/null || echo '')" if [ -z "$TRANSCRIPT_PATH" ]; then exit 0; fi # ── Resolve state ───────────────────────────────────────────────────────────── STATE_DIR_CFG="$(node -e " const c=JSON.parse(require('fs').readFileSync('$CONFIG','utf8')); console.log(c.state_dir ?? '~/.claude/plugins/context-monitor/state'); " 2>/dev/null || echo "~/.claude/plugins/context-monitor/state")" STATE_DIR="${STATE_DIR_CFG/#\~/$HOME}" STATE_FILE="$STATE_DIR/${SESSION_ID}.json" MODEL="$(STATE_FILE="$STATE_FILE" node -e ' try { const s=JSON.parse(require("fs").readFileSync(process.env.STATE_FILE,"utf8")); console.log(s.model ?? ""); } catch(_) { console.log(""); } ' 2>/dev/null || echo '')" # ── Analyze token usage ─────────────────────────────────────────────────────── STATS="$(node "$ANALYZE" "$TRANSCRIPT_PATH" "$MODEL" "$SESSION_ID" 2>/dev/null || echo '{}')" if [ "$STATS" = '{}' ] || [ -z "$STATS" ]; then exit 0 fi # ── Prefer accurate context_window data from statusline.sh (ground truth) ───── # statusline.sh writes used_tokens / used_percentage / context_limit to state. # Claude Code's internal tracking includes system prompts, tool defs, and injected # status lines — tokens that analyze.js cannot see in the transcript. STATE_DIR_CFG="$(node -e " const c=JSON.parse(require('fs').readFileSync('$CONFIG','utf8')); console.log(c.state_dir ?? '~/.claude/plugins/context-monitor/state'); " 2>/dev/null || echo "~/.claude/plugins/context-monitor/state")" STATE_DIR="${STATE_DIR_CFG/#\~/$HOME}" STATE_FILE="$STATE_DIR/${SESSION_ID}.json" # Read ground-truth values from state if available GROUND_TRUTH="$(STATE_FILE="$STATE_FILE" node -e ' const fs = require("fs"); try { const s = JSON.parse(fs.readFileSync(process.env.STATE_FILE, "utf8")); console.log(JSON.stringify({ used_tokens: s.used_tokens ?? null, used_percentage: s.used_percentage ?? null, context_limit: s.context_limit ?? null })); } catch(_) { console.log("{}"); } ' 2>/dev/null || echo '{}')" TOKENS_MAX="$(echo "$STATS" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.tokens_max ?? 200000); " 2>/dev/null || echo 200000)" # Override with ground truth when available OVERRIDE_USED_TOKENS="$(echo "$GROUND_TRUTH" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.used_tokens ?? ''); " 2>/dev/null || echo '')" OVERRIDE_USED_PCT="$(echo "$GROUND_TRUTH" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.used_percentage ?? ''); " 2>/dev/null || echo '')" OVERRIDE_LIMIT="$(echo "$GROUND_TRUTH" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.context_limit ?? ''); " 2>/dev/null || echo '')" TOKENS_USED="$(echo "$STATS" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.tokens_used ?? 0); " 2>/dev/null || echo 0)" USAGE_PCT="$(echo "$STATS" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.usage_pct ?? 0); " 2>/dev/null || echo 0)" # Apply overrides: Claude Code's context_window data is ground truth if [ -n "$OVERRIDE_USED_TOKENS" ] && [ "$OVERRIDE_USED_TOKENS" != "null" ]; then TOKENS_USED="$OVERRIDE_USED_TOKENS" fi if [ -n "$OVERRIDE_USED_PCT" ] && [ "$OVERRIDE_USED_PCT" != "null" ]; then USAGE_PCT="$OVERRIDE_USED_PCT" fi if [ -n "$OVERRIDE_LIMIT" ] && [ "$OVERRIDE_LIMIT" != "null" ]; then TOKENS_MAX="$OVERRIDE_LIMIT" fi TURNS_LEFT="$(echo "$STATS" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.turns_left != null ? d.turns_left : '?'); " 2>/dev/null || echo '?')" BURN_RATE="$(echo "$STATS" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.burn_rate ?? 0); " 2>/dev/null || echo 0)" # ── Render token bar ────────────────────────────────────────────────────────── render_bar() { local pct="$1" local turns="$2" local used="$3" local max="$4" node -e " const pct = parseFloat('$pct') || 0; const BAR_WIDTH = 20; const filled = Math.round(BAR_WIDTH * pct / 100); const empty = Math.max(0, BAR_WIDTH - filled); let color = ''; if (pct >= 85) color = '🔴'; else if (pct >= 70) color = '🟡'; else color = '🟢'; const bar = '█'.repeat(filled) + '░'.repeat(empty); const turnsText = '$turns' !== '?' ? '~$turns turns left' : 'burn rate calculating'; const usedK = Math.round($used / 1000); const maxK = Math.round($max / 1000); console.log(\`[CTX] \${color} [\${bar} - hooks/post-compact.shRunsGitHub
Read the script
#!/usr/bin/env bash # hooks/post-compact.sh — PostCompact hook # Reads PostCompact JSON from stdin, resets token history, logs compact event, # saves compact summary for carry-forward, and sends a desktop notification. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$(dirname "$SCRIPT_DIR")}" CONFIG="$PLUGIN_DIR/config.json" NOTIFY="$PLUGIN_DIR/src/notify.sh" INPUT="$(cat)" SESSION_ID="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.session_id ?? d.sessionId ?? ''); " 2>/dev/null || echo '')" TRIGGER="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.trigger ?? 'manual'); " 2>/dev/null || echo 'manual')" if [ -z "$SESSION_ID" ]; then exit 0; fi # ── Resolve state file ──────────────────────────────────────────────────────── STATE_DIR_CFG="$(node -e " const c=JSON.parse(require('fs').readFileSync('$CONFIG','utf8')); console.log(c.state_dir ?? '~/.claude/plugins/context-monitor/state'); " 2>/dev/null || echo "~/.claude/plugins/context-monitor/state")" STATE_DIR="${STATE_DIR_CFG/#\~/$HOME}" STATE_FILE="$STATE_DIR/$SESSION_ID.json" if [ ! -f "$STATE_FILE" ]; then exit 0; fi TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" # ── Save full summary to temp file for carry-forward ────────────────────────── # Extract the full (non-truncated) summary from the input JSON and write to a # temp file so the node block below can read it without shell escaping issues. FULL_SUMMARY_FILE="$(mktemp)" # Single-quote the trap body so the path is expanded at trap time with proper # quoting, surviving a TMPDIR that contains spaces or other odd characters. trap 'rm -f "$FULL_SUMMARY_FILE"' EXIT echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const s=d.compact_summary ?? d.summary ?? ''; process.stdout.write(typeof s === 'string' ? s : ''); " > "$FULL_SUMMARY_FILE" 2>/dev/null || true # ── Update state: log compact event, save summary, reset token history ──────── # Values passed via env (never interpolated into JS source): $TRIGGER comes from # the hook JSON and $STATE_FILE embeds the session id. STATE_FILE="$STATE_FILE" TRIGGER="$TRIGGER" TIMESTAMP="$TIMESTAMP" \ FULL_SUMMARY_FILE="$FULL_SUMMARY_FILE" node -e " const fs = require('fs'); const stateFile = process.env.STATE_FILE; let state; try { state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); } catch(_) { process.exit(0); } const history = state.token_history || []; const preTokens = history.length > 0 ? history[history.length - 1].tokens_used : 0; // Read the full summary from temp file (written by the shell script above) let fullSummary = ''; try { fullSummary = fs.readFileSync(process.env.FULL_SUMMARY_FILE, 'utf8').trim(); } catch(_) {} // Log compact event (use first 100 chars of summary as preview) state.compact_events = state.compact_events || []; state.compact_events.push({ compacted_at: process.env.TIMESTAMP, trigger: process.env.TRIGGER, pre_tokens: preTokens, turns_at_compact: state.total_turns, summary_preview: fullSummary.substring(0, 100), }); // Save full compact summary for carry-forward into next compact prompt. // This solves cumulative amnesia: each compact preserves key decisions from // all previous compacts, not just the current conversation. // Trim at paragraph boundaries to avoid truncating mid-sentence. if (fullSummary.length > 0) { let summary = fullSummary.substring(0, 3000); const lastBreak = summary.lastIndexOf('\\n\\n', 2950); if (lastBreak > 1500) summary = summary.substring(0, lastBreak); state.last_compact_summary = summary; state.last_compact_timestamp = process.env.TIMESTAMP; state.last_compact_turn = state.total_turns; } // Reset token history — context was freed state.token_history = []; state.last_compact_at_turn = state.total_turns; const tmp = stateFile + '.tmp.' + process.pid; fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); fs.renameSync(tmp, stateFile); " 2>/dev/null || true # Clean up temp file rm -f "$FULL_SUMMARY_FILE" 2>/dev/null || true # ── Send desktop notification ───────────────────────────────────────────────── bash "$NOTIFY" "info" "🧹 Compact Complete" "Context freed! Session continues fresh. (trigger: $TRIGGER)" 2>/dev/null || true exit 0 - hooks/pre-compact.shRunsGitHub
Read the script
#!/usr/bin/env bash # hooks/pre-compact.sh — PreCompact hook (Smart Compact Instructions) # # Injects dynamic, context-aware instructions into the compact summarization # prompt. Unlike the original static policy, this version: # # 1. Carries forward the previous compact summary (solves cumulative amnesia) # 2. Marks stale vs active topics (solves "what to keep/drop") # 3. Preserves active task state (solves "what matters now") # 4. Signals task completion (solves "safe to drop stale context") # # Reads from the per-session state file written by advisor.js, check.sh, # and post-compact.sh. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$(dirname "$SCRIPT_DIR")}" CONFIG="$PLUGIN_DIR/config.json" # ── Static fallback policy (used when no state is available) ────────────────── print_fallback_policy() { cat << 'FALLBACK' Context Monitor Policy: When summarizing this session, please explicitly preserve: 1. All absolute and relative file paths that have been modified or discussed. 2. Any specific error messages we are currently trying to fix. 3. Important environment variables, config values, or port numbers. 4. Output these as a structured list at the top or bottom of your summary. FALLBACK } # ── Read hook input ──────────────────────────────────────────────────────────── INPUT="$(cat)" SESSION_ID="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.session_id ?? d.sessionId ?? ''); " 2>/dev/null || echo '')" if [ -z "$SESSION_ID" ]; then # No session context — fall back to static policy print_fallback_policy exit 0 fi # ── Resolve state file ───────────────────────────────────────────────────────── STATE_DIR_CFG="$(node -e " const c=JSON.parse(require('fs').readFileSync('$CONFIG','utf8')); console.log(c.state_dir ?? '~/.claude/plugins/context-monitor/state'); " 2>/dev/null || echo "~/.claude/plugins/context-monitor/state")" STATE_DIR="${STATE_DIR_CFG/#\~/$HOME}" STATE_FILE="$STATE_DIR/$SESSION_ID.json" if [ ! -f "$STATE_FILE" ]; then # No state — fall back to static policy print_fallback_policy exit 0 fi # ── Build dynamic compact instructions ───────────────────────────────────────── # Uses node to read the state file and produce the instruction block, avoiding # shell escaping issues with the compact summary content. STATE_FILE="$STATE_FILE" node -e " const fs = require('fs'); let state; try { state = JSON.parse(fs.readFileSync(process.env.STATE_FILE, 'utf8')); } catch(_) { // Fall back to static policy console.log('Context Monitor Policy: When summarizing this session, please explicitly preserve:'); console.log('1. All absolute and relative file paths that have been modified or discussed.'); console.log('2. Any specific error messages we are currently trying to fix.'); console.log('3. Important environment variables, config values, or port numbers.'); console.log('4. Output these as a structured list at the top or bottom of your summary.'); process.exit(0); } const lines = []; // ── Section 1: Core preservation policy (always present) ──────────────── lines.push('=== SMART COMPACT INSTRUCTIONS ==='); lines.push(''); lines.push('When summarizing this session, you MUST explicitly preserve:'); lines.push('1. All absolute and relative file paths that have been modified or discussed.'); lines.push('2. Any specific error messages we are currently trying to fix.'); lines.push('3. Important environment variables, config values, or port numbers.'); lines.push('4. Output these as a structured list at the top or bottom of your summary.'); lines.push(''); // ── Section 2: Previous compact summary (carry-forward) ──────────────── // This is the key innovation: each compact preserves the summary from the // previous compact, creating a cumulative memory chain that prevents the // exponential fidelity loss documented in anthropics/claude-code#33212. const prevSummary = state.last_compact_summary || ''; if (prevSummary.length > 0) { lines.push('--- PREVIOUS COMPACT SUMMARY (PRESERVE VERBATIM) ---'); lines.push('This is a summary from a previous compaction in this session.'); lines.push('You MUST include this content in your new summary under a'); lines.push('\"Historical Context\" section. Do NOT discard or paraphrase it.'); lines.push(''); lines.push(prevSummary); lines.push(''); } // ── Section 3: Topic awareness ────────────────────────────────────────── // The advisor tracks topic shifts via Haiku eval. We use that data to tell // the compact prompt which topics are stale (safe to summarize aggressively) // vs active (must be preserved in detail). const topics = state.topics || []; if (topics.length > 0) { lines.push('--- TOPIC HISTORY ---'); lines.push('Topic shifts detected during this session:'); topics.forEach((t, i) => { const typeTag = t.shift_type ? \` [\${t.shift_type}]\` : ''; lines.push(\` [\${i + 1}] Turn \${t.turn}\${typeTag}: \${t.label}\`); }); lines.push(''); // The most recent topic is the active one const activeTopic = topics[topics.length - 1]; if (topics.length > 1) { const staleTopics = topics.slice(0, -1); lines.push(\`Active topic: \${activeTopic.label} (preserve in FULL detail)\`); lines.push(\`Stale topics: \${staleTopics.map(t => t.label).join(', ')}\`); lines.push('For stale topics: summarize AGGRESSIVELY — keep only final'); lines.push('decisions and outcomes. Drop intermediate reasoning and debugging steps.'); } else { lines.push(\`Active topic: \${activeTopic.label} (preserve in FULL detail)\`); } lines.push(''); } // ── Section 4: Active task state ──────────────────────────────────────── // Saved by advisor.js on each run: completion status, relevance, compact score. const activeTask = state - hooks/session-init.shRunsGitHub
Read the script
#!/usr/bin/env bash # hooks/session-init.sh — SessionStart hook # Reads SessionStart JSON from stdin, creates per-session state file # and checks CLAUDE.md bloat. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$(dirname "$SCRIPT_DIR")}" CONFIG="$PLUGIN_DIR/config.json" # Claude Code's active config dir — honors CLAUDE_CONFIG_DIR (multi-account / # custom homes). The statusline wrapper + settings.json registration must land # here, not a hardcoded ~/.claude, or the status bar silently never appears for # anyone running a non-default config dir. CLAUDE_CFG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" # Every step relies on node; bail cleanly if it is missing so an unguarded # `node ... ` write can't abort the script under `set -e`. command -v node >/dev/null 2>&1 || exit 0 # ── Helper: read config value ───────────────────────────────────────────────── config_get() { node -e " const c = JSON.parse(require('fs').readFileSync('$CONFIG', 'utf8')); const keys = '$1'.split('.'); let v = c; for (const k of keys) v = v?.[k]; console.log(v ?? '$2'); " 2>/dev/null || echo "$2" } # ── Read hook input ─────────────────────────────────────────────────────────── INPUT="$(cat)" SESSION_ID="$(echo "$INPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.session_id ?? d.sessionId ?? '');" 2>/dev/null || echo '')" MODEL="$(echo "$INPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.model ?? '');" 2>/dev/null || echo '')" TRANSCRIPT_PATH="$(echo "$INPUT" | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.transcript_path ?? d.transcriptPath ?? '');" 2>/dev/null || echo '')" if [ -z "$SESSION_ID" ]; then # Cannot initialize without session id — exit silently exit 0 fi # ── Resolve state dir ───────────────────────────────────────────────────────── STATE_DIR_CFG="$(config_get state_dir "$HOME/.claude/plugins/context-monitor/state")" STATE_DIR="${STATE_DIR_CFG/#\~/$HOME}" mkdir -p "$STATE_DIR" STATE_FILE="$STATE_DIR/$SESSION_ID.json" # ── Create initial state file ───────────────────────────────────────────────── # Values are passed via the environment (never interpolated into JS source) so a # session id, model, or transcript path containing quotes/backticks/${} cannot # break the write or execute code. Written atomically (temp + rename). STARTED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" SESSION_ID="$SESSION_ID" MODEL="$MODEL" STARTED_AT="$STARTED_AT" \ TRANSCRIPT_PATH="$TRANSCRIPT_PATH" STATE_FILE="$STATE_FILE" node -e ' const fs = require("fs"); const stateFile = process.env.STATE_FILE; const state = { session_id: process.env.SESSION_ID, model: process.env.MODEL || "", started_at: process.env.STARTED_AT, transcript_path: process.env.TRANSCRIPT_PATH || "", token_history: [], topics: [], last_compact_at_turn: 0, total_turns: 0, last_assistant_message: "", claude_md_tokens: 0, compact_events: [], last_compact_summary: "", last_compact_timestamp: null, last_compact_turn: null, active_task: null }; const tmp = stateFile + ".tmp." + process.pid; fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); fs.renameSync(tmp, stateFile); ' 2>/dev/null || true # ── CLAUDE.md bloat check ───────────────────────────────────────────────────── CLAUDE_MD="$HOME/.claude/CLAUDE.md" # Also check local CLAUDE.md LOCAL_CLAUDE_MD="$(pwd)/CLAUDE.md" BLOAT_THRESHOLD="$(config_get claude_md_bloat_threshold_pct 15)" check_claude_md_bloat() { local md_path="$1" local label="$2" if [ ! -f "$md_path" ]; then return; fi local size_bytes size_bytes="$(wc -c < "$md_path" | tr -d ' ')" local model_limit model_limit="$(MODEL="$MODEL" CONFIG="$CONFIG" PLUGIN_DIR="$PLUGIN_DIR" node -e ' const { lookupLimit } = require(process.env.PLUGIN_DIR + "/src/context-limit.js"); const c = JSON.parse(require("fs").readFileSync(process.env.CONFIG, "utf8")); console.log(lookupLimit(process.env.MODEL || "", c.context_limits || {})); ' 2>/dev/null || echo 200000)" # Estimate tokens: chars / 3.5 local est_tokens est_tokens=$(( size_bytes * 10 / 35 )) local threshold_tokens threshold_tokens=$(( model_limit * BLOAT_THRESHOLD / 100 )) if [ "$est_tokens" -gt "$threshold_tokens" ]; then echo "[CTX] ⚠️ $label is large (~${est_tokens} tokens = $(( est_tokens * 100 / model_limit ))% of context). Consider trimming it." fi # Persist to state (env + atomic write, consistent with the rest of the hooks) EST_TOKENS="$est_tokens" STATE_FILE="$STATE_FILE" node -e ' const fs = require("fs"); const stateFile = process.env.STATE_FILE; const state = JSON.parse(fs.readFileSync(stateFile, "utf8")); state.claude_md_tokens = parseInt(process.env.EST_TOKENS, 10) || 0; const tmp = stateFile + ".tmp." + process.pid; fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); fs.renameSync(tmp, stateFile); ' 2>/dev/null || true } check_claude_md_bloat "$CLAUDE_MD" "~/.claude/CLAUDE.md" check_claude_md_bloat "$LOCAL_CLAUDE_MD" "CLAUDE.md" # ── Statusline wrapper setup ────────────────────────────────────────────────── # Writes <config-dir>/statusline.sh as a self-cleaning wrapper each session. # On upgrade: wrapper is rewritten with the current plugin path. # On uninstall: next statusLine call detects missing plugin, removes the # statusLine entry from settings.json, and deletes itself. setup_statusline() { local wrapper_path="$CLAUDE_CFG_DIR/statusline.sh" local plugin_script="$PLUGIN_DIR/src/statusline.sh" local settings_file="$CLAUDE_CFG_DIR/settings.json" # Only write wrapper if absent or previously generated by this plugin if [ ! -f "$wrapper_path" ] || head -n 2 "$wrapper_path" | grep -q "Auto-generated by claude-context-monitor"; then cat > "$wrapper_path" << EOF #!/usr/bin/env bash # Auto-generated by claude-context - hooks/update-state.shRunsGitHub
Read the script
#!/usr/bin/env bash # hooks/update-state.sh — Stop hook (async) # Reads Stop hook JSON from stdin, calls analyze.js, persists stats to state file. # Registered as a background hook — does NOT block the user. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PLUGIN_DIR="${CLAUDE_PLUGIN_ROOT:-$(dirname "$SCRIPT_DIR")}" CONFIG="$PLUGIN_DIR/config.json" ANALYZE="$PLUGIN_DIR/src/analyze.js" NOTIFY="$PLUGIN_DIR/src/notify.sh" # node is a hard dependency for every step below — bail out cleanly if absent # so the hook never aborts mid-write under `set -e`. command -v node >/dev/null 2>&1 || exit 0 INPUT="$(cat)" SESSION_ID="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.session_id ?? d.sessionId ?? ''); " 2>/dev/null || echo '')" TRANSCRIPT_PATH="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); console.log(d.transcript_path ?? d.transcriptPath ?? ''); " 2>/dev/null || echo '')" LAST_MSG="$(echo "$INPUT" | node -e " const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); // Try various fields for the last assistant message content const msg = d.message ?? d.assistant_message ?? ''; if (typeof msg === 'string') { console.log(msg.substring(0, 500)); } else if (Array.isArray(msg?.content)) { const text = msg.content.filter(b=>b.type==='text').map(b=>b.text).join(' '); console.log(text.substring(0, 500)); } else { console.log(''); } " 2>/dev/null || echo '')" if [ -z "$SESSION_ID" ] || [ -z "$TRANSCRIPT_PATH" ]; then exit 0; fi # ── Resolve state dir ───────────────────────────────────────────────────────── STATE_DIR_CFG="$(node -e " const c=JSON.parse(require('fs').readFileSync('$CONFIG','utf8')); console.log(c.state_dir ?? '~/.claude/plugins/context-monitor/state'); " 2>/dev/null || echo "~/.claude/plugins/context-monitor/state")" STATE_DIR="${STATE_DIR_CFG/#\~/$HOME}" STATE_FILE="$STATE_DIR/$SESSION_ID.json" # Create state file if it doesn't exist (e.g. session-init didn't run). # Session id passed via env, written atomically — consistent with the main write. if [ ! -f "$STATE_FILE" ]; then mkdir -p "$STATE_DIR" SESSION_ID="$SESSION_ID" STATE_FILE="$STATE_FILE" node -e ' const fs=require("fs"); const stateFile=process.env.STATE_FILE; const tmp=stateFile + ".tmp." + process.pid; fs.writeFileSync(tmp, JSON.stringify({ session_id:process.env.SESSION_ID, token_history:[], topics:[], last_compact_at_turn:0, total_turns:0, last_assistant_message:"", compact_events:[], last_compact_summary:"", last_compact_timestamp:null, last_compact_turn:null, active_task:null }, null, 2)); fs.renameSync(tmp, stateFile); ' 2>/dev/null || true fi # ── Get token stats ─────────────────────────────────────────────────────────── MODEL="$(node -e " try { const s=JSON.parse(require('fs').readFileSync('$STATE_FILE','utf8')); console.log(s.model ?? ''); } catch(_) { console.log(''); } " 2>/dev/null || echo '')" STATS="$(node "$ANALYZE" "$TRANSCRIPT_PATH" "$MODEL" "$SESSION_ID" 2>/dev/null || echo '{}')" if [ "$STATS" = '{}' ] || [ -z "$STATS" ]; then exit 0; fi # ── Persist stats to state ──────────────────────────────────────────────────── TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" # All shell→JS values are passed through the environment and read with # process.env inside node. We never interpolate them into the JS source, so # content containing backticks, ${...}, quotes, etc. is treated as plain data # (no syntax errors, no code execution) — see the injection that the old # `const stats = $STATS` / `\`$LAST_MSG\`` interpolation allowed. STATS="$STATS" \ LAST_MSG="$LAST_MSG" \ STATE_FILE="$STATE_FILE" \ SESSION_ID="$SESSION_ID" \ TRANSCRIPT_PATH="$TRANSCRIPT_PATH" \ TIMESTAMP="$TIMESTAMP" \ node -e ' const fs = require("fs"); const stateFile = process.env.STATE_FILE; let stats = {}; try { stats = JSON.parse(process.env.STATS || "{}"); } catch (_) {} // Read the state fresh immediately before mutating, then touch ONLY the fields // this writer owns. Everything else is left exactly as read from disk so we // never clobber a concurrent writer: statusline owns context_limit/used_*/ // model/model_display; advisor owns topics/active_task; post-compact owns // compact_events/last_compact_*. let state; try { state = JSON.parse(fs.readFileSync(stateFile, "utf8")); } catch (_) { state = { session_id: process.env.SESSION_ID, token_history: [], topics: [], last_compact_at_turn: 0, total_turns: 0, compact_events: [] }; } // Append to token history (owned by this writer) state.token_history = state.token_history || []; state.token_history.push({ timestamp: process.env.TIMESTAMP, tokens_used: stats.tokens_used, tokens_input: stats.tokens_input, usage_pct: stats.usage_pct, burn_rate: stats.burn_rate, cache_efficiency: stats.cache_efficiency ?? 0, }); // Keep history capped at 50 entries if (state.token_history.length > 50) { state.token_history = state.token_history.slice(-50); } state.total_turns = stats.total_turns; state.transcript_path = process.env.TRANSCRIPT_PATH; state.last_assistant_message = (process.env.LAST_MSG || "").slice(0, 500); state.last_updated = process.env.TIMESTAMP; // statusline is authoritative for the live model; only fill in from the // transcript-derived model when statusline has not set one yet. if (!state.model) state.model = stats.model || ""; // Atomic write: write to a temp file then rename (atomic on the same fs) so a // concurrent reader never observes a torn/partial JSON file. const tmp = stateFile + ".tmp." + process.pid; fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); fs.renameSync(tmp, stateFile); ' 2>/dev/null || true # ── Tmux status bar integration (opt-in) ───────────────────────────────────── TMUX_ENABLED="$(node -e " const
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
A Claude Code plugin that watches your context window in real-time, predicts when you'll hit the limit, and tells you before it's too late.
Repo: fomyio/claude-context-monitor

