#!/usr/bin/env node // gsd-hook-version: 1.30.0 // SHARED CANONICAL FILE - hardlinked into all harness hooks/ directories. // Harness is auto-detected from process.env at runtime (GEMINI_API_KEY -> AfterTool). // Do NOT add harness-specific if/else blocks here. See HOOKS_ARCHITECTURE.md. // 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); } });