feat: add ECC-native statusline and context monitor hooks#1504
feat: add ECC-native statusline and context monitor hooks#1504ulinzeng wants to merge 6 commits intoaffaan-m:mainfrom
Conversation
Extract estimateCost() into scripts/lib/cost-estimate.js for reuse across cost-tracker and ecc-metrics-bridge hooks. Add scripts/lib/session-bridge.js with atomic bridge file I/O, session ID sanitization, and path traversal prevention.
Replace inline estimateCost() in cost-tracker.js with import from scripts/lib/cost-estimate.js. No behavior change.
Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json
with cost, tool count, files modified, and recent tool ring buffer for
loop detection. Bridge file is read by ecc-statusline and ecc-context-monitor.
statusLine command showing model, current task, session cost, tool count, files modified, session duration, and context usage bar with color thresholds (green/yellow/orange/red).
PostToolUse hook injecting agent-facing warnings for: - Context exhaustion (35% WARNING, 25% CRITICAL) - Session cost ($5 NOTICE, $10 WARNING, $50 CRITICAL) - Scope creep (>20 files modified) - Tool loops (same tool+params 3x in last 5 calls) Includes debounce (5 calls between warnings) with severity escalation bypass.
Add post:ecc-metrics-bridge and post:ecc-context-monitor to PostToolUse hooks. Update examples/statusline.json with ECC-native statusline config.
|
ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR. |
📝 WalkthroughWalkthroughThis PR implements a comprehensive session monitoring and metrics system for Claude through three new PostToolUse hooks (ecc-metrics-bridge, ecc-context-monitor, ecc-statusline) backed by shared libraries (session-bridge, cost-estimate). It refactors the statusline from a Bash pipeline to a Node-based implementation and extracts cost estimation to a reusable module. Includes full test coverage for all new functionality. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR introduces an ECC-native statusline (
Confidence Score: 4/5Two P1 logic bugs in the metrics bridge should be fixed before merging to avoid false loop warnings and inflated cost alerts. The PR is well-structured with solid test coverage and good security hygiene (session ID sanitization, atomic writes). Two P1 issues exist: the hash collision for non-file tools causes real false positives in normal use, and the "default" session row aggregation can prematurely fire cost threshold warnings. scripts/hooks/ecc-metrics-bridge.js — hashToolCall and readSessionCost both have logic bugs Important Files Changed
Sequence DiagramsequenceDiagram
participant CC as Claude Code Runtime
participant MB as ecc-metrics-bridge.js (PostToolUse)
participant CM as ecc-context-monitor.js (PostToolUse)
participant BF as /tmp/ecc-metrics-session.json
participant SL as ecc-statusline.js (statusLine)
participant CT as cost-tracker.js (PostToolUse)
participant JSONL as ~/.claude/metrics/costs.jsonl
CC->>CT: tool result (stdin)
CT->>JSONL: append cost row (session_id from env)
CT->>CC: pass-through stdout
CC->>MB: tool result (stdin)
MB->>JSONL: read last 8KB (readSessionCost)
MB->>BF: writeBridgeAtomic (tool_count, files, cost, recent_tools)
MB->>CC: pass-through stdout
CC->>CM: tool result (stdin)
CM->>BF: readBridge (context_remaining_pct, cost, files, recent_tools)
CM->>CM: evaluateConditions + debounce
alt warnings exist
CM->>CC: JSON with additionalContext warnings
else no warnings
CM->>CC: pass-through stdout
end
CC->>SL: status JSON (stdin: model, session, context%)
SL->>BF: readBridge (cost, tool_count, files, duration)
SL->>BF: writeBridgeAtomic (update context_remaining_pct)
SL->>CC: ANSI statusline string (stdout)
Reviews (1): Last reviewed commit: "chore: register new hooks in hooks.json ..." | Re-trigger Greptile |
| function hashToolCall(toolName, toolInput) { | ||
| const name = String(toolName || ''); | ||
| let key = ''; | ||
| if (name === 'Bash') { | ||
| key = String(toolInput?.command || '').slice(0, 80); | ||
| } else { | ||
| key = String(toolInput?.file_path || ''); | ||
| } | ||
| return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8); | ||
| } |
There was a problem hiding this comment.
False loop detection for tools without
file_path
Any tool that is not Bash and does not carry a file_path in its input (e.g. Glob, WebFetch, TodoRead) will always produce the same hash because key resolves to the empty string. Three consecutive Glob calls with completely different patterns would share the hash Glob: and trip the LOOP WARNING. With RECENT_TOOLS_SIZE = 5 and LOOP_THRESHOLD = 3, this fires easily in normal usage that mixes several non-file tools.
Consider falling back to a short digest of the full toolInput JSON for unrecognised tool names, instead of '', so different parameters produce different hashes.
| for (const line of lines) { | ||
| try { | ||
| const row = JSON.parse(line); | ||
| if (row.session_id === sessionId || row.session_id === 'default') { | ||
| totalCost += toNumber(row.estimated_cost_usd); | ||
| totalIn += toNumber(row.input_tokens); | ||
| totalOut += toNumber(row.output_tokens); |
There was a problem hiding this comment.
"default" rows always aggregated, inflating cost for named sessions
row.session_id === 'default' is always matched regardless of what envSessionId is. If any rows were written with "default" (which cost-tracker.js does when the env vars are unset) they will be permanently counted against every subsequent named session, potentially triggering premature COST WARNING / CRITICAL messages. The condition should only include the default fallback when the caller is actually looking up the default session:
| for (const line of lines) { | |
| try { | |
| const row = JSON.parse(line); | |
| if (row.session_id === sessionId || row.session_id === 'default') { | |
| totalCost += toNumber(row.estimated_cost_usd); | |
| totalIn += toNumber(row.input_tokens); | |
| totalOut += toNumber(row.output_tokens); | |
| if (row.session_id === sessionId) { |
| function runStatusline() { | ||
| let input = ''; | ||
| const stdinTimeout = setTimeout(() => process.exit(0), 3000); | ||
| process.stdin.setEncoding('utf8'); | ||
| process.stdin.on('data', chunk => (input += chunk)); | ||
| process.stdin.on('end', () => { | ||
| clearTimeout(stdinTimeout); |
There was a problem hiding this comment.
Unlike ecc-metrics-bridge.js and ecc-context-monitor.js (which both guard with MAX_STDIN), runStatusline accumulates stdin without any limit. The 3-second timeout guards against indefinite blocking, but memory can grow unbounded before the timeout fires. Consider applying the same MAX_STDIN guard used in the sibling hooks for consistency.
| function writeWarnState(sessionId, state) { | ||
| fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8'); | ||
| } |
There was a problem hiding this comment.
Non-atomic write for debounce state
writeWarnState does a direct writeFileSync rather than the atomic temp-file + rename pattern used by writeBridgeAtomic. A crash or SIGTERM mid-write can leave a truncated/corrupt JSON file; the next readWarnState will return the silent default { callsSinceWarn: 0, lastSeverity: null }, effectively resetting debounce on every restart. Applying the same atomic write pattern would prevent this.
| } | ||
|
|
||
| // Current task | ||
| const task = session ? readCurrentTask(session) : ''; |
There was a problem hiding this comment.
Raw session ID passed to
readCurrentTask
sanitizeSessionId(session) is called just above (stored as sessionId), but the raw session string is passed to readCurrentTask instead. Inside readCurrentTask the parameter is used as a String.startsWith prefix against filenames (not for path construction), so there is no path traversal risk, but it is inconsistent with the defensive sanitization applied everywhere else. Consider passing the already-sanitised sessionId:
| const task = session ? readCurrentTask(session) : ''; | |
| const task = sessionId ? readCurrentTask(sessionId) : ''; |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (3)
hooks/hooks.json (1)
223-246: Consider"async": truefor these PostToolUse hooks.Both hooks use
matcher: "*"so they fire on every tool invocation, yet neither setsasync: true(comparepost:observe:continuous-learningat line 215 andpost:bash:dispatcherat line 131). Any filesystem hiccup up to the 10s timeout will block the user between tool calls.ecc-metrics-bridgewrites state the statusline/context-monitor read, but since the statusline re-reads on each render and the context-monitor runs on the next PostToolUse anyway, async execution should be safe and noticeably snappier.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@hooks/hooks.json` around lines 223 - 246, The two PostToolUse hook entries with ids post:ecc-metrics-bridge and post:ecc-context-monitor are synchronous and can block tool execution on filesystem delays; update each hook object (the objects that contain "type": "command", "command": "...", "timeout": 10) to include "async": true so they run asynchronously (match the pattern used by post:observe:continuous-learning and post:bash:dispatcher) to avoid blocking the user between tool calls.scripts/lib/cost-estimate.js (1)
9-13: Consider documenting rate source/version.These per-1M rates will drift as Anthropic updates pricing. A short comment pointing to the source (docs URL + date pulled) makes future maintenance auditable. Purely optional.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/lib/cost-estimate.js` around lines 9 - 13, Add a short comment above the RATE_TABLE constant documenting the source and timestamp of these per-1M rates (e.g., Anthropic pricing docs URL and date retrieved) so future maintainers can audit when/where values for RATE_TABLE (and its keys haiku, sonnet, opus) were taken from and update when pricing changes; include version or release note if available and a short note describing units ("per 1M tokens, in/cost out") for clarity.tests/hooks/ecc-statusline.test.js (1)
108-133: Tests are tightly coupled toAUTO_COMPACT_BUFFER_PCT.
buildContextBarscalesremainingby the buffer constant before deciding color, so these threshold assertions silently depend onAUTO_COMPACT_BUFFER_PCT's current value. Changing that constant could shift a test from green→yellow without obvious signal. Consider asserting behavior in terms of the computedusedvalue, or documenting the assumed buffer value in the test, so the coupling is explicit.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/hooks/ecc-statusline.test.js` around lines 108 - 133, Tests rely on buildContextBar's color thresholds but implicitly depend on AUTO_COMPACT_BUFFER_PCT, so update the tests to either compute the scaled/used value explicitly or assert against the computed used value instead of raw remaining; locate the tests around buildContextBar in ecc-statusline.test.js and change the three assertions (80%, 50%, 20%) to compute used = remaining * AUTO_COMPACT_BUFFER_PCT (or import the constant) and assert the expected ANSI color for that used value, or alternatively add a comment/docstring near those assertions stating the assumed AUTO_COMPACT_BUFFER_PCT value to make the coupling explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@scripts/hooks/ecc-context-monitor.js`:
- Around line 195-199: The debounce logic never clears warnState.lastSeverity
when the warning set becomes empty, so a later new 'critical' spike won't be
treated as an escalation; modify the early-return branch that handles
warnings.length === 0 to reset the stored state (e.g., set
warnState.lastSeverity = null or '' and update any related flag like
severityEscalated if needed) and call writeWarnState(sessionId, warnState)
before returning rawInput so future critical spikes re-escalate immediately;
reference warnState, lastSeverity, severityEscalated, warnings.length,
writeWarnState, DEBOUNCE_CALLS, sessionId, and rawInput when making the change.
- Around line 54-56: writeWarnState currently writes directly with
fs.writeFileSync causing race conditions; change it to perform an atomic
write-temp-then-rename using the same pattern as
session-bridge.writeBridgeAtomic: serialize the state (JSON.stringify) to a temp
file in the same directory (e.g., getWarnPath(sessionId) +
`.tmp.${pid}.${random}`), fs.writeFileSync to that temp file, optionally
fs.fsyncSync the fd for durability, then fs.renameSync to move the temp file to
getWarnPath(sessionId); ensure errors are caught and cleaned up (remove temp on
failure) so concurrent PostToolUse invocations can't interleave and corrupt the
final JSON file.
In `@scripts/hooks/ecc-metrics-bridge.js`:
- Around line 73-78: The 8KB tail-read using readSize causes
bridge.total_cost_usd to reflect only the last chunk; modify the logic around
costsPath/readSize/fs.openSync/fs.readSync so the bridge maintains a persistent
lastSeenOffset per costsPath (or per session) and on each invocation reads only
the new bytes from Math.max(0, stat.size - lastSeenOffset) (or reads the whole
file when stat.size is below a safe threshold), parses only appended lines to
compute a delta, and increments bridge.total_cost_usd by that delta rather than
re-summing the last 8KB buffer; ensure lastSeenOffset is updated after
successful read so cost totals remain cumulative for long-running sessions used
by ecc-context-monitor.js.
- Line 15: Remove the unused import "estimateCost" by deleting the require line
that destructures estimateCost (const { estimateCost } =
require('../lib/cost-estimate');) from the top of the module; confirm there are
no other references to estimateCost (the code uses readSessionCost to source
costs) and run lint to ensure the unused-import warning is resolved.
- Around line 158-163: The cost aggregation currently uses an env-derived
envSessionId and calls readSessionCost(envSessionId), which can diverge from the
sanitized input.session_id used elsewhere; replace that by calling
readSessionCost with the existing sanitized sessionId (the variable named
sessionId used throughout this function) so bridge.total_cost_usd,
bridge.total_input_tokens and bridge.total_output_tokens are computed from the
same session id as the rest of the function (remove or stop using envSessionId
and pass sessionId into readSessionCost).
In `@scripts/hooks/ecc-statusline.js`:
- Around line 68-72: The filename filter using f.startsWith(sessionId) can
incorrectly match sessions with shared prefixes; update the filter in the files
construction (the .filter(...) that currently uses startsWith(sessionId) and
checks '-agent-' and '.json') to require the separator after the session id
(e.g., use startsWith(sessionId + '-') or a regex like new RegExp('^' +
escape(sessionId) + '-')) so only files that have the sessionId followed by a
dash are matched; keep the rest of the chain (includes('-agent-'),
endsWith('.json'), statSync/mapping/sort) unchanged.
- Around line 99-109: The current read-modify-write in the statusline uses
readBridge(sessionId) then writeBridgeAtomic(sessionId, bridge), which can
overwrite concurrent updates; change the flow in the statusline to re-read the
bridge immediately before writing and merge only the single field
context_remaining_pct into the freshly read object (i.e., call
readBridge(sessionId) again into a freshBridge, set
freshBridge.context_remaining_pct = remaining, then writeBridgeAtomic(sessionId,
freshBridge)); this preserves any concurrent updates (from
ecc-metrics-bridge.js) and avoids stomping other metrics—alternatively implement
a tiny separate file for context_remaining_pct or add file-locking in
session-bridge.js, but the minimal fix is the read-merge-write described above
using readBridge, writeBridgeAtomic, sessionId, bridge, and
context_remaining_pct.
In `@scripts/lib/session-bridge.js`:
- Around line 23-28: The current sanitizeSessionId function rejects any input
containing two consecutive dots which disallows valid IDs; tighten the check so
it only rejects path-traversal occurrences (.. as a path segment) instead of any
substring '..'. Modify the conditional that currently tests /[/\\]|\.\./ to
instead test for path-traversal like /[/\\]|(^|[/\\])\.\.([/\\]|$)/ (or remove
the \.\. check entirely) so sanitizeSessionId still blocks '/' and '\' but
accepts IDs such as "session..1"; keep the subsequent replace(...).slice(0,
MAX_SESSION_ID_LENGTH) and return logic unchanged.
- Around line 58-63: writeBridgeAtomic currently writes to a deterministic tmp
path `${target}.tmp`, causing races when concurrent writers call
writeBridgeAtomic; change it to use a unique temporary filename per invocation
(e.g., include process.pid, Date.now(), and a random suffix or use a secure temp
helper) instead of `${target}.tmp`, write the JSON to that unique tmp file and
then renameSync to `target`; ensure the unique tmp is created in the same
directory as `target` so rename stays atomic and consider removing the tmp on
error to avoid stale files (reference function writeBridgeAtomic and the
variables target/tmp).
---
Nitpick comments:
In `@hooks/hooks.json`:
- Around line 223-246: The two PostToolUse hook entries with ids
post:ecc-metrics-bridge and post:ecc-context-monitor are synchronous and can
block tool execution on filesystem delays; update each hook object (the objects
that contain "type": "command", "command": "...", "timeout": 10) to include
"async": true so they run asynchronously (match the pattern used by
post:observe:continuous-learning and post:bash:dispatcher) to avoid blocking the
user between tool calls.
In `@scripts/lib/cost-estimate.js`:
- Around line 9-13: Add a short comment above the RATE_TABLE constant
documenting the source and timestamp of these per-1M rates (e.g., Anthropic
pricing docs URL and date retrieved) so future maintainers can audit when/where
values for RATE_TABLE (and its keys haiku, sonnet, opus) were taken from and
update when pricing changes; include version or release note if available and a
short note describing units ("per 1M tokens, in/cost out") for clarity.
In `@tests/hooks/ecc-statusline.test.js`:
- Around line 108-133: Tests rely on buildContextBar's color thresholds but
implicitly depend on AUTO_COMPACT_BUFFER_PCT, so update the tests to either
compute the scaled/used value explicitly or assert against the computed used
value instead of raw remaining; locate the tests around buildContextBar in
ecc-statusline.test.js and change the three assertions (80%, 50%, 20%) to
compute used = remaining * AUTO_COMPACT_BUFFER_PCT (or import the constant) and
assert the expected ANSI color for that used value, or alternatively add a
comment/docstring near those assertions stating the assumed
AUTO_COMPACT_BUFFER_PCT value to make the coupling explicit.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 42fedb05-4079-40d1-bba7-589d271a0c58
📒 Files selected for processing (13)
examples/statusline.jsonhooks/hooks.jsonscripts/hooks/cost-tracker.jsscripts/hooks/ecc-context-monitor.jsscripts/hooks/ecc-metrics-bridge.jsscripts/hooks/ecc-statusline.jsscripts/lib/cost-estimate.jsscripts/lib/session-bridge.jstests/hooks/ecc-context-monitor.test.jstests/hooks/ecc-metrics-bridge.test.jstests/hooks/ecc-statusline.test.jstests/lib/cost-estimate.test.jstests/lib/session-bridge.test.js
| function writeWarnState(sessionId, state) { | ||
| fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8'); | ||
| } |
There was a problem hiding this comment.
writeWarnState is not atomic.
Concurrent PostToolUse invocations (parallel tool calls) can interleave writeFileSync on the same path, corrupting the JSON and causing readWarnState to silently reset debounce state on the next call. Use a write-temp-then-rename pattern as in session-bridge.writeBridgeAtomic for consistency and robustness.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-context-monitor.js` around lines 54 - 56, writeWarnState
currently writes directly with fs.writeFileSync causing race conditions; change
it to perform an atomic write-temp-then-rename using the same pattern as
session-bridge.writeBridgeAtomic: serialize the state (JSON.stringify) to a temp
file in the same directory (e.g., getWarnPath(sessionId) +
`.tmp.${pid}.${random}`), fs.writeFileSync to that temp file, optionally
fs.fsyncSync the fd for durability, then fs.renameSync to move the temp file to
getWarnPath(sessionId); ensure errors are caught and cleaned up (remove temp on
failure) so concurrent PostToolUse invocations can't interleave and corrupt the
final JSON file.
| const isFirst = !warnState.lastSeverity; | ||
| if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) { | ||
| writeWarnState(sessionId, warnState); | ||
| return rawInput; | ||
| } |
There was a problem hiding this comment.
Debounce escalation is one-shot for critical.
Once lastSeverity is set to 'critical', severityEscalated becomes false forever (since the condition is !== 'critical'), so subsequent sustained-critical calls will only re-emit every DEBOUNCE_CALLS tool calls. That is likely fine, but note the inverse: if severity drops from critical back to warning, the next critical spike will correctly re-escalate only after lastSeverity decays — which it never does here. Consider resetting lastSeverity when the warning set is empty (currently warnings.length === 0 returns early without touching state), so a repeat critical after a clean period escalates immediately.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-context-monitor.js` around lines 195 - 199, The debounce
logic never clears warnState.lastSeverity when the warning set becomes empty, so
a later new 'critical' spike won't be treated as an escalation; modify the
early-return branch that handles warnings.length === 0 to reset the stored state
(e.g., set warnState.lastSeverity = null or '' and update any related flag like
severityEscalated if needed) and call writeWarnState(sessionId, warnState)
before returning rawInput so future critical spikes re-escalate immediately;
reference warnState, lastSeverity, severityEscalated, warnings.length,
writeWarnState, DEBOUNCE_CALLS, sessionId, and rawInput when making the change.
| const crypto = require('crypto'); | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { estimateCost } = require('../lib/cost-estimate'); |
There was a problem hiding this comment.
Remove unused estimateCost import.
ESLint flags this as unused; cost is sourced from costs.jsonl via readSessionCost, so the import can go.
-const { estimateCost } = require('../lib/cost-estimate');📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { estimateCost } = require('../lib/cost-estimate'); |
🧰 Tools
🪛 ESLint
[error] 15-15: 'estimateCost' is assigned a value but never used. Allowed unused vars must match /^_/u.
(no-unused-vars)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-metrics-bridge.js` at line 15, Remove the unused import
"estimateCost" by deleting the require line that destructures estimateCost
(const { estimateCost } = require('../lib/cost-estimate');) from the top of the
module; confirm there are no other references to estimateCost (the code uses
readSessionCost to source costs) and run lint to ensure the unused-import
warning is resolved.
| const readSize = Math.min(stat.size, 8192); | ||
| const fd = fs.openSync(costsPath, 'r'); | ||
| try { | ||
| const buf = Buffer.alloc(readSize); | ||
| fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize)); | ||
| const lines = buf.toString('utf8').split('\n').filter(Boolean); |
There was a problem hiding this comment.
8 KB tail may not cover a whole session on long runs.
With the readSize = min(stat.size, 8192) tail-read strategy, once costs.jsonl grows past 8 KB the running total becomes "last 8 KB only" rather than a true cumulative session cost. For sessions that generate more than a few hundred log rows, bridge.total_cost_usd will plateau or even decrease as older rows scroll out, which will misdrive the cost thresholds in ecc-context-monitor.js. Consider either (a) tracking cumulative cost incrementally in the bridge (add the delta from the last-seen file offset), or (b) reading the whole file filtered by sessionId when size is modest and falling back to the bridge's last known total otherwise.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-metrics-bridge.js` around lines 73 - 78, The 8KB tail-read
using readSize causes bridge.total_cost_usd to reflect only the last chunk;
modify the logic around costsPath/readSize/fs.openSync/fs.readSync so the bridge
maintains a persistent lastSeenOffset per costsPath (or per session) and on each
invocation reads only the new bytes from Math.max(0, stat.size - lastSeenOffset)
(or reads the whole file when stat.size is below a safe threshold), parses only
appended lines to compute a delta, and increments bridge.total_cost_usd by that
delta rather than re-summing the last 8KB buffer; ensure lastSeenOffset is
updated after successful read so cost totals remain cumulative for long-running
sessions used by ecc-context-monitor.js.
| // Update cost from costs.jsonl tail | ||
| const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default'); | ||
| const costs = readSessionCost(envSessionId); | ||
| bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6; | ||
| bridge.total_input_tokens = costs.totalIn; | ||
| bridge.total_output_tokens = costs.totalOut; |
There was a problem hiding this comment.
Cost aggregation uses env-derived session id, not the sanitized input session id.
sessionId (from input.session_id, sanitized) is used everywhere else in this function, but cost aggregation re-derives a different id from env vars with a 'default' fallback. If input.session_id is provided but ECC_SESSION_ID/CLAUDE_SESSION_ID are unset, readSessionCost('default') will match only rows logged as 'default' in costs.jsonl, missing all rows written with the actual session id (and vice versa). This causes incorrect total_cost_usd in the bridge, which then drives the context-monitor's cost thresholds.
Proposed fix
- // Update cost from costs.jsonl tail
- const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
- const costs = readSessionCost(envSessionId);
+ // Update cost from costs.jsonl tail
+ const costs = readSessionCost(sessionId);Note that readSessionCost already OR-matches row.session_id === 'default', so passing the real sessionId still covers the env-less case that cost-tracker.js logs as 'default'.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-metrics-bridge.js` around lines 158 - 163, The cost
aggregation currently uses an env-derived envSessionId and calls
readSessionCost(envSessionId), which can diverge from the sanitized
input.session_id used elsewhere; replace that by calling readSessionCost with
the existing sanitized sessionId (the variable named sessionId used throughout
this function) so bridge.total_cost_usd, bridge.total_input_tokens and
bridge.total_output_tokens are computed from the same session id as the rest of
the function (remove or stop using envSessionId and pass sessionId into
readSessionCost).
| const files = fs | ||
| .readdirSync(todosDir) | ||
| .filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json')) | ||
| .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime })) | ||
| .sort((a, b) => b.mtime - a.mtime); |
There was a problem hiding this comment.
startsWith(sessionId) can match unrelated todo files.
If two session IDs share a common prefix (e.g., abc123 and abc1234), f.startsWith(sessionId) will pick up todos from the other session. Tighten the match, e.g. f.startsWith(sessionId + '-') to require the separator that precedes -agent-.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-statusline.js` around lines 68 - 72, The filename filter
using f.startsWith(sessionId) can incorrectly match sessions with shared
prefixes; update the filter in the files construction (the .filter(...) that
currently uses startsWith(sessionId) and checks '-agent-' and '.json') to
require the separator after the session id (e.g., use startsWith(sessionId +
'-') or a regex like new RegExp('^' + escape(sessionId) + '-')) so only files
that have the sessionId followed by a dash are matched; keep the rest of the
chain (includes('-agent-'), endsWith('.json'), statSync/mapping/sort) unchanged.
| const bridge = sessionId ? readBridge(sessionId) : null; | ||
|
|
||
| // Write context % back to bridge for context-monitor | ||
| if (sessionId && bridge && remaining != null) { | ||
| bridge.context_remaining_pct = remaining; | ||
| try { | ||
| writeBridgeAtomic(sessionId, bridge); | ||
| } catch { | ||
| /* best effort */ | ||
| } | ||
| } |
There was a problem hiding this comment.
Read-modify-write race can stomp metrics-bridge updates.
readBridge at line 99 and writeBridgeAtomic at line 105 are not atomic together. The statusline runs independently of hooks, so if ecc-metrics-bridge.js updates the bridge (e.g., tool_count, files_modified, recent_tools, cost totals) between these two calls, this write will persist the stale snapshot read here with only context_remaining_pct overlaid, silently dropping the metrics-bridge update. Because statusline is invoked frequently while tools are executing, this collision is realistic, not theoretical.
Consider either (a) re-reading right before the write and merging only context_remaining_pct, (b) having the statusline write context_remaining_pct to a separate tiny file that the context-monitor/metrics-bridge consult, or (c) adding a file-lock around read-modify-write in session-bridge.js.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/hooks/ecc-statusline.js` around lines 99 - 109, The current
read-modify-write in the statusline uses readBridge(sessionId) then
writeBridgeAtomic(sessionId, bridge), which can overwrite concurrent updates;
change the flow in the statusline to re-read the bridge immediately before
writing and merge only the single field context_remaining_pct into the freshly
read object (i.e., call readBridge(sessionId) again into a freshBridge, set
freshBridge.context_remaining_pct = remaining, then writeBridgeAtomic(sessionId,
freshBridge)); this preserves any concurrent updates (from
ecc-metrics-bridge.js) and avoids stomping other metrics—alternatively implement
a tiny separate file for context_remaining_pct or add file-locking in
session-bridge.js, but the minimal fix is the read-merge-write described above
using readBridge, writeBridgeAtomic, sessionId, bridge, and
context_remaining_pct.
| function sanitizeSessionId(raw) { | ||
| if (!raw || typeof raw !== 'string') return null; | ||
| if (/[/\\]|\.\./.test(raw)) return null; | ||
| const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH); | ||
| return safe || null; | ||
| } |
There was a problem hiding this comment.
sanitizeSessionId rejects valid IDs containing ...
The \.\. branch rejects any string with two consecutive dots (e.g., session..1, UUID-like inputs from external callers), not only path-traversal sequences. Since the subsequent replace(/[^a-zA-Z0-9_-]/g, '_') already neutralizes dots, the \.\. check is redundant for safety and only exists to reject traversal — but / and \ are already blocked, so .. alone cannot escape os.tmpdir(). Consider dropping the \.\. clause or tightening it to ^\.\.$ / (^|[/\\])\.\.([/\\]|$) to avoid false rejections.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/lib/session-bridge.js` around lines 23 - 28, The current
sanitizeSessionId function rejects any input containing two consecutive dots
which disallows valid IDs; tighten the check so it only rejects path-traversal
occurrences (.. as a path segment) instead of any substring '..'. Modify the
conditional that currently tests /[/\\]|\.\./ to instead test for path-traversal
like /[/\\]|(^|[/\\])\.\.([/\\]|$)/ (or remove the \.\. check entirely) so
sanitizeSessionId still blocks '/' and '\' but accepts IDs such as "session..1";
keep the subsequent replace(...).slice(0, MAX_SESSION_ID_LENGTH) and return
logic unchanged.
| function writeBridgeAtomic(sessionId, data) { | ||
| const target = getBridgePath(sessionId); | ||
| const tmp = `${target}.tmp`; | ||
| fs.writeFileSync(tmp, JSON.stringify(data), 'utf8'); | ||
| fs.renameSync(tmp, target); | ||
| } |
There was a problem hiding this comment.
Concurrent writers can race on the shared .tmp path.
The statusline and ecc-metrics-bridge hook both call writeBridgeAtomic for the same session, and the tmp path is deterministic (${target}.tmp). If two invocations overlap (statusline refresh while a PostToolUse runs), they'll interleave writeFileSync contents and both call renameSync, risking a corrupted bridge file (the "atomic" guarantee only holds for a single writer).
Use a unique tmp suffix per call:
🔒 Suggested fix
function writeBridgeAtomic(sessionId, data) {
const target = getBridgePath(sessionId);
- const tmp = `${target}.tmp`;
- fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
- fs.renameSync(tmp, target);
+ const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
+ try {
+ fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
+ fs.renameSync(tmp, target);
+ } catch (err) {
+ try { fs.unlinkSync(tmp); } catch { /* ignore */ }
+ throw err;
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function writeBridgeAtomic(sessionId, data) { | |
| const target = getBridgePath(sessionId); | |
| const tmp = `${target}.tmp`; | |
| fs.writeFileSync(tmp, JSON.stringify(data), 'utf8'); | |
| fs.renameSync(tmp, target); | |
| } | |
| function writeBridgeAtomic(sessionId, data) { | |
| const target = getBridgePath(sessionId); | |
| const tmp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; | |
| try { | |
| fs.writeFileSync(tmp, JSON.stringify(data), 'utf8'); | |
| fs.renameSync(tmp, target); | |
| } catch (err) { | |
| try { fs.unlinkSync(tmp); } catch { /* ignore */ } | |
| throw err; | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/lib/session-bridge.js` around lines 58 - 63, writeBridgeAtomic
currently writes to a deterministic tmp path `${target}.tmp`, causing races when
concurrent writers call writeBridgeAtomic; change it to use a unique temporary
filename per invocation (e.g., include process.pid, Date.now(), and a random
suffix or use a secure temp helper) instead of `${target}.tmp`, write the JSON
to that unique tmp file and then renameSync to `target`; ensure the unique tmp
is created in the same directory as `target` so rename stays atomic and consider
removing the tmp on error to avoid stale files (reference function
writeBridgeAtomic and the variables target/tmp).
There was a problem hiding this comment.
9 issues found across 13 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/hooks/ecc-statusline.js">
<violation number="1" location="scripts/hooks/ecc-statusline.js:105">
P2: Read-modify-write of the shared bridge file can lose concurrent updates from other hooks.</violation>
</file>
<file name="scripts/lib/cost-estimate.js">
<violation number="1" location="scripts/lib/cost-estimate.js:9">
P2: Exporting a mutable shared rate table allows downstream mutation to change future cost estimates unexpectedly.</violation>
</file>
<file name="scripts/lib/session-bridge.js">
<violation number="1" location="scripts/lib/session-bridge.js:60">
P2: The deterministic tmp path (`${target}.tmp`) breaks the atomicity guarantee when multiple callers (`ecc-statusline.js` and `ecc-metrics-bridge.js`) write concurrently for the same session. Two overlapping writes interleave on the same `.tmp` file and both rename, risking corruption. Use a unique tmp suffix per call (e.g., include `process.pid`, `Date.now()`, and a random component).</violation>
</file>
<file name="scripts/hooks/ecc-metrics-bridge.js">
<violation number="1" location="scripts/hooks/ecc-metrics-bridge.js:15">
P2: Unused import `estimateCost` triggers the repo's `no-unused-vars` ESLint error and can fail lint/CI.</violation>
<violation number="2" location="scripts/hooks/ecc-metrics-bridge.js:38">
P2: Non-Bash tools without `file_path` all hash to the same empty key, so distinct calls can be misclassified as repeated loops.</violation>
<violation number="3" location="scripts/hooks/ecc-metrics-bridge.js:73">
P2: Reading only the last 8KB of costs.jsonl can drop earlier rows for long sessions, causing undercounted cost/token totals.</violation>
<violation number="4" location="scripts/hooks/ecc-metrics-bridge.js:86">
P2: Cost totals are not session-isolated: `readSessionCost()` includes `default` rows for every session, so fallback costs/tokens from other runs can leak into the current bridge.</violation>
<violation number="5" location="scripts/hooks/ecc-metrics-bridge.js:159">
P1: Cost aggregation uses a separately-derived session ID from env vars instead of the already-sanitized `sessionId` used everywhere else in this function. If `input.session_id` is provided but `ECC_SESSION_ID`/`CLAUDE_SESSION_ID` are unset, `readSessionCost('default')` will miss rows logged under the real session ID, producing incorrect `total_cost_usd` in the bridge and misfiring cost thresholds in the context monitor. Use `sessionId` directly.</violation>
<violation number="6" location="scripts/hooks/ecc-metrics-bridge.js:165">
P2: Bridge updates are subject to lost updates under concurrency because the hook rewrites the entire session bridge without any locking or merge semantics.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default'); | ||
| const costs = readSessionCost(envSessionId); |
There was a problem hiding this comment.
P1: Cost aggregation uses a separately-derived session ID from env vars instead of the already-sanitized sessionId used everywhere else in this function. If input.session_id is provided but ECC_SESSION_ID/CLAUDE_SESSION_ID are unset, readSessionCost('default') will miss rows logged under the real session ID, producing incorrect total_cost_usd in the bridge and misfiring cost thresholds in the context monitor. Use sessionId directly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-metrics-bridge.js, line 159:
<comment>Cost aggregation uses a separately-derived session ID from env vars instead of the already-sanitized `sessionId` used everywhere else in this function. If `input.session_id` is provided but `ECC_SESSION_ID`/`CLAUDE_SESSION_ID` are unset, `readSessionCost('default')` will miss rows logged under the real session ID, producing incorrect `total_cost_usd` in the bridge and misfiring cost thresholds in the context monitor. Use `sessionId` directly.</comment>
<file context>
@@ -0,0 +1,185 @@
+ bridge.recent_tools = recent;
+
+ // Update cost from costs.jsonl tail
+ const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
+ const costs = readSessionCost(envSessionId);
+ bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
</file context>
| const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default'); | |
| const costs = readSessionCost(envSessionId); | |
| const costs = readSessionCost(sessionId); |
| if (sessionId && bridge && remaining != null) { | ||
| bridge.context_remaining_pct = remaining; | ||
| try { | ||
| writeBridgeAtomic(sessionId, bridge); |
There was a problem hiding this comment.
P2: Read-modify-write of the shared bridge file can lose concurrent updates from other hooks.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-statusline.js, line 105:
<comment>Read-modify-write of the shared bridge file can lose concurrent updates from other hooks.</comment>
<file context>
@@ -0,0 +1,160 @@
+ if (sessionId && bridge && remaining != null) {
+ bridge.context_remaining_pct = remaining;
+ try {
+ writeBridgeAtomic(sessionId, bridge);
+ } catch {
+ /* best effort */
</file context>
| * Approximate per-1M-token blended rates (conservative defaults). | ||
| */ | ||
|
|
||
| const RATE_TABLE = { |
There was a problem hiding this comment.
P2: Exporting a mutable shared rate table allows downstream mutation to change future cost estimates unexpectedly.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/lib/cost-estimate.js, line 9:
<comment>Exporting a mutable shared rate table allows downstream mutation to change future cost estimates unexpectedly.</comment>
<file context>
@@ -0,0 +1,32 @@
+ * Approximate per-1M-token blended rates (conservative defaults).
+ */
+
+const RATE_TABLE = {
+ haiku: { in: 0.8, out: 4.0 },
+ sonnet: { in: 3.0, out: 15.0 },
</file context>
| */ | ||
| function writeBridgeAtomic(sessionId, data) { | ||
| const target = getBridgePath(sessionId); | ||
| const tmp = `${target}.tmp`; |
There was a problem hiding this comment.
P2: The deterministic tmp path (${target}.tmp) breaks the atomicity guarantee when multiple callers (ecc-statusline.js and ecc-metrics-bridge.js) write concurrently for the same session. Two overlapping writes interleave on the same .tmp file and both rename, risking corruption. Use a unique tmp suffix per call (e.g., include process.pid, Date.now(), and a random component).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/lib/session-bridge.js, line 60:
<comment>The deterministic tmp path (`${target}.tmp`) breaks the atomicity guarantee when multiple callers (`ecc-statusline.js` and `ecc-metrics-bridge.js`) write concurrently for the same session. Two overlapping writes interleave on the same `.tmp` file and both rename, risking corruption. Use a unique tmp suffix per call (e.g., include `process.pid`, `Date.now()`, and a random component).</comment>
<file context>
@@ -0,0 +1,81 @@
+ */
+function writeBridgeAtomic(sessionId, data) {
+ const target = getBridgePath(sessionId);
+ const tmp = `${target}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
+ fs.renameSync(tmp, target);
</file context>
| const crypto = require('crypto'); | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { estimateCost } = require('../lib/cost-estimate'); |
There was a problem hiding this comment.
P2: Unused import estimateCost triggers the repo's no-unused-vars ESLint error and can fail lint/CI.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-metrics-bridge.js, line 15:
<comment>Unused import `estimateCost` triggers the repo's `no-unused-vars` ESLint error and can fail lint/CI.</comment>
<file context>
@@ -0,0 +1,185 @@
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { estimateCost } = require('../lib/cost-estimate');
+const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
+const { getClaudeDir } = require('../lib/utils');
</file context>
| bridge.total_input_tokens = costs.totalIn; | ||
| bridge.total_output_tokens = costs.totalOut; | ||
|
|
||
| writeBridgeAtomic(sessionId, bridge); |
There was a problem hiding this comment.
P2: Bridge updates are subject to lost updates under concurrency because the hook rewrites the entire session bridge without any locking or merge semantics.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-metrics-bridge.js, line 165:
<comment>Bridge updates are subject to lost updates under concurrency because the hook rewrites the entire session bridge without any locking or merge semantics.</comment>
<file context>
@@ -0,0 +1,185 @@
+ bridge.total_input_tokens = costs.totalIn;
+ bridge.total_output_tokens = costs.totalOut;
+
+ writeBridgeAtomic(sessionId, bridge);
+ } catch {
+ // Never block tool execution
</file context>
| for (const line of lines) { | ||
| try { | ||
| const row = JSON.parse(line); | ||
| if (row.session_id === sessionId || row.session_id === 'default') { |
There was a problem hiding this comment.
P2: Cost totals are not session-isolated: readSessionCost() includes default rows for every session, so fallback costs/tokens from other runs can leak into the current bridge.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-metrics-bridge.js, line 86:
<comment>Cost totals are not session-isolated: `readSessionCost()` includes `default` rows for every session, so fallback costs/tokens from other runs can leak into the current bridge.</comment>
<file context>
@@ -0,0 +1,185 @@
+ for (const line of lines) {
+ try {
+ const row = JSON.parse(line);
+ if (row.session_id === sessionId || row.session_id === 'default') {
+ totalCost += toNumber(row.estimated_cost_usd);
+ totalIn += toNumber(row.input_tokens);
</file context>
| if (name === 'Bash') { | ||
| key = String(toolInput?.command || '').slice(0, 80); | ||
| } else { | ||
| key = String(toolInput?.file_path || ''); |
There was a problem hiding this comment.
P2: Non-Bash tools without file_path all hash to the same empty key, so distinct calls can be misclassified as repeated loops.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-metrics-bridge.js, line 38:
<comment>Non-Bash tools without `file_path` all hash to the same empty key, so distinct calls can be misclassified as repeated loops.</comment>
<file context>
@@ -0,0 +1,185 @@
+ if (name === 'Bash') {
+ key = String(toolInput?.command || '').slice(0, 80);
+ } else {
+ key = String(toolInput?.file_path || '');
+ }
+ return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8);
</file context>
| try { | ||
| const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl'); | ||
| const stat = fs.statSync(costsPath); | ||
| const readSize = Math.min(stat.size, 8192); |
There was a problem hiding this comment.
P2: Reading only the last 8KB of costs.jsonl can drop earlier rows for long sessions, causing undercounted cost/token totals.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/hooks/ecc-metrics-bridge.js, line 73:
<comment>Reading only the last 8KB of costs.jsonl can drop earlier rows for long sessions, causing undercounted cost/token totals.</comment>
<file context>
@@ -0,0 +1,185 @@
+ try {
+ const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
+ const stat = fs.statSync(costsPath);
+ const readSize = Math.min(stat.size, 8192);
+ const fd = fs.openSync(costsPath, 'r');
+ try {
</file context>
| const readSize = Math.min(stat.size, 8192); | |
| const readSize = stat.size; |
Summary
ecc-statusline.js) displaying model, current task, session cost, tool count, files modified, session duration, and context usage bar with color thresholdsecc-metrics-bridge.js) PostToolUse hook maintaining a running session aggregate in/tmp/ecc-metrics-{session}.json, avoiding the need to scan large JSONL logs on every invocationecc-context-monitor.js) PostToolUse hook injecting agent-facing warnings for context exhaustion, high cost ($5/$10/$50 thresholds), scope creep (>20 files), and tool loop detectioncost-estimate.js,session-bridge.js) for reuse across hookscost-tracker.jsto import sharedestimateCost()instead of inline copyArchitecture
Statusline output example
Context monitor warning dimensions
Test plan
node tests/lib/cost-estimate.test.js— 7 tests passnode tests/lib/session-bridge.test.js— 12 tests passnode tests/hooks/ecc-metrics-bridge.test.js— 12 tests passnode tests/hooks/ecc-statusline.test.js— 15 tests passnode tests/hooks/ecc-context-monitor.test.js— 17 tests passnode tests/run-all.js— 1867/1867 all pass$TMPDIR)Summary by cubic
Adds an ECC-native statusline and a PostToolUse metrics pipeline to show real-time cost, tools, files, and context usage. Also adds a context monitor that warns on low context, high cost, scope creep, and tool loops.
New Features
scripts/hooks/ecc-statusline.js): shows model, current task, $cost, tool count, files changed, session duration, cwd, and a colorized context bar.scripts/hooks/ecc-metrics-bridge.js): writes/tmp/ecc-metrics-{session}.jsonwith total cost/tokens, tool count, files modified, and recent tools for loop detection.scripts/hooks/ecc-context-monitor.js): injects warnings for context ≤35%/≤25%, cost >$5/$10/$50, >20 files, and repeated tool calls; debounced with escalation.Migration
hooks/hooks.jsonaspost:ecc-metrics-bridgeandpost:ecc-context-monitor.statusLine.commandto:node "<plugin-root>/scripts/hooks/ecc-statusline.js"(useCLAUDE_PLUGIN_ROOTto resolve<plugin-root>).Written for commit 9f9467f. Summary will update on new commits.
Summary by CodeRabbit
New Features
Tests