#!/usr/bin/env node // gsd-hook-version: 1.26.0 // Context Monitor - PostToolUse/AfterTool hook (Gemini uses AfterTool) // Reads context metrics from the statusline bridge file and injects // warnings when context usage is high. This makes the AGENT aware of // context limits (the statusline only shows the user). // // How it works: // 1. The statusline hook writes metrics to /tmp/claude-ctx-{session_id}.json // 2. This hook reads those metrics after each tool use // 3. When remaining context drops below thresholds, it injects a warning // as additionalContext, which the agent sees in its conversation // // Thresholds: // WARNING (remaining <= 35%): Agent should wrap up current task // CRITICAL (remaining <= 25%): Agent should stop immediately and save state // // Debounce: 5 tool uses between warnings to avoid spam // Severity escalation bypasses debounce (WARNING -> CRITICAL fires immediately) const fs = require('fs'); const os = require('os'); const path = require('path'); const WARNING_THRESHOLD = 35; // remaining_percentage <= 35% const CRITICAL_THRESHOLD = 25; // remaining_percentage <= 25% const STALE_SECONDS = 60; // ignore metrics older than 60s const DEBOUNCE_CALLS = 5; // min tool uses between warnings let input = ''; // Timeout guard: if stdin doesn't close within 10s (e.g. pipe issues on // Windows/Git Bash, or slow Claude Code piping during large outputs), // exit silently instead of hanging until Claude Code kills the process // and reports "hook error". See #775, #1162. const stdinTimeout = setTimeout(() => process.exit(0), 10000); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => input += chunk); process.stdin.on('end', () => { clearTimeout(stdinTimeout); try { const data = JSON.parse(input); const sessionId = data.session_id; if (!sessionId) { process.exit(0); } // Check if context warnings are disabled via config const cwd = data.cwd || process.cwd(); const configPath = path.join(cwd, '.planning', 'config.json'); if (fs.existsSync(configPath)) { try { const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (config.hooks?.context_warnings === false) { process.exit(0); } } catch (e) { // Ignore config parse errors } } const tmpDir = os.tmpdir(); const metricsPath = path.join(tmpDir, `claude-ctx-${sessionId}.json`); // If no metrics file, this is a subagent or fresh session -- exit silently if (!fs.existsSync(metricsPath)) { process.exit(0); } const metrics = JSON.parse(fs.readFileSync(metricsPath, 'utf8')); const now = Math.floor(Date.now() / 1000); // Ignore stale metrics if (metrics.timestamp && (now - metrics.timestamp) > STALE_SECONDS) { process.exit(0); } const remaining = metrics.remaining_percentage; const usedPct = metrics.used_pct; // No warning needed if (remaining > WARNING_THRESHOLD) { process.exit(0); } // Debounce: check if we warned recently const warnPath = path.join(tmpDir, `claude-ctx-${sessionId}-warned.json`); let warnData = { callsSinceWarn: 0, lastLevel: null }; let firstWarn = true; if (fs.existsSync(warnPath)) { try { warnData = JSON.parse(fs.readFileSync(warnPath, 'utf8')); firstWarn = false; } catch (e) { // Corrupted file, reset } } warnData.callsSinceWarn = (warnData.callsSinceWarn || 0) + 1; const isCritical = remaining <= CRITICAL_THRESHOLD; const currentLevel = isCritical ? 'critical' : 'warning'; // Emit immediately on first warning, then debounce subsequent ones // Severity escalation (WARNING -> CRITICAL) bypasses debounce const severityEscalated = currentLevel === 'critical' && warnData.lastLevel === 'warning'; if (!firstWarn && warnData.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) { // Update counter and exit without warning fs.writeFileSync(warnPath, JSON.stringify(warnData)); process.exit(0); } // Reset debounce counter warnData.callsSinceWarn = 0; warnData.lastLevel = currentLevel; fs.writeFileSync(warnPath, JSON.stringify(warnData)); // Detect if GSD is active (has .planning/STATE.md in working directory) const isGsdActive = fs.existsSync(path.join(cwd, '.planning', 'STATE.md')); // Build advisory warning message (never use imperative commands that // override user preferences — see #884) let message; if (isCritical) { message = isGsdActive ? `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` + 'Context is nearly exhausted. Do NOT start new complex work or write handoff files — ' + 'GSD state is already tracked in STATE.md. Inform the user so they can run ' + '/gsd:pause-work at the next natural stopping point.' : `CONTEXT CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` + 'Context is nearly exhausted. Inform the user that context is low and ask how they ' + 'want to proceed. Do NOT autonomously save state or write handoff files unless the user asks.'; } else { message = isGsdActive ? `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` + 'Context is getting limited. Avoid starting new complex work. If not between ' + 'defined plan steps, inform the user so they can prepare to pause.' : `CONTEXT WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` + 'Be aware that context is getting limited. Avoid unnecessary exploration or ' + 'starting new complex work.'; } const output = { hookSpecificOutput: { hookEventName: process.env.GEMINI_API_KEY ? "AfterTool" : "PostToolUse", additionalContext: message } }; process.stdout.write(JSON.stringify(output)); } catch (e) { // Silent fail -- never block tool execution process.exit(0); } });