Skip to content

feat: add ECC-native statusline and context monitor hooks#1504

Open
ulinzeng wants to merge 6 commits intoaffaan-m:mainfrom
ulinzeng:feat/ecc-statusline-and-context-monitor
Open

feat: add ECC-native statusline and context monitor hooks#1504
ulinzeng wants to merge 6 commits intoaffaan-m:mainfrom
ulinzeng:feat/ecc-statusline-and-context-monitor

Conversation

@ulinzeng
Copy link
Copy Markdown

@ulinzeng ulinzeng commented Apr 20, 2026

Summary

  • Add ECC-native statusline (ecc-statusline.js) displaying model, current task, session cost, tool count, files modified, session duration, and context usage bar with color thresholds
  • Add metrics bridge (ecc-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 invocation
  • Add context monitor (ecc-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 detection
  • Extract shared libs (cost-estimate.js, session-bridge.js) for reuse across hooks
  • Refactor cost-tracker.js to import shared estimateCost() instead of inline copy

Architecture

PostToolUse (every tool call)                statusLine (between messages)
┌──────────────────────┐                    ┌───────────────────────┐
│ ecc-metrics-bridge.js│── writes ──┐       │ ecc-statusline.js     │
│ (aggregates metrics) │            │       │ (display to user)     │
└──────────────────────┘            ▼       │                       │
                        /tmp/ecc-metrics-   │ reads bridge + stdin  │
┌──────────────────────┐  {session}.json    │ writes ctx% to bridge │
│ ecc-context-monitor.js│── reads ──┘       └───────────────────────┘
│ (injects warnings)   │
└──────────────────────┘

Statusline output example

Opus 4.6 │ Fixing auth bug │ $1.23 47t 5f 15m │ myproject ███████░░░ 68%

Context monitor warning dimensions

Condition Level
context remaining <= 35% WARNING
context remaining <= 25% CRITICAL
session cost > $5 NOTICE
session cost > $10 WARNING
session cost > $50 CRITICAL
files modified > 20 WARNING
same tool+params 3x in last 5 calls WARNING

Test plan

  • node tests/lib/cost-estimate.test.js — 7 tests pass
  • node tests/lib/session-bridge.test.js — 12 tests pass
  • node tests/hooks/ecc-metrics-bridge.test.js — 12 tests pass
  • node tests/hooks/ecc-statusline.test.js — 15 tests pass
  • node tests/hooks/ecc-context-monitor.test.js — 17 tests pass
  • node tests/run-all.js — 1867/1867 all pass
  • Manual statusline test with piped JSON stdin
  • Manual bridge file verification on macOS ($TMPDIR)
  • Loop detection triggered successfully in live session

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

    • Statusline (scripts/hooks/ecc-statusline.js): shows model, current task, $cost, tool count, files changed, session duration, cwd, and a colorized context bar.
    • Metrics bridge (scripts/hooks/ecc-metrics-bridge.js): writes /tmp/ecc-metrics-{session}.json with total cost/tokens, tool count, files modified, and recent tools for loop detection.
    • Context monitor (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

    • New hooks are registered in hooks/hooks.json as post:ecc-metrics-bridge and post:ecc-context-monitor.
    • To enable the statusline, set statusLine.command to: node "<plugin-root>/scripts/hooks/ecc-statusline.js" (use CLAUDE_PLUGIN_ROOT to resolve <plugin-root>).
    • Keep the metrics bridge enabled so the statusline can read the session aggregate.

Written for commit 9f9467f. Summary will update on new commits.

Summary by CodeRabbit

  • New Features

    • Added context exhaustion monitoring with automatic warnings.
    • Implemented session metrics aggregation for cost tracking and tool usage monitoring.
    • Enhanced status line display with real-time metrics visualization including costs, file modifications, and context usage indicators.
  • Tests

    • Added comprehensive test coverage for new monitoring and metrics functionality.

ulinzeng added 6 commits April 20, 2026 15:15
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-tools
Copy link
Copy Markdown
Contributor

ecc-tools Bot commented Apr 20, 2026

ECC bundle files are already tracked in this repository. Skipping generation of another bundle PR.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

This 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

Cohort / File(s) Summary
Hook Configuration
hooks/hooks.json, examples/statusline.json
Added two new PostToolUse hooks for metrics aggregation and context monitoring; updated statusline example to reference new Node-based implementation with metrics-derived output format and colored context bar visualization.
Core Hook Scripts
scripts/hooks/ecc-metrics-bridge.js, scripts/hooks/ecc-context-monitor.js, scripts/hooks/ecc-statusline.js
Three new hook implementations: metrics-bridge aggregates session tool counts, modified files, and cumulative costs; context-monitor evaluates context/cost/scope thresholds and detects tool loops; statusline formats ANSI-colored status output with task info and context bar.
Shared Libraries
scripts/lib/cost-estimate.js, scripts/lib/session-bridge.js, scripts/hooks/cost-tracker.js
Extracted cost estimation into reusable library with per-model rate tables; added session-bridge for sanitization and filesystem-backed per-session JSON aggregates; refactored cost-tracker to use external cost-estimate module.
Test Coverage
tests/hooks/ecc-metrics-bridge.test.js, tests/hooks/ecc-context-monitor.test.js, tests/hooks/ecc-statusline.test.js, tests/lib/cost-estimate.test.js, tests/lib/session-bridge.test.js
Comprehensive test suites validating hook behavior, library exports, warning thresholds, loop detection, file extraction, session isolation, and cost calculation logic.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • affaan-m

Poem

🐰 The metrics flow in burrows deep,
Where session bridges safely keep
The costs and tools and context bars—
A rabbit's dashboard bright with stars!
No more bash pipelines twist and turn,
Now Node.js helps the metrics learn. 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: add ECC-native statusline and context monitor hooks' directly and accurately summarizes the main changes: three new hook scripts (ecc-statusline.js, ecc-metrics-bridge.js, ecc-context-monitor.js) and supporting libraries are added to provide ECC-native monitoring and display functionality.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 20, 2026

Greptile Summary

This PR introduces an ECC-native statusline (ecc-statusline.js) and context monitor (ecc-context-monitor.js) connected via a shared bridge file (/tmp/ecc-metrics-{session}.json), extracts reusable cost-estimate.js and session-bridge.js libraries, and refactors cost-tracker.js to use the shared estimator. The architecture is clean and well-tested, with two functional bugs worth addressing before shipping:

  • hashToolCall produces identical hashes for all non-Bash tools lacking a file_path (e.g. Glob, WebFetch), causing false LOOP WARNING injections into the agent context after just three such calls.
  • readSessionCost unconditionally includes all session_id === \"default\" rows regardless of which session is being queried, inflating cost totals and potentially firing premature COST CRITICAL warnings.

Confidence Score: 4/5

Two 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

Filename Overview
scripts/hooks/ecc-metrics-bridge.js New PostToolUse hook aggregating session metrics; has false-positive loop detection for tools without file_path, and cost inflation from unconditional "default" row inclusion.
scripts/hooks/ecc-context-monitor.js New PostToolUse hook injecting agent-facing warnings; debounce state write is non-atomic but logic is otherwise sound.
scripts/hooks/ecc-statusline.js New statusLine command script; missing stdin size cap and passes unsanitized session ID to readCurrentTask.
scripts/lib/session-bridge.js New shared lib for bridge file I/O; sanitization, atomic writes, and path construction are well-implemented.
scripts/lib/cost-estimate.js Extracted shared cost estimation utility; straightforward and correctly refactored from cost-tracker.js.
scripts/hooks/cost-tracker.js Refactored to import shared estimateCost(); change is minimal and correct.
hooks/hooks.json Adds two new PostToolUse hook entries for ecc-metrics-bridge and ecc-context-monitor with appropriate 10s timeouts.
examples/statusline.json Updated statusLine example to reference the new ecc-statusline.js script with clear setup instructions.

Sequence Diagram

sequenceDiagram
    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)
Loading

Reviews (1): Last reviewed commit: "chore: register new hooks in hooks.json ..." | Re-trigger Greptile

Comment on lines +32 to +41
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);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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.

Comment on lines +83 to +89
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 "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:

Suggested change
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) {

Comment on lines +84 to +90
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No stdin size cap

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.

Comment on lines +54 to +56
function writeWarnState(sessionId, state) {
fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8');
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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) : '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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:

Suggested change
const task = session ? readCurrentTask(session) : '';
const task = sessionId ? readCurrentTask(sessionId) : '';

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (3)
hooks/hooks.json (1)

223-246: Consider "async": true for these PostToolUse hooks.

Both hooks use matcher: "*" so they fire on every tool invocation, yet neither sets async: true (compare post:observe:continuous-learning at line 215 and post:bash:dispatcher at line 131). Any filesystem hiccup up to the 10s timeout will block the user between tool calls. ecc-metrics-bridge writes 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 to AUTO_COMPACT_BUFFER_PCT.

buildContextBar scales remaining by the buffer constant before deciding color, so these threshold assertions silently depend on AUTO_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 computed used value, 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

📥 Commits

Reviewing files that changed from the base of the PR and between 8bdf88e and 9f9467f.

📒 Files selected for processing (13)
  • examples/statusline.json
  • hooks/hooks.json
  • scripts/hooks/cost-tracker.js
  • scripts/hooks/ecc-context-monitor.js
  • scripts/hooks/ecc-metrics-bridge.js
  • scripts/hooks/ecc-statusline.js
  • scripts/lib/cost-estimate.js
  • scripts/lib/session-bridge.js
  • tests/hooks/ecc-context-monitor.test.js
  • tests/hooks/ecc-metrics-bridge.test.js
  • tests/hooks/ecc-statusline.test.js
  • tests/lib/cost-estimate.test.js
  • tests/lib/session-bridge.test.js

Comment on lines +54 to +56
function writeWarnState(sessionId, state) {
fs.writeFileSync(getWarnPath(sessionId), JSON.stringify(state), 'utf8');
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +195 to +199
const isFirst = !warnState.lastSeverity;
if (!isFirst && warnState.callsSinceWarn < DEBOUNCE_CALLS && !severityEscalated) {
writeWarnState(sessionId, warnState);
return rawInput;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +73 to +78
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +158 to +163
// 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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).

Comment on lines +68 to +72
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +99 to +109
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 */
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +23 to +28
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +58 to +63
function writeBridgeAtomic(sessionId, data) {
const target = getBridgePath(sessionId);
const tmp = `${target}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
fs.renameSync(tmp, target);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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).

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +159 to +160
const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
const costs = readSessionCost(envSessionId);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
const envSessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default');
const costs = readSessionCost(envSessionId);
const costs = readSessionCost(sessionId);
Fix with Cubic

if (sessionId && bridge && remaining != null) {
bridge.context_remaining_pct = remaining;
try {
writeBridgeAtomic(sessionId, bridge);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

* Approximate per-1M-token blended rates (conservative defaults).
*/

const RATE_TABLE = {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

*/
function writeBridgeAtomic(sessionId, data) {
const target = getBridgePath(sessionId);
const tmp = `${target}.tmp`;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { estimateCost } = require('../lib/cost-estimate');
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

bridge.total_input_tokens = costs.totalIn;
bridge.total_output_tokens = costs.totalOut;

writeBridgeAtomic(sessionId, bridge);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

for (const line of lines) {
try {
const row = JSON.parse(line);
if (row.session_id === sessionId || row.session_id === 'default') {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

if (name === 'Bash') {
key = String(toolInput?.command || '').slice(0, 80);
} else {
key = String(toolInput?.file_path || '');
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with Cubic

try {
const costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
const stat = fs.statSync(costsPath);
const readSize = Math.min(stat.size, 8192);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Suggested change
const readSize = Math.min(stat.size, 8192);
const readSize = stat.size;
Fix with Cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant