-
-
Notifications
You must be signed in to change notification settings - Fork 27.2k
feat: add ECC-native statusline and context monitor hooks #1504
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f579dad
b5294fc
0f0efd7
aec611a
cf79534
9f9467f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,20 @@ | ||
| { | ||
| "statusLine": { | ||
| "type": "command", | ||
| "command": "input=$(cat); user=$(whoami); cwd=$(echo \"$input\" | jq -r '.workspace.current_dir' | sed \"s|$HOME|~|g\"); model=$(echo \"$input\" | jq -r '.model.display_name'); time=$(date +%H:%M); remaining=$(echo \"$input\" | jq -r '.context_window.remaining_percentage // empty'); transcript=$(echo \"$input\" | jq -r '.transcript_path'); todo_count=$([ -f \"$transcript\" ] && grep -c '\"type\":\"todo\"' \"$transcript\" 2>/dev/null || echo 0); cd \"$(echo \"$input\" | jq -r '.workspace.current_dir')\" 2>/dev/null; branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo ''); status=''; [ -n \"$branch\" ] && { [ -n \"$(git status --porcelain 2>/dev/null)\" ] && status='*'; }; B='\\033[38;2;30;102;245m'; G='\\033[38;2;64;160;43m'; Y='\\033[38;2;223;142;29m'; M='\\033[38;2;136;57;239m'; C='\\033[38;2;23;146;153m'; R='\\033[0m'; T='\\033[38;2;76;79;105m'; printf \"${C}${user}${R}:${B}${cwd}${R}\"; [ -n \"$branch\" ] && printf \" ${G}${branch}${Y}${status}${R}\"; [ -n \"$remaining\" ] && printf \" ${M}ctx:${remaining}%%${R}\"; printf \" ${T}${model}${R} ${Y}${time}${R}\"; [ \"$todo_count\" -gt 0 ] && printf \" ${C}todos:${todo_count}${R}\"; echo", | ||
| "description": "Custom status line showing: user:path branch* ctx:% model time todos:N" | ||
| "command": "node \"<plugin-root>/scripts/hooks/ecc-statusline.js\"", | ||
| "description": "ECC statusline: model | task | $cost tools files duration | dir | context bar" | ||
| }, | ||
| "_comments": { | ||
| "setup": "Replace <plugin-root> with your ECC installation path. For plugin installs, use the resolved path from CLAUDE_PLUGIN_ROOT.", | ||
| "display": "Shows model name, current task, session cost, tool count, files modified, session duration, directory, and context usage bar with color thresholds.", | ||
| "colors": { | ||
| "B": "Blue - directory path", | ||
| "G": "Green - git branch", | ||
| "Y": "Yellow - dirty status, time", | ||
| "M": "Magenta - context remaining", | ||
| "C": "Cyan - username, todos", | ||
| "T": "Gray - model name" | ||
| "green": "Context used < 50%", | ||
| "yellow": "Context used < 65%", | ||
| "orange": "Context used < 80%", | ||
| "red_blink": "Context used >= 80%" | ||
| }, | ||
| "output_example": "affoon:~/projects/myapp main* ctx:73% sonnet-4.6 14:30 todos:3", | ||
| "output_example": "Opus 4.6 | Fixing auth bug | $1.23 47t 5f 15m | myproject ███████░░░ 68%", | ||
| "dependencies": "Reads bridge file from ecc-metrics-bridge.js PostToolUse hook. Both must be installed for full metrics display.", | ||
| "usage": "Copy the statusLine object to your ~/.claude/settings.json" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| #!/usr/bin/env node | ||
| /** | ||
| * ECC Context Monitor — PostToolUse hook | ||
| * | ||
| * Reads bridge file from ecc-metrics-bridge.js and injects agent-facing | ||
| * warnings when thresholds are crossed: context exhaustion, high cost, | ||
| * scope creep, or tool loops. | ||
| */ | ||
|
|
||
| 'use strict'; | ||
|
|
||
| const fs = require('fs'); | ||
| const os = require('os'); | ||
| const path = require('path'); | ||
| const { sanitizeSessionId, readBridge } = require('../lib/session-bridge'); | ||
|
|
||
| const CONTEXT_WARNING_PCT = 35; | ||
| const CONTEXT_CRITICAL_PCT = 25; | ||
| const COST_NOTICE_USD = 5; | ||
| const COST_WARNING_USD = 10; | ||
| const COST_CRITICAL_USD = 50; | ||
| const FILES_WARNING_COUNT = 20; | ||
| const LOOP_THRESHOLD = 3; | ||
| const STALE_SECONDS = 60; | ||
| const DEBOUNCE_CALLS = 5; | ||
|
|
||
| /** | ||
| * Get debounce state file path. | ||
| * @param {string} sessionId | ||
| * @returns {string} | ||
| */ | ||
| function getWarnPath(sessionId) { | ||
| return path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`); | ||
| } | ||
|
|
||
| /** | ||
| * Read debounce state. | ||
| * @param {string} sessionId | ||
| * @returns {object} | ||
| */ | ||
| function readWarnState(sessionId) { | ||
| try { | ||
| return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8')); | ||
| } catch { | ||
| return { callsSinceWarn: 0, lastSeverity: null }; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Write debounce state. | ||
| * @param {string} sessionId | ||
| * @param {object} state | ||
| */ | ||
| function writeWarnState(sessionId, state) { | ||
| fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8'); | ||
| } | ||
|
Comment on lines
+54
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Concurrent PostToolUse invocations (parallel tool calls) can interleave 🤖 Prompt for AI Agents |
||
|
|
||
| /** | ||
| * Detect tool loops from recent_tools ring buffer. | ||
| * @param {Array} recentTools | ||
| * @returns {{detected: boolean, tool: string, count: number}} | ||
| */ | ||
| function detectLoop(recentTools) { | ||
| if (!Array.isArray(recentTools) || recentTools.length < LOOP_THRESHOLD) { | ||
| return { detected: false, tool: '', count: 0 }; | ||
| } | ||
| const counts = {}; | ||
| for (const entry of recentTools) { | ||
| const key = `${entry.tool}:${entry.hash}`; | ||
| counts[key] = (counts[key] || 0) + 1; | ||
| } | ||
| for (const [key, count] of Object.entries(counts)) { | ||
| if (count >= LOOP_THRESHOLD) { | ||
| return { detected: true, tool: key.split(':')[0], count }; | ||
| } | ||
| } | ||
| return { detected: false, tool: '', count: 0 }; | ||
| } | ||
|
|
||
| /** | ||
| * Evaluate all warning conditions against bridge data. | ||
| * Returns array of {severity, type, message} sorted by severity desc. | ||
| */ | ||
| function evaluateConditions(bridge) { | ||
| const warnings = []; | ||
| const remaining = bridge.context_remaining_pct; | ||
|
|
||
| // Context warnings (skip if no context data) | ||
| if (remaining != null) { | ||
| if (remaining <= CONTEXT_CRITICAL_PCT) { | ||
| warnings.push({ | ||
| severity: 3, | ||
| type: 'context', | ||
| message: | ||
| `CONTEXT CRITICAL: ${remaining}% remaining. Context 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 if (remaining <= CONTEXT_WARNING_PCT) { | ||
| warnings.push({ | ||
| severity: 2, | ||
| type: 'context', | ||
| message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.' | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Cost warnings | ||
| const cost = bridge.total_cost_usd || 0; | ||
| if (cost > COST_CRITICAL_USD) { | ||
| warnings.push({ | ||
| severity: 3, | ||
| type: 'cost', | ||
| message: `COST CRITICAL: Session cost is $${cost.toFixed(2)}. ` + 'Stop and inform the user about high cost before continuing.' | ||
| }); | ||
| } else if (cost > COST_WARNING_USD) { | ||
| warnings.push({ | ||
| severity: 2, | ||
| type: 'cost', | ||
| message: `COST WARNING: Session cost is $${cost.toFixed(2)}. ` + 'Review whether the current approach justifies the expense.' | ||
| }); | ||
| } else if (cost > COST_NOTICE_USD) { | ||
| warnings.push({ | ||
| severity: 1, | ||
| type: 'cost', | ||
| message: `COST NOTICE: Session cost is $${cost.toFixed(2)}. ` + 'Consider whether the current approach is efficient.' | ||
| }); | ||
| } | ||
|
|
||
| // File scope warning | ||
| const fileCount = bridge.files_modified_count || 0; | ||
| if (fileCount > FILES_WARNING_COUNT) { | ||
| warnings.push({ | ||
| severity: 2, | ||
| type: 'scope', | ||
| message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.' | ||
| }); | ||
| } | ||
|
|
||
| // Loop detection | ||
| const loop = detectLoop(bridge.recent_tools); | ||
| if (loop.detected) { | ||
| warnings.push({ | ||
| severity: 2, | ||
| type: 'loop', | ||
| message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.' | ||
| }); | ||
| } | ||
|
|
||
| return warnings.sort((a, b) => b.severity - a.severity); | ||
| } | ||
|
|
||
| /** | ||
| * Map numeric severity to label. | ||
| */ | ||
| function severityLabel(n) { | ||
| if (n >= 3) return 'critical'; | ||
| if (n >= 2) return 'warning'; | ||
| return 'notice'; | ||
| } | ||
|
|
||
| /** | ||
| * @param {string} rawInput - Raw JSON string from stdin | ||
| * @returns {string} JSON output with additionalContext or pass-through | ||
| */ | ||
| function run(rawInput) { | ||
| try { | ||
| const input = rawInput.trim() ? JSON.parse(rawInput) : {}; | ||
|
|
||
| const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID); | ||
|
|
||
| if (!sessionId) return rawInput; | ||
|
|
||
| const bridge = readBridge(sessionId); | ||
| if (!bridge) return rawInput; | ||
|
|
||
| // Stale check for context warnings | ||
| const now = Math.floor(Date.now() / 1000); | ||
| const lastTs = bridge.last_timestamp ? Math.floor(new Date(bridge.last_timestamp).getTime() / 1000) : 0; | ||
| const isStale = lastTs > 0 && now - lastTs > STALE_SECONDS; | ||
|
|
||
| // If bridge is stale, null out context data (still check cost/scope/loop) | ||
| const evalBridge = isStale ? { ...bridge, context_remaining_pct: null } : bridge; | ||
|
|
||
| const warnings = evaluateConditions(evalBridge); | ||
| if (warnings.length === 0) return rawInput; | ||
|
|
||
| // Debounce logic | ||
| const warnState = readWarnState(sessionId); | ||
| warnState.callsSinceWarn = (warnState.callsSinceWarn || 0) + 1; | ||
|
|
||
| const topSeverity = severityLabel(warnings[0].severity); | ||
| const severityEscalated = topSeverity === 'critical' && warnState.lastSeverity !== 'critical'; | ||
|
|
||
| const isFirst = !warnState.lastSeverity; | ||
| if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) { | ||
| writeWarnState(sessionId, warnState); | ||
| return rawInput; | ||
| } | ||
|
Comment on lines
+195
to
+199
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Debounce escalation is one-shot for critical. Once 🤖 Prompt for AI Agents |
||
|
|
||
| // Reset debounce, emit warning | ||
| warnState.callsSinceWarn = 0; | ||
| warnState.lastSeverity = topSeverity; | ||
| writeWarnState(sessionId, warnState); | ||
|
|
||
| // Combine top 2 warnings | ||
| const message = warnings | ||
| .slice(0, 2) | ||
| .map(w => w.message) | ||
| .join('\n'); | ||
|
|
||
| const output = { | ||
| hookSpecificOutput: { | ||
| hookEventName: 'PostToolUse', | ||
| additionalContext: message | ||
| } | ||
| }; | ||
|
|
||
| return JSON.stringify(output); | ||
| } catch { | ||
| // Never block tool execution | ||
| return rawInput; | ||
| } | ||
| } | ||
|
|
||
| if (require.main === module) { | ||
| let data = ''; | ||
| const MAX_STDIN = 1024 * 1024; | ||
| process.stdin.setEncoding('utf8'); | ||
| process.stdin.on('data', chunk => { | ||
| if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length); | ||
| }); | ||
| process.stdin.on('end', () => { | ||
| process.stdout.write(run(data)); | ||
| process.exit(0); | ||
| }); | ||
| } | ||
|
|
||
| module.exports = { run, evaluateConditions, detectLoop, severityLabel }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
writeWarnStatedoes a directwriteFileSyncrather than the atomic temp-file + rename pattern used bywriteBridgeAtomic. A crash or SIGTERM mid-write can leave a truncated/corrupt JSON file; the nextreadWarnStatewill return the silent default{ callsSinceWarn: 0, lastSeverity: null }, effectively resetting debounce on every restart. Applying the same atomic write pattern would prevent this.