Skip to content

Claude cost data stops updating after first scan due to directory mtime caching in scanClaudeRoot #411

@kcharlan

Description

@kcharlan

Describe the bug

Claude cost usage data in the menu bar stops updating after the initial scan on app launch. The displayed cost and token counts remain frozen — often for the entire day — even though Claude Code is actively generating new usage. Manually deleting the cache file (~/Library/Caches/CodexBar/cost-usage/claude-v1.json) forces a correct refresh, but the numbers go stale again shortly after.

Root cause

There are two issues in the cost scanning pipeline that compound to prevent updates.

1. canSkipEnumeration relies on root directory mtime, which doesn't reflect subdirectory changes

In CostUsageScanner+Claude.swift, scanClaudeRoot caches the root directory's mtime and uses it to decide whether to enumerate the directory tree:

let rootMtimeMs = Int64(rootMtime * 1000)
let cachedRootMtime = rootCandidates.compactMap { state.rootCache[$0] }.first
let canSkipEnumeration = cachedRootMtime == rootMtimeMs && rootMtimeMs > 0

When canSkipEnumeration is true, the scanner only re-checks files already in the cache — it never walks the directory tree to discover new log files.

On macOS (and POSIX systems generally), a directory's mtime only updates when direct children are created, renamed, or deleted. It does not update when files in subdirectories are modified or created. Claude Code stores session logs in a nested structure:

~/.claude/projects/<project-hash>/<session-id>.jsonl

The root being enumerated is ~/.claude/projects/. When a new session starts (creating a .jsonl in a subdirectory) or an existing session log grows, the root directory's mtime does not change. The scanner sees the unchanged mtime, takes the canSkipEnumeration fast path, and returns without discovering the new or modified files.

Additionally, when canSkipEnumeration is true, the scanner only iterates over already-cached file paths. Even for those files, it checks per-file mtime/size and skips unchanged ones — which is correct behavior. But the critical problem is that new files are never found, so they are never added to the cache in the first place.

After the first scan, the scanner effectively freezes: it never enumerates, never finds new session files, and returns the same stale data until the cache is manually cleared.

2. refreshMinIntervalSeconds default prevents re-scanning

In CostUsageFetcher.swift, when loadTokenSnapshot is called with forceRefresh: false (the normal timer-driven path), the scanner Options use the default refreshMinIntervalSeconds = 60:

var options = CostUsageScanner.Options()
// ...
if forceRefresh {
    options.refreshMinIntervalSeconds = 0
    options.forceRescan = true
}

Inside loadClaudeDaily, this TTL gates the entire scan:

let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000)
let shouldRefresh = refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs

If the cache was written within the last 60 seconds — for example, by a prior refresh cycle — shouldRefresh is false and the scanner returns stale cached data without checking any files at all.

This TTL is redundant with the per-file mtime/size checks in processClaudeFile, which already skip unchanged files efficiently. The scanner-level TTL just adds a second caching layer that can prevent those per-file checks from ever running.

How to reproduce

  1. Launch CodexBar with Claude cost scanning enabled
  2. Note the displayed cost/token numbers
  3. Use Claude Code actively for 10-15 minutes (generating meaningful token usage)
  4. Observe that the cost numbers in CodexBar do not change
  5. Run ccusage (or similar) to confirm actual usage has increased significantly
  6. Delete ~/Library/Caches/CodexBar/cost-usage/claude-v1.json
  7. Observe that CodexBar immediately shows correct, higher numbers
  8. Wait another 10-15 minutes of Claude Code usage — numbers freeze again

Evidence

During debugging, I confirmed that:

  • The cache file's lastScanUnixMs timestamp was updating (proving the scan code path was being entered)
  • But the per-file size and mtimeUnixMs values in the cache were NOT updating for actively-written log files
  • The actual file sizes on disk were growing (confirmed via stat) while the cached sizes remained frozen
  • After removing the canSkipEnumeration fast path, the cached file sizes immediately began tracking actual sizes, and cost numbers updated correctly

Concrete example from a debugging session:

  • Cache showed: size=1166728 for the active session log
  • Actual file: size=1209163 (42KB of unscanned usage data)
  • ccusage reported $33.80 for the day; CodexBar showed $18.00

Suggested fix

Fix 1: Remove the canSkipEnumeration fast path in scanClaudeRoot. The per-file mtime/size cache in processClaudeFile already prevents redundant parsing of unchanged files, so the performance cost of always enumerating is just the directory walk itself — no files are re-parsed unless they've actually changed.

Fix 2: Set refreshMinIntervalSeconds = 0 unconditionally in CostUsageFetcher.loadTokenSnapshot, rather than only when forceRefresh is true. This removes the redundant scanner-level TTL while preserving the per-file caching that prevents redundant work.

I have both fixes working locally and can confirm cost data updates correctly on each hourly refresh cycle. Happy to submit a PR if that would be helpful.

Additional context

  • The Codex scanner (loadCodexDaily) does not have canSkipEnumeration — it always enumerates via listCodexSessionFiles. This bug is specific to the Claude scanner.
  • Closed PR feat: live quota refresh on provider activity #98 (live quota refresh via FSEvents) and closed PR feat: make cost summary refresh interval configurable #256 (configurable cost refresh interval) suggest other users may have encountered this staleness symptom, though neither identified this root cause.
  • The tokenFetchTTL of 1 hour in UsageStore.swift limits cost updates to once per hour. This is a separate design choice (not a bug per se), but it's worth noting that since cost scanning reads local files rather than making network calls, a shorter interval might better match user expectations. The user's configured refreshFrequency (default: 5 minutes) does not affect cost scan frequency due to this TTL.

Environment

  • macOS 15+ (Apple Silicon)
  • CodexBar installed via Homebrew cask
  • Claude Code writing logs to ~/.claude/projects/
  • Multiple active projects with nested session JSONL files

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions