diff --git a/bin/install.js b/bin/install.js index 5f0e3893..96b4bc34 100755 --- a/bin/install.js +++ b/bin/install.js @@ -892,7 +892,7 @@ function uninstall(isGlobal, runtime = 'claude') { // 4. Remove GSD hooks const hooksDir = path.join(targetDir, 'hooks'); if (fs.existsSync(hooksDir)) { - const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh']; + const gsdHooks = ['gsd-statusline.js', 'gsd-check-update.js', 'gsd-check-update.sh', 'gsd-context-monitor.js']; let hookCount = 0; for (const hook of gsdHooks) { const hookPath = path.join(hooksDir, hook); @@ -958,10 +958,32 @@ function uninstall(isGlobal, runtime = 'claude') { if (settings.hooks.SessionStart.length === 0) { delete settings.hooks.SessionStart; } - // Clean up empty hooks object - if (Object.keys(settings.hooks).length === 0) { - delete settings.hooks; + } + + // Remove GSD hooks from PostToolUse + if (settings.hooks && settings.hooks.PostToolUse) { + const before = settings.hooks.PostToolUse.length; + settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(entry => { + if (entry.hooks && Array.isArray(entry.hooks)) { + const hasGsdHook = entry.hooks.some(h => + h.command && h.command.includes('gsd-context-monitor') + ); + return !hasGsdHook; + } + return true; + }); + if (settings.hooks.PostToolUse.length < before) { + settingsModified = true; + console.log(` ${green}✓${reset} Removed context monitor hook from settings`); } + if (settings.hooks.PostToolUse.length === 0) { + delete settings.hooks.PostToolUse; + } + } + + // Clean up empty hooks object + if (settings.hooks && Object.keys(settings.hooks).length === 0) { + delete settings.hooks; } if (settingsModified) { @@ -1520,6 +1542,9 @@ function install(isGlobal, runtime = 'claude') { const updateCheckCommand = isGlobal ? buildHookCommand(targetDir, 'gsd-check-update.js') : 'node ' + dirName + '/hooks/gsd-check-update.js'; + const contextMonitorCommand = isGlobal + ? buildHookCommand(targetDir, 'gsd-context-monitor.js') + : 'node ' + dirName + '/hooks/gsd-context-monitor.js'; // Enable experimental agents for Gemini CLI (required for custom sub-agents) if (isGemini) { @@ -1556,6 +1581,27 @@ function install(isGlobal, runtime = 'claude') { }); console.log(` ${green}✓${reset} Configured update check hook`); } + + // Configure PostToolUse hook for context window monitoring + if (!settings.hooks.PostToolUse) { + settings.hooks.PostToolUse = []; + } + + const hasContextMonitorHook = settings.hooks.PostToolUse.some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor')) + ); + + if (!hasContextMonitorHook) { + settings.hooks.PostToolUse.push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand + } + ] + }); + console.log(` ${green}✓${reset} Configured context window monitor hook`); + } } // Write file manifest for future modification detection diff --git a/docs/context-monitor.md b/docs/context-monitor.md new file mode 100644 index 00000000..b312bd83 --- /dev/null +++ b/docs/context-monitor.md @@ -0,0 +1,96 @@ +# Context Window Monitor + +A PostToolUse hook that warns the agent when context window usage is high. + +## Problem + +The statusline shows context usage to the **user**, but the **agent** has no awareness of context limits. When context runs low, the agent continues working until it hits the wall — potentially mid-task with no state saved. + +## How It Works + +1. The statusline hook writes context metrics to `/tmp/claude-ctx-{session_id}.json` +2. After each tool use, the context monitor reads these metrics +3. When remaining context drops below thresholds, it injects a warning as `additionalContext` +4. The agent receives the warning in its conversation and can act accordingly + +## Thresholds + +| Level | Remaining | Agent Behavior | +|-------|-----------|----------------| +| Normal | > 35% | No warning | +| WARNING | <= 35% | Wrap up current task, avoid starting new complex work | +| CRITICAL | <= 25% | Stop immediately, save state (`/gsd:pause-work`) | + +## Debounce + +To avoid spamming the agent with repeated warnings: +- First warning always fires immediately +- Subsequent warnings require 5 tool uses between them +- Severity escalation (WARNING -> CRITICAL) bypasses debounce + +## Architecture + +``` +Statusline Hook (gsd-statusline.js) + | writes + v +/tmp/claude-ctx-{session_id}.json + ^ reads + | +Context Monitor (gsd-context-monitor.js, PostToolUse) + | injects + v +additionalContext -> Agent sees warning +``` + +The bridge file is a simple JSON object: + +```json +{ + "session_id": "abc123", + "remaining_percentage": 28.5, + "used_pct": 71, + "timestamp": 1708200000 +} +``` + +## Integration with GSD + +GSD's `/gsd:pause-work` command saves execution state. The WARNING message suggests using it. The CRITICAL message instructs immediate state save. + +## Setup + +Both hooks are automatically registered during `npx get-shit-done-cc` installation: + +- **Statusline** (writes bridge file): Registered as `statusLine` in settings.json +- **Context Monitor** (reads bridge file): Registered as `PostToolUse` hook in settings.json + +Manual registration in `~/.claude/settings.json`: + +```json +{ + "statusLine": { + "type": "command", + "command": "node ~/.claude/hooks/gsd-statusline.js" + }, + "hooks": { + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "node ~/.claude/hooks/gsd-context-monitor.js" + } + ] + } + ] + } +} +``` + +## Safety + +- The hook wraps everything in try/catch and exits silently on error +- It never blocks tool execution — a broken monitor should not break the agent's workflow +- Stale metrics (older than 60s) are ignored +- Missing bridge files are handled gracefully (subagents, fresh sessions) diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js new file mode 100644 index 00000000..f86ac2f5 --- /dev/null +++ b/hooks/gsd-context-monitor.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// Context Monitor - PostToolUse hook +// 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 = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', chunk => input += chunk); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + const sessionId = data.session_id; + + if (!sessionId) { + process.exit(0); + } + + 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)); + + // Build warning message + let message; + if (isCritical) { + message = `CONTEXT MONITOR CRITICAL: Usage at ${usedPct}%. Remaining: ${remaining}%. ` + + 'STOP new work immediately. Save state NOW and inform the user that context is nearly exhausted. ' + + 'If using GSD, run /gsd:pause-work to save execution state.'; + } else { + message = `CONTEXT MONITOR WARNING: Usage at ${usedPct}%. Remaining: ${remaining}%. ` + + 'Begin wrapping up current task. Do not start new complex work. ' + + 'If using GSD, consider /gsd:pause-work to save state.'; + } + + const output = { + hookSpecificOutput: { + hookEventName: "PostToolUse", + additionalContext: message + } + }; + + process.stdout.write(JSON.stringify(output)); + } catch (e) { + // Silent fail -- never block tool execution + process.exit(0); + } +}); diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index fa8889f9..29185d68 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -27,6 +27,23 @@ process.stdin.on('end', () => { // Scale: 80% real usage = 100% displayed const used = Math.min(100, Math.round((rawUsed / 80) * 100)); + // Write context metrics to bridge file for the context-monitor PostToolUse hook. + // The monitor reads this file to inject agent-facing warnings when context is low. + if (session) { + try { + const bridgePath = path.join(os.tmpdir(), `claude-ctx-${session}.json`); + const bridgeData = JSON.stringify({ + session_id: session, + remaining_percentage: remaining, + used_pct: used, + timestamp: Math.floor(Date.now() / 1000) + }); + fs.writeFileSync(bridgePath, bridgeData); + } catch (e) { + // Silent fail -- bridge is best-effort, don't break statusline + } + } + // Build progress bar (10 segments) const filled = Math.floor(used / 10); const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); diff --git a/scripts/build-hooks.js b/scripts/build-hooks.js index a8c47869..ffb60b0f 100644 --- a/scripts/build-hooks.js +++ b/scripts/build-hooks.js @@ -12,6 +12,7 @@ const DIST_DIR = path.join(HOOKS_DIR, 'dist'); // Hooks to copy (pure Node.js, no bundling needed) const HOOKS_TO_COPY = [ 'gsd-check-update.js', + 'gsd-context-monitor.js', 'gsd-statusline.js' ];