diff --git a/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs
new file mode 100644
index 00000000..9b3308b1
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs
@@ -0,0 +1,105 @@
+namespace ClaudeDo.Worker.Usage.TokenTracker;
+
+public sealed record TokenTrackerTotals(long TotalTokens, double CostUsd, int Sessions);
+
+///
+/// Pure aggregation over a parsed export. One fetch covers the whole history — --from and
+/// --to are accepted by the CLI but do not filter its output (measured against
+/// v0.88.4: identical 886 rows with and without a range) — so filtering here is load-bearing, and
+/// switching the range in the UI costs nothing.
+///
+public static class TokenTrackerAggregator
+{
+ public const string ScopeClaudeDo = "claudedo";
+ public const string ScopeOther = "other";
+
+ public static IReadOnlyList ByModel(
+ IReadOnlyList sessions,
+ DateOnly from,
+ DateOnly to,
+ ISet claudeDoHashes)
+ {
+ var buckets = new Dictionary<(DateOnly Date, string Model, string Scope), Accumulator>();
+
+ foreach (var session in InRange(sessions, from, to))
+ {
+ var key = (
+ DateOf(session),
+ session.Model,
+ claudeDoHashes.Contains(session.SessionHash) ? ScopeClaudeDo : ScopeOther);
+
+ if (!buckets.TryGetValue(key, out var acc))
+ {
+ acc = new Accumulator();
+ buckets[key] = acc;
+ }
+
+ acc.Input += session.InputTokens;
+ acc.Output += session.OutputTokens;
+ acc.CacheRead += session.CacheReadTokens;
+ acc.CacheCreation += session.CacheCreationTokens;
+ acc.Cost += session.CostUsd;
+ acc.Sessions++;
+ }
+
+ return buckets
+ .Select(kv => new TokenTrackerModelRow(
+ kv.Key.Date, kv.Key.Model, kv.Key.Scope,
+ kv.Value.Input, kv.Value.Output, kv.Value.CacheRead, kv.Value.CacheCreation,
+ kv.Value.Sessions, kv.Value.Cost))
+ .OrderBy(r => r.Date).ThenBy(r => r.Model).ThenBy(r => r.Scope)
+ .ToList();
+ }
+
+ /// Cost and efficiency for one task, given the session ids of its runs. Null when
+ /// none of them appear in the export — the caller then shows tokens without cost. Several
+ /// export rows can share one session hash (they are disjoint segments of the same session),
+ /// so summing every match is correct rather than double-counting.
+ public static TokenTrackerTaskExtras? ForSessions(
+ IReadOnlyList sessions,
+ IEnumerable sessionIds)
+ {
+ var wanted = sessionIds
+ .Select(SessionHash.ForClaudeSession)
+ .Where(h => h is not null)
+ .ToHashSet()!;
+
+ if (wanted.Count == 0) return null;
+
+ var matched = sessions.Where(s => wanted.Contains(s.SessionHash)).ToList();
+ if (matched.Count == 0) return null;
+
+ return new TokenTrackerTaskExtras(
+ CostUsd: matched.Sum(s => s.CostUsd),
+ Retries: matched.Sum(s => s.RetryTurns),
+ Productive: matched.Any(s => s.Productive),
+ OneShot: matched.All(s => s.OneShot));
+ }
+
+ public static TokenTrackerTotals Totals(
+ IReadOnlyList sessions, DateOnly from, DateOnly to)
+ {
+ var inRange = InRange(sessions, from, to).ToList();
+ return new TokenTrackerTotals(
+ TotalTokens: inRange.Sum(s =>
+ s.InputTokens + s.OutputTokens + s.CacheReadTokens + s.CacheCreationTokens),
+ CostUsd: inRange.Sum(s => s.CostUsd),
+ Sessions: inRange.Count);
+ }
+
+ private static IEnumerable InRange(
+ IReadOnlyList sessions, DateOnly from, DateOnly to) =>
+ sessions.Where(s => DateOf(s) >= from && DateOf(s) <= to);
+
+ /// Local start date — the UI's pickers and the old transcript reader both work in
+ /// local time, so a UTC-based date would shift rows across midnight for the user.
+ private static DateOnly DateOf(TokenTrackerSession session) =>
+ DateOnly.FromDateTime(session.StartedAt.LocalDateTime);
+
+ private sealed class Accumulator
+ {
+ public long Input, Output, CacheRead, CacheCreation;
+ public double Cost;
+ public int Sessions;
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs
new file mode 100644
index 00000000..ffebe5ac
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs
@@ -0,0 +1,106 @@
+using ClaudeDo.Worker.Usage.TokenTracker;
+
+namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
+
+public sealed class TokenTrackerAggregatorTests
+{
+ private static readonly DateOnly RangeStart = new(2026, 8, 20);
+ private static readonly DateOnly RangeEnd = new(2026, 8, 21);
+
+ private static IReadOnlyList Sessions() =>
+ TokenTrackerExportParser.Parse(
+ TokenTrackerFixtures.SessionsJson,
+ new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))!.Sessions;
+
+ private static HashSet ClaudeDoHashes() =>
+ [
+ SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdA)!,
+ SessionHash.ForClaudeSession(TokenTrackerFixtures.ClaudeDoSessionIdB)!,
+ ];
+
+ [Fact]
+ public void ByModel_ExcludesSessionsOutsideTheRange()
+ {
+ var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes());
+
+ Assert.All(rows, r => Assert.InRange(r.Date, RangeStart, RangeEnd));
+ Assert.DoesNotContain(rows, r => r.CostUsd == 99.0);
+ }
+
+ [Fact]
+ public void ByModel_SplitsClaudeDoFromOther()
+ {
+ var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, ClaudeDoHashes());
+
+ var opus = rows.Single(r => r.Model == "claude-opus-5" && r.Scope == "claudedo");
+ Assert.Equal(110, opus.InputTokens);
+ Assert.Equal(440, opus.OutputTokens);
+ Assert.Equal(990, opus.CacheReadTokens);
+ Assert.Equal(55, opus.CacheCreationTokens);
+ Assert.Equal(2, opus.Sessions);
+ Assert.Equal(3.5, opus.CostUsd, 6);
+
+ var sonnet = rows.Single(r => r.Model == "claude-sonnet-5");
+ Assert.Equal("other", sonnet.Scope);
+ Assert.Equal(0.25, sonnet.CostUsd, 6);
+ }
+
+ [Fact]
+ public void ByModel_UnknownHash_CountsAsOther()
+ {
+ var rows = TokenTrackerAggregator.ByModel(Sessions(), RangeStart, RangeEnd, new HashSet());
+
+ Assert.All(rows, r => Assert.Equal("other", r.Scope));
+ }
+
+ [Fact]
+ public void ByModel_EmptyInput_ReturnsEmpty()
+ {
+ Assert.Empty(TokenTrackerAggregator.ByModel([], RangeStart, RangeEnd, new HashSet()));
+ }
+
+ [Fact]
+ public void ForSessions_SumsCostAndRetriesOfMatchingRows()
+ {
+ var extras = TokenTrackerAggregator.ForSessions(
+ Sessions(),
+ [TokenTrackerFixtures.ClaudeDoSessionIdA, TokenTrackerFixtures.ClaudeDoSessionIdB]);
+
+ Assert.NotNull(extras);
+ Assert.Equal(3.5, extras!.CostUsd, 6);
+ Assert.Equal(3, extras.Retries);
+ Assert.True(extras.Productive); // A is productive
+ Assert.False(extras.OneShot); // B is not one-shot
+ }
+
+ [Fact]
+ public void ForSessions_SingleOneShotSession_ReportsOneShot()
+ {
+ var extras = TokenTrackerAggregator.ForSessions(
+ Sessions(), [TokenTrackerFixtures.ClaudeDoSessionIdA]);
+
+ Assert.True(extras!.OneShot);
+ Assert.Equal(2.5, extras.CostUsd, 6);
+ }
+
+ [Fact]
+ public void ForSessions_NoMatch_ReturnsNull()
+ {
+ Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), ["11111111-2222-3333-4444-555555555555"]));
+ }
+
+ [Fact]
+ public void ForSessions_BlankIds_ReturnsNull()
+ {
+ Assert.Null(TokenTrackerAggregator.ForSessions(Sessions(), [null, "", " "]));
+ }
+
+ [Fact]
+ public void Totals_SumsTokensAndCostInRange()
+ {
+ var totals = TokenTrackerAggregator.Totals(Sessions(), RangeStart, RangeEnd);
+
+ Assert.Equal(1615, totals.TotalTokens); // 1450 + 145 + 20
+ Assert.Equal(3.75, totals.CostUsd, 6);
+ }
+}