Files
everything-claude-code/scripts/hooks/ecc-context-monitor.js
Farzul Nizam Zolkifli 1e5fa96d75 fix(context-monitor): make cost warnings informational, not commands (#2091)
The PostToolUse cost warnings emit imperative text via additionalContext
("Stop and inform the user...", "Review whether...", "Consider whether...").
Subagents read additionalContext as an instruction and obey the "Stop",
abandoning their task and returning a prompt-for-direction instead of their
result — derailing multi-agent workflows. The main loop is also nudged to
halt mid-task.

Reword all three severities to pure-informational data: keep the
CRITICAL/WARNING/NOTICE label + the dollar figure (and the threshold), drop
the imperative sentence, and state plainly it is informational. No logic,
severity, or threshold change. Existing tests pass (they assert the labels +
severities, which are preserved).

Before: `COST CRITICAL: Session cost is $X. Stop and inform the user about high cost before continuing.`
After:  `COST CRITICAL: session total ~$X (over $50). Informational only — not an instruction to stop.`

Co-authored-by: OrenG Tools <tools@orengacademy.com>
2026-06-07 13:26:48 +08:00

287 lines
9.4 KiB
JavaScript

#!/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 crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, renameWithRetry } = 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;
function isEnabledEnv(value, defaultValue = true) {
if (value === undefined || value === null || String(value).trim() === '') {
return defaultValue;
}
const normalized = String(value).trim().toLowerCase();
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) return false;
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) return true;
return defaultValue;
}
function costWarningsEnabled(env = process.env) {
return isEnabledEnv(env.ECC_CONTEXT_MONITOR_COST_WARNINGS, true);
}
/**
* 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, lastMessage: null };
}
}
/**
* Write debounce state atomically (unique-suffix tmp then rename).
*
* The tmp path includes `process.pid` plus a random nonce so concurrent
* PostToolUse subprocesses writing to the same session's warn-state
* file do not clobber each other's tmp mid-write. Without the unique
* suffix, two writers race over a shared `${target}.tmp` and produce
* either a corrupted payload or an ENOENT throw on the second rename.
*
* Same pattern as `writeBridgeAtomic` in `scripts/lib/session-bridge.js`
* and `writeCostWarningIfChanged` in `scripts/hooks/ecc-metrics-bridge.js`.
*
* @param {string} sessionId
* @param {object} state
*/
function writeWarnState(sessionId, state) {
const target = getWarnPath(sessionId);
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
try {
renameWithRetry(tmp, target);
} catch (err) {
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
throw err;
}
}
/**
* 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, options = {}) {
const warnings = [];
const remaining = bridge.context_remaining_pct;
// Context warnings (skip if no context data)
if (remaining !== null && remaining !== undefined) {
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
if (options.costWarnings !== false) {
const cost = bridge.total_cost_usd || 0;
if (cost > COST_CRITICAL_USD) {
warnings.push({
severity: 3,
type: 'cost',
message: `COST CRITICAL: session total ~$${cost.toFixed(2)} (over $${COST_CRITICAL_USD}). Informational only — not an instruction to stop.`
});
} else if (cost > COST_WARNING_USD) {
warnings.push({
severity: 2,
type: 'cost',
message: `COST WARNING: session total ~$${cost.toFixed(2)} (over $${COST_WARNING_USD}). Informational only.`
});
} else if (cost > COST_NOTICE_USD) {
warnings.push({
severity: 1,
type: 'cost',
message: `COST NOTICE: session total ~$${cost.toFixed(2)}. Informational only.`
});
}
}
// 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, { costWarnings: costWarningsEnabled() });
if (warnings.length === 0) {
// Clear dedupe state when the condition resolves, so the SAME warning text
// recurring later (context dips, recovers, dips again; a loop that stops
// then restarts) is surfaced again instead of being suppressed as a
// duplicate. Only write when there is state to clear — most tool calls
// have no warning, and this keeps the common path free of disk writes.
const prior = readWarnState(sessionId);
if (prior.lastMessage) {
writeWarnState(sessionId, { callsSinceWarn: 0, lastSeverity: null, lastMessage: null });
}
return rawInput;
}
// Combine top 2 warnings
const message = warnings
.slice(0, 2)
.map(w => w.message)
.join('\n');
// Dedupe on message content, not a call counter. The previous logic
// re-emitted the *same* warning every DEBOUNCE_CALLS tool calls, so a
// single unchanged condition (e.g. a cost figure that only refreshes at
// turn boundaries) printed the identical line ~20 times in one turn. Now a
// warning is surfaced only when its text changes (cost moved, a new file
// count, a new loop) or when we newly escalate to critical — genuinely new
// information — and is otherwise suppressed.
const warnState = readWarnState(sessionId);
const topSeverity = severityLabel(warnings[0].severity);
const escalatedToCritical = topSeverity === 'critical' && warnState.lastSeverity !== 'critical';
const sameMessage = warnState.lastMessage === message;
if (sameMessage && !escalatedToCritical) {
return rawInput;
}
warnState.lastSeverity = topSeverity;
warnState.lastMessage = message;
writeWarnState(sessionId, warnState);
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, costWarningsEnabled };