diff --git a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs
index 8247ce2..c6dbd17 100644
--- a/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs
+++ b/src/AIUsageMonitor.Core/Providers/Claude/ClaudeUsageProvider.cs
@@ -12,6 +12,7 @@ namespace AIUsageMonitor.Core.Providers.Claude;
/// Builds a from session transcripts when the cache is missing or stale.
/// Builds a summary of recent activity from session transcripts.
/// Builds hourly activity data from session transcripts.
+/// Tracks the latest session file write time without re-scanning the projects directory on every call.
/// Logger used to report cache fallback diagnostics.
public sealed class ClaudeUsageProvider(
ClaudeDataLocator locator,
@@ -19,6 +20,7 @@ public sealed class ClaudeUsageProvider(
StatsCacheBuilder statsCacheBuilder,
RecentActivityBuilder recentActivityBuilder,
HourlyActivityBuilder hourlyActivityBuilder,
+ SessionActivityTracker sessionActivityTracker,
ILogger logger) : IUsageProvider
{
/// Gets the display name of this usage provider.
@@ -65,7 +67,9 @@ public StatsCache GetStatsCache(IProgress? progress = null)
{
var cache = statsCacheParser.Parse(locator.StatsCachePath);
var today = DateOnly.FromDateTime(DateTimeOffset.Now.LocalDateTime);
- if (cache.LastComputedDate >= today)
+ var cacheWriteTime = File.GetLastWriteTimeUtc(locator.StatsCachePath);
+
+ if (cache.LastComputedDate >= today && cacheWriteTime >= sessionActivityTracker.LatestWriteUtc)
{
progress?.Report(100);
return cache;
diff --git a/src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs b/src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs
new file mode 100644
index 0000000..c35670b
--- /dev/null
+++ b/src/AIUsageMonitor.Core/Providers/Claude/SessionActivityTracker.cs
@@ -0,0 +1,53 @@
+namespace AIUsageMonitor.Core.Providers.Claude;
+
+///
+/// Tracks the most recent write time across all Claude session transcript files (*.jsonl)
+/// without repeatedly re-scanning the projects directory. The first access to
+/// takes a one-time baseline scan; after that, callers who already observe file-system change events
+/// (e.g. 's session-file watcher) feed updates via
+/// in O(1), so no further scans are needed.
+///
+/// Locates Claude session transcript files on disk, used only for the initial baseline scan.
+public sealed class SessionActivityTracker(ClaudeDataLocator locator)
+{
+ private readonly Lock _lock = new();
+ private DateTime? _latestWriteUtc;
+
+ ///
+ /// Gets the latest known write time (UTC) across all session transcript files. Triggers a one-time
+ /// directory scan on first access if no write has been observed yet via .
+ ///
+ public DateTime LatestWriteUtc
+ {
+ get
+ {
+ lock (_lock)
+ {
+ _latestWriteUtc ??= ScanForLatestWriteUtc();
+ return _latestWriteUtc.Value;
+ }
+ }
+ }
+
+ ///
+ /// Records a newly observed write time, advancing if it is more recent
+ /// than what's currently known.
+ ///
+ /// The write time (UTC) observed for a session transcript file.
+ public void Observe(DateTime writeTimeUtc)
+ {
+ lock (_lock)
+ {
+ if (_latestWriteUtc is null || writeTimeUtc > _latestWriteUtc)
+ {
+ _latestWriteUtc = writeTimeUtc;
+ }
+ }
+ }
+
+ private DateTime ScanForLatestWriteUtc()
+ {
+ var files = locator.GetSessionFiles();
+ return files.Count == 0 ? DateTime.MinValue : files.Max(File.GetLastWriteTimeUtc);
+ }
+}
\ No newline at end of file
diff --git a/src/AIUsageMonitor.Core/Services/DataService.cs b/src/AIUsageMonitor.Core/Services/DataService.cs
index 686b03f..783b45b 100644
--- a/src/AIUsageMonitor.Core/Services/DataService.cs
+++ b/src/AIUsageMonitor.Core/Services/DataService.cs
@@ -43,6 +43,13 @@ public sealed class DataService : IDisposable
///
private readonly IUsageProvider _provider;
+ ///
+ /// Tracks the latest session file write time, fed incrementally from
+ /// events so can check staleness without
+ /// re-scanning the projects directory.
+ ///
+ private readonly SessionActivityTracker _sessionActivityTracker;
+
///
/// The session file cache used to cache parsed session transcript rows. This field is initialized in the constructor and is used to store the results of parsing session transcript files, allowing for faster access to the data without needing to repeatedly read and parse the files from disk.
///
@@ -64,16 +71,19 @@ public sealed class DataService : IDisposable
/// The usage provider used to retrieve AI usage data.
/// The usage analyzer used to analyze AI usage data.
/// The session file cache used to cache parsed session transcript rows.
+ /// Tracks the latest session file write time from file-system watcher events.
/// The logger instance used for logging warnings and errors.
public DataService(
IUsageProvider provider,
UsageAnalyzer analyzer,
SessionFileCache sessionFileCache,
+ SessionActivityTracker sessionActivityTracker,
ILogger logger)
{
_provider = provider;
_analyzer = analyzer;
_sessionFileCache = sessionFileCache;
+ _sessionActivityTracker = sessionActivityTracker;
_logger = logger;
if (provider is ClaudeUsageProvider claudeProvider)
@@ -206,11 +216,14 @@ private void SessionsWatcher_Changed(object sender, FileSystemEventArgs e)
{
_logger.LogTrace("Session file change detected: {ChangeType} - {FullPath}", e.ChangeType, e.FullPath);
+ _cache.Remove(StatsCacheKey);
+
switch (e.ChangeType)
{
case WatcherChangeTypes.Changed:
case WatcherChangeTypes.Created:
_sessionFileCache.Set(e.FullPath);
+ _sessionActivityTracker.Observe(File.GetLastWriteTimeUtc(e.FullPath));
break;
case WatcherChangeTypes.Deleted:
diff --git a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs
index a8aa2b9..acd253a 100644
--- a/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs
+++ b/src/AIUsageMonitor.Core/Services/ServiceCollectionExtensions.cs
@@ -22,6 +22,7 @@ public static IServiceCollection AddClaudeUsageCore(this IServiceCollection serv
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs
new file mode 100644
index 0000000..23edec2
--- /dev/null
+++ b/tests/AIUsageMonitor.Core.Tests/Providers/Claude/ClaudeUsageProviderTests.cs
@@ -0,0 +1,103 @@
+using AIUsageMonitor.Core.Analytics;
+using AIUsageMonitor.Core.Providers.Claude;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AIUsageMonitor.Core.Tests.Providers.Claude;
+
+public class ClaudeUsageProviderTests : IDisposable
+{
+ private readonly string _claudeDir = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid()}");
+ private readonly ClaudeUsageProvider _sut;
+
+ public ClaudeUsageProviderTests()
+ {
+ Directory.CreateDirectory(Path.Combine(_claudeDir, "projects"));
+
+ var locator = new ClaudeDataLocator(_claudeDir);
+ var sessionParser = new SessionParser(NullLogger.Instance);
+ var sessionFileCache = new SessionFileCache(sessionParser);
+ var costCalculator = new CostCalculator();
+
+ _sut = new ClaudeUsageProvider(
+ locator,
+ new StatsCacheParser(),
+ new StatsCacheBuilder(sessionFileCache),
+ new RecentActivityBuilder(sessionFileCache, costCalculator),
+ new HourlyActivityBuilder(sessionFileCache),
+ new SessionActivityTracker(locator),
+ NullLogger.Instance);
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_claudeDir))
+ {
+ Directory.Delete(_claudeDir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void GetStatsCache_SessionFileNewerThanStatsCache_RebuildsFromTranscripts()
+ {
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var statsCachePath = Path.Combine(_claudeDir, "stats-cache.json");
+ File.WriteAllText(statsCachePath, $$"""
+ {
+ "version": 1,
+ "lastComputedDate": "{{today:yyyy-MM-dd}}",
+ "totalSessions": 1,
+ "totalMessages": 1,
+ "modelUsage": {
+ "stale-model": { "inputTokens": 1, "outputTokens": 1 }
+ }
+ }
+ """);
+ File.SetLastWriteTimeUtc(statsCachePath, DateTime.UtcNow.AddMinutes(-30));
+
+ var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl");
+ var timestamp = DateTimeOffset.UtcNow.ToString("O");
+ File.WriteAllLines(sessionFile,
+ [
+ "{\"type\":\"user\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\"}",
+ "{\"type\":\"assistant\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\",\"message\":{\"role\":\"assistant\",\"model\":\"fresh-model\",\"usage\":{\"input_tokens\":100,\"output_tokens\":50}}}",
+ ]);
+ File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow);
+
+ var cache = _sut.GetStatsCache();
+
+ Assert.True(cache.ModelUsage.ContainsKey("fresh-model"));
+ Assert.False(cache.ModelUsage.ContainsKey("stale-model"));
+ }
+
+ [Fact]
+ public void GetStatsCache_StatsCacheNewerThanSessions_UsesStatsCacheAsIs()
+ {
+ var sessionFile = Path.Combine(_claudeDir, "projects", "session.jsonl");
+ var timestamp = DateTimeOffset.UtcNow.ToString("O");
+ File.WriteAllLines(sessionFile,
+ [
+ "{\"type\":\"user\",\"timestamp\":\"" + timestamp + "\",\"sessionId\":\"s1\"}",
+ ]);
+ File.SetLastWriteTimeUtc(sessionFile, DateTime.UtcNow.AddMinutes(-30));
+
+ var today = DateOnly.FromDateTime(DateTime.UtcNow);
+ var statsCachePath = Path.Combine(_claudeDir, "stats-cache.json");
+ File.WriteAllText(statsCachePath, $$"""
+ {
+ "version": 1,
+ "lastComputedDate": "{{today:yyyy-MM-dd}}",
+ "totalSessions": 1,
+ "totalMessages": 1,
+ "modelUsage": {
+ "cached-model": { "inputTokens": 1, "outputTokens": 1 }
+ }
+ }
+ """);
+ File.SetLastWriteTimeUtc(statsCachePath, DateTime.UtcNow);
+
+ var cache = _sut.GetStatsCache();
+
+ Assert.True(cache.ModelUsage.ContainsKey("cached-model"));
+ }
+}