diff --git a/scripts/hooks/ecc-metrics-bridge.js b/scripts/hooks/ecc-metrics-bridge.js index ccc6307e..f509cade 100644 --- a/scripts/hooks/ecc-metrics-bridge.js +++ b/scripts/hooks/ecc-metrics-bridge.js @@ -11,6 +11,7 @@ const crypto = require('crypto'); const fs = require('fs'); +const os = require('os'); const path = require('path'); const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge'); const { getClaudeDir } = require('../lib/utils'); @@ -19,7 +20,7 @@ const MAX_STDIN = 1024 * 1024; const MAX_FILES_TRACKED = 200; const RECENT_TOOLS_SIZE = 5; const HASH_INPUT_LIMIT = 2048; -const costWarningSignatures = new Map(); +const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-'; function toNumber(value) { const n = Number(value); @@ -77,11 +78,34 @@ function extractFilePaths(toolName, toolInput) { return paths; } -function writeCostWarningOnce(kind, costsPath, signature, message) { - const key = `${kind}:${costsPath}`; - if (costWarningSignatures.get(key) === signature) return; - costWarningSignatures.set(key, signature); +function getCostWarningCachePath(costsPath) { + const hash = crypto.createHash('sha256').update(costsPath).digest('hex').slice(0, 16); + return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${hash}.json`); +} + +function readCostWarningCache(cachePath) { + try { + const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +function writeCostWarningIfChanged(kind, costsPath, signature, message) { + const cachePath = getCostWarningCachePath(costsPath); + const cache = readCostWarningCache(cachePath); + if (cache[kind] === signature) return; + process.stderr.write(message); + try { + const next = { ...cache, [kind]: signature }; + const tmp = `${cachePath}.${process.pid}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(next), 'utf8'); + fs.renameSync(tmp, cachePath); + } catch { + // Warning-cache persistence is best effort; never block hook execution. + } } /** @@ -102,8 +126,9 @@ function writeCostWarningOnce(kind, costsPath, signature, message) { * even cheaper. */ function readSessionCost(sessionId) { - const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl'); + let costsPath = path.join('metrics', 'costs.jsonl'); try { + costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl'); const content = fs.readFileSync(costsPath, 'utf8'); const lines = content.split('\n').filter(Boolean); @@ -111,6 +136,7 @@ function readSessionCost(sessionId) { let totalIn = 0; let totalOut = 0; let malformed = 0; + const malformedHasher = crypto.createHash('sha256'); for (const line of lines) { try { const row = JSON.parse(line); @@ -121,17 +147,18 @@ function readSessionCost(sessionId) { } } catch { malformed += 1; + malformedHasher.update(line).update('\0'); } } // One aggregated breadcrumb per call rather than one per bad row, so a // log-flooded costs.jsonl stays diagnosable without overwhelming stderr. - // Suppress repeats for the same malformed-line count; this hook runs after - // every tool invocation, so a persistent bad row should not spam stderr. + // Suppress repeats for the same malformed-line signature across hook + // subprocesses, so a persistent bad row should not spam stderr. if (malformed > 0) { - writeCostWarningOnce( + writeCostWarningIfChanged( 'malformed', costsPath, - String(malformed), + `${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`, `[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n` ); } @@ -142,10 +169,10 @@ function readSessionCost(sessionId) { // (permission, EISDIR, malformed read) deserves a breadcrumb because // the bridge will silently report zero cost otherwise. if (err && err.code !== 'ENOENT') { - writeCostWarningOnce( + writeCostWarningIfChanged( 'read-error', costsPath, - err.code || err.name || 'error', + `${err.code || err.name || 'error'}:${err.message || String(err)}`, `[ecc-metrics-bridge] failing open after ${err.name || 'error'} reading ${costsPath}: ${err.message || String(err)}\n` ); } diff --git a/tests/hooks/ecc-metrics-bridge.test.js b/tests/hooks/ecc-metrics-bridge.test.js index d1b40b72..cdd99535 100644 --- a/tests/hooks/ecc-metrics-bridge.test.js +++ b/tests/hooks/ecc-metrics-bridge.test.js @@ -5,6 +5,7 @@ */ const assert = require('assert'); +const { spawnSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -277,6 +278,48 @@ function runTests() { passed++; else failed++; + if ( + test('readSessionCost suppresses repeated malformed breadcrumbs across hook subprocesses', () => { + const tmpHome = makeTempHome(); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + try { + process.env.HOME = tmpHome; + process.env.USERPROFILE = tmpHome; + const metricsDir = path.join(tmpHome, '.claude', 'metrics'); + fs.mkdirSync(metricsDir, { recursive: true }); + fs.writeFileSync( + path.join(metricsDir, 'costs.jsonl'), + [ + JSON.stringify({ session_id: 'S1', estimated_cost_usd: 0.7, input_tokens: 700, output_tokens: 350 }), + 'NOT_JSON', + '{"truncated":' + ].join('\n') + '\n', + 'utf8' + ); + + const bridgePath = path.resolve(__dirname, '../../scripts/hooks/ecc-metrics-bridge'); + const code = "const { readSessionCost } = require(process.argv[1]); readSessionCost('S1');"; + const env = { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome }; + const first = spawnSync(process.execPath, ['-e', code, bridgePath], { env, encoding: 'utf8' }); + const second = spawnSync(process.execPath, ['-e', code, bridgePath], { env, encoding: 'utf8' }); + + assert.strictEqual(first.status, 0, first.stderr || first.stdout); + assert.strictEqual(second.status, 0, second.stderr || second.stdout); + assert.match(first.stderr, /skipped 2 malformed line\(s\)/); + assert.strictEqual(second.stderr, '', `expected repeat subprocess warning suppression, got: ${second.stderr}`); + } finally { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = originalUserProfile; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( test('readSessionCost stays silent when costs.jsonl does not exist (ENOENT)', () => { // ENOENT is the common case before any Stop event has fired — it is