diff --git a/docs/superpowers/plans/2026-08-24-tokentracker-usage-analytics.md b/docs/superpowers/plans/2026-08-24-tokentracker-usage-analytics.md new file mode 100644 index 00000000..2008a9a1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-tokentracker-usage-analytics.md @@ -0,0 +1,2679 @@ +# TokenTracker Usage-Analytics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Token-Auswertung (Breakdown pro Modell und pro Task, Total Tokens, neu auch Kosten in USD) kommt aus dem lokalen `tokentracker sessions`-Export statt aus unserem eigenen Transkript-Scanner; Limits, Gate und Throttle bleiben unverändert bei unserem OAuth-Poll. + +**Architecture:** Ein neuer Ordner `src/ClaudeDo.Worker/Usage/TokenTracker/` kapselt Prozess-Aufruf, Parsing, Caching und Aggregation. Der Kern ist bewusst pure: `SessionHash`, `TokenTrackerArgs`, `TokenTrackerExportParser` und `TokenTrackerAggregator` haben keine I/O und sind voll testbar; nur `TokenTrackerClient` (Prozess) und `TokenTrackerService` (Cache-Koordination) berühren die Außenwelt. `WorkerHub.GetModelUsage` speist sich aus dem Cache, `GetTaskUsage` bleibt `task_runs`-basiert und wird über den `session_hash`-Join angereichert. Die UI bekommt Kostenspalten plus eine Hinweis-Karte mit Install-Button, wenn TokenTracker fehlt. + +**Tech Stack:** .NET 8, xUnit, SignalR, Avalonia 12 / CommunityToolkit.Mvvm, `System.Text.Json`, `ClaudeDo.Data.Environment.ExecutableResolver`. + +**Spec:** `docs/superpowers/specs/2026-08-24-tokentracker-usage-analytics-design.md` + +--- + +## Vorbemerkungen für die umsetzende Person + +Ein paar Dinge, die im Code nicht sichtbar sind und die dieser Plan voraussetzt: + +- **Das externe CLI wird in keinem Test gestartet.** Genau wie beim `claude`-CLI ist das eine harte Projektregel. Alle Tests laufen gegen `ITokenTrackerClient`-Fakes oder gegen pure Funktionen. +- **Fail-open ist das oberste Prinzip im ganzen `Usage/`-Bereich.** Fehlt TokenTracker, bricht der Export ab oder liefert Müll, dann zeigt die App weniger an — sie wirft nie, und ein Fehler überschreibt nie ein zuletzt erfolgreiches Ergebnis. +- **Das Fixture ist ein C#-Raw-String-Literal, keine Datei.** Grund: `ClaudeDo.Worker.Tests` müsste sonst eine `CopyToOutputDirectory`-Regel in der csproj bekommen, und Dateipfade in Tests sind eine unnötige Fehlerquelle. Ein `const string` in `TokenTrackerFixtures.cs` ist genauso eingecheckt. +- **Build auf dieser Maschine:** `dotnet build ClaudeDo.slnx` scheitert (braucht .NET 9). Immer einzelne csproj mit `-c Release` bauen — ein laufender Worker sperrt das `Debug`-Output. +- **Beim Committen niemals `git add -A`.** Das Repo wird von parallelen Sessions benutzt; immer explizit die Pfade angeben, wie in den Commit-Schritten unten geschrieben. + +--- + +## File Structure + +**Neu — Worker:** + +| Datei | Verantwortung | +|---|---| +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs` | `TokenTrackerSession`, `TokenTrackerExport`, `TokenTrackerProbe`, `TokenTrackerRunResult`, `TokenTrackerModelRow`, `TokenTrackerTaskExtras` | +| `src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs` | Nur die Hash-Formel: Claude-Session-ID → TokenTracker-`session_hash` | +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs` | Baut die CLI-Argumentlisten (pure) | +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs` | JSON → `TokenTrackerExport?`, defensiv | +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs` | Range-Filter + Aggregation nach Modell und pro Task (pure) | +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs` | Threadsafe Halter des letzten guten Exports | +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs` | Prozess-Aufruf (Export, Probe, npm-Install) | +| `src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs` | Die Naht für Tests | +| `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs` | Cache-Koordination: Probe-Cache, Single-Flight-Refresh, 90-Tage-Fenster | + +**Neu — Tests:** + +`tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/` mit `TokenTrackerFixtures.cs`, +`SessionHashTests.cs`, `TokenTrackerArgsTests.cs`, `TokenTrackerExportParserTests.cs`, +`TokenTrackerAggregatorTests.cs`, `TokenTrackerStateTests.cs`, `TokenTrackerServiceTests.cs`, +`FakeTokenTrackerClient.cs`. + +**Geändert:** + +| Datei | Änderung | +|---|---| +| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | `ModelUsageRowDto`/`TaskUsageRowDto` erweitert, `GetModelUsage` umgestellt, `GetTaskUsage` angereichert, drei neue Methoden | +| `src/ClaudeDo.Worker/Program.cs` | DI für die drei neuen Singletons | +| `src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs` | `ReadAsync` + Aggregations-Innereien entfernt | +| `src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs` | Auf `ReadSessionTotalsAsync` reduziert | +| `src/ClaudeDo.Worker/Usage/UsageModels.cs` | `UsageAggregateRow` + `UsageScope` entfernt | +| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Drei neue Methoden | +| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implementierung + DTOs | +| `src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs` | Status, Kosten, Install-/Refresh-Command | +| `src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml` | Kostenspalten, Kopfzeile, Hinweis-Karte | +| `src/ClaudeDo.Localization/locales/{en,de}.json` | Neue Keys in Parität | +| `tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs` | `ReadAsync`-Tests entfernen | +| Fakes in `tests/ClaudeDo.Ui.Tests` | `IWorkerClient`-Erweiterung nachziehen | +| `docs/explore-notes/usage-monitoring.md`, `src/ClaudeDo.Worker/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md` | Doku | + +--- + +## Task 1: SessionHash + +Die Brücke zwischen unseren `task_runs.session_id` und TokenTrackers `session_hash`. +Formel aus `lib/session-analytics.js:88`: `sha256(source + "\0" + sessionId)`, Hex, erste 24 Zeichen. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs` + +- [ ] **Step 1: Write the failing test** + +Die drei Vektoren sind gegen den echten Export auf der Entwicklungsmaschine verifiziert +(2026-08-24) — nicht ändern, sie sind der Beweis, dass die Formel stimmt. + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class SessionHashTests +{ + [Theory] + [InlineData("77470328-94f7-49d3-b378-a562d1501b5f", "b24babcbf2b615730459773f")] + [InlineData("20ebc1bc-3e6a-4c09-a4b3-19472dffa841", "3e2a08fe8f2b941817fb554f")] + [InlineData("559e7ebb-7215-4112-9faf-be86bece9613", "03c30f4bc0bfe82c3fbb17c1")] + public void ForClaudeSession_MatchesTokenTrackerHash(string sessionId, string expected) + { + Assert.Equal(expected, SessionHash.ForClaudeSession(sessionId)); + } + + [Fact] + public void ForClaudeSession_IsLowercaseHexOf24Chars() + { + var hash = SessionHash.ForClaudeSession("any-session-id"); + + Assert.Equal(24, hash.Length); + Assert.All(hash, c => Assert.Contains(c, "0123456789abcdef")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ForClaudeSession_BlankId_ReturnsNull(string? sessionId) + { + Assert.Null(SessionHash.ForClaudeSession(sessionId)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~SessionHashTests +``` + +Expected: Build-Fehler `CS0246: The type or namespace name 'SessionHash' could not be found`. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.Security.Cryptography; +using System.Text; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Reproduces TokenTracker's session identity so its export rows can be joined onto our +/// task_runs.session_id. The formula is sha256(source + "\0" + id), hex, first +/// 24 chars (TokenTracker's lib/session-analytics.js, sessionHash()). The export +/// never carries the raw session id, so this is the only way to attribute a row to a task. +/// +public static class SessionHash +{ + private const int HashLength = 24; + + public static string? ForClaudeSession(string? sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) return null; + + var bytes = Encoding.UTF8.GetBytes($"claude\0{sessionId}"); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant()[..HashLength]; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~SessionHashTests +``` + +Expected: PASS, 5 Tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/SessionHash.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/SessionHashTests.cs +git commit -m "feat(usage): reproduce TokenTracker session_hash for task attribution" +``` + +--- + +## Task 2: Modelle und Fixture + +Reine Datenträger plus das Fixture, das ab Task 3 jeder Test benutzt. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs` + +- [ ] **Step 1: Write the models** + +```csharp +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// One row of tokentracker sessions --format json. Field names mirror the +/// export's snake_case keys: cached_input_tokens is the cache *read* count, +/// cache_creation_input_tokens the write count. +public sealed record TokenTrackerSession( + string SessionHash, + string Source, + string Model, + DateTimeOffset StartedAt, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, + double CostUsd, + int Turns, + int RetryTurns, + bool Productive, + bool OneShot); + +/// A parsed export plus when we fetched it. is the +/// version field of the session rows — the only compatibility signal the export gives us. +public sealed record TokenTrackerExport( + int FormatVersion, + IReadOnlyList Sessions, + DateTime FetchedAtUtc); + +public sealed record TokenTrackerProbe( + bool Installed, + string? Version, + bool NodeOk, + string? NodeVersion, + string? Error) +{ + public static TokenTrackerProbe Unknown { get; } = new(false, null, false, null, null); +} + +/// Outcome of one CLI invocation. false always carries an +/// — callers turn that into state, never into an exception. +public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error); + +/// Aggregated per date/model/scope. Scope is "claudedo" or "other", +/// decided by whether the session hash belongs to one of our runs. Sessions replaces the +/// old assistant-message count — the export has no message granularity. +public sealed record TokenTrackerModelRow( + DateOnly Date, + string Model, + string Scope, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, + int Sessions, + double CostUsd); + +/// What the export adds on top of what task_runs already knows about a task. +public sealed record TokenTrackerTaskExtras( + double CostUsd, + int Retries, + bool Productive, + bool OneShot); +``` + +- [ ] **Step 2: Write the fixture** + +Vier Sessions: zwei ClaudeDo-Opus-Sessions am 2026-08-20, eine fremde Sonnet-Session am selben +Tag, eine Opus-Session am 2026-08-25 (außerhalb des Testbereichs). Die beiden ClaudeDo-Hashes +sind exakt die aus Task 1, damit der Join-Test echte IDs verwenden kann. + +```csharp +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public static class TokenTrackerFixtures +{ + /// Session id whose hash is the fixture's first ClaudeDo session. + public const string ClaudeDoSessionIdA = "77470328-94f7-49d3-b378-a562d1501b5f"; + + /// Session id whose hash is the fixture's second ClaudeDo session. + public const string ClaudeDoSessionIdB = "20ebc1bc-3e6a-4c09-a4b3-19472dffa841"; + + /// Shape copied from a real `tokentracker sessions --format json` run + /// (2026-08-24), trimmed to four rows and stripped of everything we don't read. + public const string SessionsJson = """ + { + "available": true, + "sessions": [ + { + "version": 11, + "session_hash": "b24babcbf2b615730459773f", + "source": "claude", + "project_key": "ClaudeDo", + "model": "claude-opus-5", + "started_at": "2026-08-20T10:00:00.000Z", + "ended_at": "2026-08-20T10:10:00.000Z", + "turns": 4, + "edit_turns": 2, + "retry_turns": 1, + "tokens": { + "input_tokens": 100, + "cached_input_tokens": 900, + "cache_creation_input_tokens": 50, + "output_tokens": 400, + "reasoning_output_tokens": 0, + "total_tokens": 1450 + }, + "total_tokens": 1450, + "cost_usd": 2.5, + "productive": true, + "first_pass": true, + "one_shot": true + }, + { + "version": 11, + "session_hash": "3e2a08fe8f2b941817fb554f", + "source": "claude", + "project_key": "02f6746f-d67b-4217-a581-f2f911f6ff0d", + "model": "claude-opus-5", + "started_at": "2026-08-20T12:00:00.000Z", + "ended_at": "2026-08-20T12:30:00.000Z", + "turns": 6, + "edit_turns": 0, + "retry_turns": 2, + "tokens": { + "input_tokens": 10, + "cached_input_tokens": 90, + "cache_creation_input_tokens": 5, + "output_tokens": 40, + "reasoning_output_tokens": 0, + "total_tokens": 145 + }, + "total_tokens": 145, + "cost_usd": 1.0, + "productive": false, + "first_pass": false, + "one_shot": false + }, + { + "version": 11, + "session_hash": "ffffffffffffffffffffffff", + "source": "claude", + "project_key": "SomeOtherRepo", + "model": "claude-sonnet-5", + "started_at": "2026-08-20T14:00:00.000Z", + "ended_at": "2026-08-20T14:05:00.000Z", + "turns": 2, + "edit_turns": 1, + "retry_turns": 0, + "tokens": { + "input_tokens": 7, + "cached_input_tokens": 3, + "cache_creation_input_tokens": 1, + "output_tokens": 9, + "reasoning_output_tokens": 0, + "total_tokens": 20 + }, + "total_tokens": 20, + "cost_usd": 0.25, + "productive": true, + "first_pass": true, + "one_shot": true + }, + { + "version": 11, + "session_hash": "aaaaaaaaaaaaaaaaaaaaaaaa", + "source": "claude", + "project_key": "ClaudeDo", + "model": "claude-opus-5", + "started_at": "2026-08-25T10:00:00.000Z", + "ended_at": "2026-08-25T10:20:00.000Z", + "turns": 3, + "edit_turns": 1, + "retry_turns": 0, + "tokens": { + "input_tokens": 1000, + "cached_input_tokens": 2000, + "cache_creation_input_tokens": 300, + "output_tokens": 4000, + "reasoning_output_tokens": 0, + "total_tokens": 7300 + }, + "total_tokens": 7300, + "cost_usd": 99.0, + "productive": true, + "first_pass": true, + "one_shot": true + } + ] + } + """; + + /// Same as but with a future format version. + public const string UnsupportedVersionJson = """ + { + "available": true, + "sessions": [ + { + "version": 99, + "session_hash": "b24babcbf2b615730459773f", + "source": "claude", + "model": "claude-opus-5", + "started_at": "2026-08-20T10:00:00.000Z", + "tokens": { "input_tokens": 1, "output_tokens": 2 }, + "cost_usd": 0.1 + } + ] + } + """; +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +``` + +Expected: Build succeeded, 0 Errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerModels.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerFixtures.cs +git commit -m "feat(usage): add TokenTracker export models and test fixture" +``` + +--- + +## Task 3: Export-Parser + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerExportParserTests +{ + private static readonly DateTime FetchedAt = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Parse_ReadsAllSessions() + { + var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt); + + Assert.NotNull(export); + Assert.Equal(4, export!.Sessions.Count); + Assert.Equal(11, export.FormatVersion); + Assert.Equal(FetchedAt, export.FetchedAtUtc); + } + + [Fact] + public void Parse_MapsTokenFieldsAndCost() + { + var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt)!; + var first = export.Sessions.Single(s => s.SessionHash == "b24babcbf2b615730459773f"); + + Assert.Equal("claude-opus-5", first.Model); + Assert.Equal(100, first.InputTokens); + Assert.Equal(400, first.OutputTokens); + Assert.Equal(900, first.CacheReadTokens); + Assert.Equal(50, first.CacheCreationTokens); + Assert.Equal(2.5, first.CostUsd); + Assert.Equal(4, first.Turns); + Assert.Equal(1, first.RetryTurns); + Assert.True(first.Productive); + Assert.True(first.OneShot); + } + + [Fact] + public void Parse_UnsupportedVersion_StillParsesButReportsIt() + { + var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.UnsupportedVersionJson, FetchedAt); + + Assert.NotNull(export); + Assert.Equal(99, export!.FormatVersion); + } + + [Fact] + public void Parse_MissingFields_DefaultToZero() + { + const string json = """ + { "sessions": [ { "version": 11, "session_hash": "abc", "source": "claude", + "model": "claude-opus-5", "started_at": "2026-08-20T10:00:00.000Z" } ] } + """; + + var session = TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions.Single(); + + Assert.Equal(0, session.InputTokens); + Assert.Equal(0, session.CostUsd); + Assert.Equal(0, session.RetryTurns); + Assert.False(session.Productive); + } + + [Fact] + public void Parse_SkipsRowsWithoutHashOrTimestamp() + { + const string json = """ + { "sessions": [ { "version": 11, "source": "claude", "model": "m" }, + { "version": 11, "session_hash": "abc", "source": "claude", "model": "m" }, + { "version": 11, "session_hash": "def", "source": "claude", "model": "m", + "started_at": "2026-08-20T10:00:00.000Z" } ] } + """; + + var export = TokenTrackerExportParser.Parse(json, FetchedAt); + + Assert.Single(export!.Sessions); + Assert.Equal("def", export.Sessions[0].SessionHash); + } + + [Fact] + public void Parse_SkipsNonClaudeSources() + { + const string json = """ + { "sessions": [ { "version": 11, "session_hash": "abc", "source": "codex", "model": "gpt", + "started_at": "2026-08-20T10:00:00.000Z" } ] } + """; + + Assert.Empty(TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not json at all")] + [InlineData("[1,2,3]")] + [InlineData("{\"available\": false}")] + public void Parse_Garbage_ReturnsNull(string json) + { + Assert.Null(TokenTrackerExportParser.Parse(json, FetchedAt)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerExportParserTests +``` + +Expected: Build-Fehler `CS0103: The name 'TokenTrackerExportParser' does not exist`. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.Text.Json; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Parses tokentracker sessions --format json. The export is not a promised API, so every +/// field is read defensively: a missing number becomes 0, an unreadable row is skipped, and +/// unparseable output returns null rather than throwing into the caller's face. +/// +public static class TokenTrackerExportParser +{ + /// The version value this parser was written against. + public const int SupportedFormatVersion = 11; + + public static TokenTrackerExport? Parse(string? json, DateTime fetchedAtUtc) + { + if (string.IsNullOrWhiteSpace(json)) return null; + + JsonDocument doc; + try { doc = JsonDocument.Parse(json); } + catch (JsonException) { return null; } + + using (doc) + { + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object) return null; + if (!root.TryGetProperty("sessions", out var sessions) || + sessions.ValueKind != JsonValueKind.Array) return null; + + var parsed = new List(); + var version = 0; + + foreach (var row in sessions.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) continue; + + version = Math.Max(version, (int)GetLong(row, "version")); + + var hash = GetString(row, "session_hash"); + if (string.IsNullOrWhiteSpace(hash)) continue; + + var source = GetString(row, "source") ?? ""; + if (!string.Equals(source, "claude", StringComparison.OrdinalIgnoreCase)) continue; + + if (!DateTimeOffset.TryParse(GetString(row, "started_at"), out var startedAt)) continue; + + var tokens = row.TryGetProperty("tokens", out var t) && t.ValueKind == JsonValueKind.Object + ? t + : default; + + parsed.Add(new TokenTrackerSession( + SessionHash: hash!, + Source: source, + Model: GetString(row, "model") ?? "", + StartedAt: startedAt, + InputTokens: GetLong(tokens, "input_tokens"), + OutputTokens: GetLong(tokens, "output_tokens"), + CacheReadTokens: GetLong(tokens, "cached_input_tokens"), + CacheCreationTokens: GetLong(tokens, "cache_creation_input_tokens"), + CostUsd: GetDouble(row, "cost_usd"), + Turns: (int)GetLong(row, "turns"), + RetryTurns: (int)GetLong(row, "retry_turns"), + Productive: GetBool(row, "productive"), + OneShot: GetBool(row, "one_shot"))); + } + + return new TokenTrackerExport(version, parsed, fetchedAtUtc); + } + } + + private static string? GetString(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.String + ? el.GetString() + : null; + + private static long GetLong(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.Number && + el.TryGetInt64(out var v) + ? v + : 0; + + private static double GetDouble(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.Number && + el.TryGetDouble(out var v) + ? v + : 0; + + private static bool GetBool(JsonElement obj, string prop) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(prop, out var el) && + el.ValueKind == JsonValueKind.True; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerExportParserTests +``` + +Expected: PASS, 11 Tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerExportParser.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerExportParserTests.cs +git commit -m "feat(usage): parse the TokenTracker sessions export defensively" +``` + +--- + +## Task 4: Aggregator + +Der Ersatz für die Aggregations-Innereien von `TranscriptUsageReader.ReadAsync`. Pure, kein I/O. + +Das Datum einer Session ist ihr **lokales** Startdatum (`StartedAt.LocalDateTime`), passend zu den +lokalen Datumspickern in der UI und zum Verhalten des alten Readers. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +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); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerAggregatorTests +``` + +Expected: Build-Fehler `CS0103: The name 'TokenTrackerAggregator' does not exist`. + +- [ ] **Step 3: Write minimal implementation** + +`TokenTrackerTotals` gehört fachlich zu den Aggregaten und wird nur hier gebraucht — nach der +Projektkonvention (kleine Single-Consumer-Typen liegen bei ihrem Konsumenten) steht es in dieser +Datei, nicht in `TokenTrackerModels.cs`. + +```csharp +namespace ClaudeDo.Worker.Usage.TokenTracker; + +public sealed record TokenTrackerTotals(long TotalTokens, double CostUsd, int Sessions); + +/// +/// Pure aggregation over a parsed export. One fetch covers a wide window and every view filters +/// it here, so 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. + 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; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerAggregatorTests +``` + +Expected: PASS, 9 Tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerAggregator.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerAggregatorTests.cs +git commit -m "feat(usage): aggregate TokenTracker sessions by model and per task" +``` + +--- + +## Task 5: TokenTrackerState + +Gleiches Muster wie `UsageState` — ein Fehler darf ein gutes Ergebnis nie überschreiben. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerStateTests +{ + private static TokenTrackerExport Export(DateTime fetchedAt) => + new(11, [], fetchedAt); + + [Fact] + public void FreshState_IsEmpty() + { + var state = new TokenTrackerState(); + + Assert.Null(state.Export); + Assert.Null(state.LastAttemptUtc); + Assert.Null(state.LastError); + } + + [Fact] + public void ReportSuccess_StoresExportAndClearsError() + { + var state = new TokenTrackerState(); + var at = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + state.ReportFailure("boom", at.AddMinutes(-1)); + + state.ReportSuccess(Export(at)); + + Assert.NotNull(state.Export); + Assert.Equal(at, state.LastAttemptUtc); + Assert.Null(state.LastError); + } + + [Fact] + public void ReportFailure_KeepsPreviousExport() + { + var state = new TokenTrackerState(); + var good = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + state.ReportSuccess(Export(good)); + + state.ReportFailure("tokentracker exited with code 1", good.AddMinutes(30)); + + Assert.NotNull(state.Export); + Assert.Equal(good, state.Export!.FetchedAtUtc); + Assert.Equal("tokentracker exited with code 1", state.LastError); + Assert.Equal(good.AddMinutes(30), state.LastAttemptUtc); + } + + [Fact] + public void IsOlderThan_NoExport_IsTrue() + { + Assert.True(new TokenTrackerState().IsOlderThan(TimeSpan.FromMinutes(15), + new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))); + } + + [Fact] + public void IsOlderThan_ComparesAgainstFetchTime() + { + var state = new TokenTrackerState(); + var fetched = new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + state.ReportSuccess(Export(fetched)); + + Assert.False(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(14))); + Assert.True(state.IsOlderThan(TimeSpan.FromMinutes(15), fetched.AddMinutes(16))); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerStateTests +``` + +Expected: Build-Fehler `CS0246: The type or namespace name 'TokenTrackerState' could not be found`. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Threadsafe holder for the last successful export. Mirrors : a failed +/// fetch never overwrites a good export, it only records . +/// +public sealed class TokenTrackerState +{ + private readonly object _lock = new(); + private TokenTrackerExport? _export; + private DateTime? _lastAttemptUtc; + private string? _lastError; + + public TokenTrackerExport? Export + { + get { lock (_lock) return _export; } + } + + public DateTime? LastAttemptUtc + { + get { lock (_lock) return _lastAttemptUtc; } + } + + public string? LastError + { + get { lock (_lock) return _lastError; } + } + + public void ReportSuccess(TokenTrackerExport export) + { + lock (_lock) + { + _export = export; + _lastAttemptUtc = export.FetchedAtUtc; + _lastError = null; + } + } + + public void ReportFailure(string error, DateTime attemptedAtUtc) + { + lock (_lock) + { + _lastAttemptUtc = attemptedAtUtc; + _lastError = error; + } + } + + public bool IsOlderThan(TimeSpan maxAge, DateTime nowUtc) + { + lock (_lock) + { + return _export is null || nowUtc - _export.FetchedAtUtc > maxAge; + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerStateTests +``` + +Expected: PASS, 5 Tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerState.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerStateTests.cs +git commit -m "feat(usage): hold the last good TokenTracker export fail-open" +``` + +--- + +## Task 6: CLI-Argumente + +Die Argumentlisten pure und getestet, damit der Prozess-Wrapper in Task 7 nichts Testbares mehr enthält. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs` + +- [ ] **Step 1: Write the failing test** + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerArgsTests +{ + [Fact] + public void Export_UsesIsoDatesAndDisablesGitAttribution() + { + var args = TokenTrackerArgs.Export(new DateOnly(2026, 5, 26), new DateOnly(2026, 8, 24)); + + Assert.Equal( + new[] { "sessions", "--from", "2026-05-26", "--to", "2026-08-24", "--no-git", "--format", "json" }, + args); + } + + [Fact] + public void Export_IsCultureInvariant() + { + var previous = Thread.CurrentThread.CurrentCulture; + Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE"); + try + { + var args = TokenTrackerArgs.Export(new DateOnly(2026, 1, 2), new DateOnly(2026, 1, 3)); + Assert.Equal("2026-01-02", args[2]); + Assert.Equal("2026-01-03", args[4]); + } + finally { Thread.CurrentThread.CurrentCulture = previous; } + } + + [Fact] + public void Version_AsksTheCliForItsVersion() + { + Assert.Equal(new[] { "-v" }, TokenTrackerArgs.Version()); + } + + [Fact] + public void Install_InstallsTheCliPackageGlobally() + { + Assert.Equal(new[] { "i", "-g", "tokentracker-cli" }, TokenTrackerArgs.Install()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerArgsTests +``` + +Expected: Build-Fehler `CS0103: The name 'TokenTrackerArgs' does not exist`. + +- [ ] **Step 3: Write minimal implementation** + +```csharp +using System.Globalization; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// The exact CLI invocations we rely on. --no-git matters: without it TokenTracker runs +/// git log inside every session's working directory, which is both slower and touches +/// repositories we have no business touching. We never invoke init — that would write +/// hooks into the user's global ~/.claude/settings.json and switch on cloud sync. +/// +public static class TokenTrackerArgs +{ + public const string Command = "tokentracker"; + public const string NpmCommand = "npm"; + public const string PackageName = "tokentracker-cli"; + + public static string[] Export(DateOnly from, DateOnly to) => + [ + "sessions", + "--from", from.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "--to", to.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "--no-git", + "--format", "json", + ]; + + public static string[] Version() => ["-v"]; + + public static string[] Install() => ["i", "-g", PackageName]; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerArgsTests +``` + +Expected: PASS, 4 Tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerArgs.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerArgsTests.cs +git commit -m "feat(usage): pin the TokenTracker CLI invocations" +``` + +--- + +## Task 7: TokenTrackerClient (Prozess-Wrapper) + +Der einzige Typ, der Prozesse startet. Nicht per Unit-Test abgedeckt — die pure Logik steckt in +Task 6, und ein Test, der das echte CLI startet, ist ausdrücklich verboten. Er wird über +`ITokenTrackerClient` gefakt. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs` +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs` + +- [ ] **Step 1: Write the interface** + +```csharp +namespace ClaudeDo.Worker.Usage.TokenTracker.Interfaces; + +public interface ITokenTrackerClient +{ + /// Checks whether the CLI and a usable Node runtime are present. Never throws. + Task ProbeAsync(CancellationToken ct = default); + + /// Runs the sessions export. Never throws — a failure comes back as + /// false plus an error message. + Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default); + + /// Runs npm i -g tokentracker-cli, reporting each output line. + Task InstallAsync(IProgress? output = null, CancellationToken ct = default); +} +``` + +Der `using ClaudeDo.Worker.Usage.TokenTracker;` fehlt hier absichtlich nicht — die Records liegen +im Elternnamespace, den das Interface über den Ordner-Namespace nicht automatisch sieht. Setze +oben `using ClaudeDo.Worker.Usage.TokenTracker;` **nicht** ein, sondern verlasse dich darauf, dass +`ClaudeDo.Worker.Usage.TokenTracker.Interfaces` den Elternnamespace `…Usage.TokenTracker` +implizit auflöst — das ist C#-Namespace-Regel und in diesem Projekt bei +`Usage/Interfaces/IUsageClient.cs` genauso gelöst. + +- [ ] **Step 2: Write the client** + +```csharp +using System.Diagnostics; +using System.Globalization; +using ClaudeDo.Data.Environment; +using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Starts the TokenTracker CLI. Resolution goes through because +/// an npm-installed CLI is a .cmd shim, which UseShellExecute = false cannot exec +/// directly. We deliberately never fall back to npx — that would silently download a +/// package behind the user's back. +/// +public sealed class TokenTrackerClient : ITokenTrackerClient +{ + private static readonly TimeSpan ExportTimeout = TimeSpan.FromSeconds(120); + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(20); + private static readonly TimeSpan InstallTimeout = TimeSpan.FromMinutes(5); + private const int MinimumNodeMajor = 20; + + public async Task ProbeAsync(CancellationToken ct = default) + { + var node = await RunAsync("node", ["--version"], ProbeTimeout, null, ct); + var nodeVersion = node.Ok ? node.StdOut.Trim() : null; + var nodeOk = NodeMajor(nodeVersion) >= MinimumNodeMajor; + + var cli = await RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Version(), ProbeTimeout, null, ct); + return cli.Ok + ? new TokenTrackerProbe(true, cli.StdOut.Trim(), nodeOk, nodeVersion, null) + : new TokenTrackerProbe(false, null, nodeOk, nodeVersion, cli.Error); + } + + public Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) => + RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct); + + public Task InstallAsync(IProgress? output = null, CancellationToken ct = default) => + RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct); + + /// Visible for the probe's version parsing; returns 0 when unreadable. + internal static int NodeMajor(string? version) + { + if (string.IsNullOrWhiteSpace(version)) return 0; + + var trimmed = version.Trim().TrimStart('v', 'V'); + var major = trimmed.Split('.', 2)[0]; + return int.TryParse(major, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : 0; + } + + private static async Task RunAsync( + string command, + IReadOnlyList arguments, + TimeSpan timeout, + IProgress? output, + CancellationToken ct) + { + var resolved = ExecutableResolver.Resolve(command); + if (resolved is null) + return new TokenTrackerRunResult(false, "", $"'{command}' not found on PATH."); + + var startInfo = new ProcessStartInfo + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + if (resolved.IsShim) + { + var shim = ExecutableResolver.BuildShimStartInfo(resolved.Path, arguments); + startInfo.FileName = shim.FileName; + startInfo.Arguments = shim.Arguments; + } + else + { + startInfo.FileName = resolved.Path; + foreach (var arg in arguments) startInfo.ArgumentList.Add(arg); + } + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(timeout); + + try + { + using var process = Process.Start(startInfo); + if (process is null) + return new TokenTrackerRunResult(false, "", $"Could not start '{command}'."); + + // Both pipes are drained concurrently — reading one to the end first deadlocks as + // soon as the other fills its buffer, and a 90-day export is several MB of stdout. + var stdoutTask = process.StandardOutput.ReadToEndAsync(timeoutCts.Token); + var stderrTask = ReadLinesAsync(process, output, timeoutCts.Token); + + await process.WaitForExitAsync(timeoutCts.Token); + var stdout = await stdoutTask; + var stderr = await stderrTask; + + return process.ExitCode == 0 + ? new TokenTrackerRunResult(true, stdout, null) + : new TokenTrackerRunResult(false, stdout, + $"'{command}' exited with code {process.ExitCode}. {Head(stderr)}".TrimEnd()); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return new TokenTrackerRunResult(false, "", $"'{command}' timed out after {timeout.TotalSeconds:0}s."); + } + catch (Exception ex) + { + return new TokenTrackerRunResult(false, "", $"'{command}' failed: {ex.Message}"); + } + } + + private static async Task ReadLinesAsync(Process process, IProgress? output, CancellationToken ct) + { + var lines = new List(); + while (await process.StandardError.ReadLineAsync(ct) is { } line) + { + lines.Add(line); + output?.Report(line); + } + return string.Join(System.Environment.NewLine, lines); + } + + private static string Head(string text) + { + var trimmed = text.Trim(); + return trimmed.Length <= 300 ? trimmed : trimmed[..300] + "…"; + } +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +``` + +Expected: Build succeeded, 0 Errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/Interfaces/ITokenTrackerClient.cs src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerClient.cs +git commit -m "feat(usage): run the TokenTracker CLI through the shared executable resolver" +``` + +--- + +## Task 8: TokenTrackerService + +Koordiniert Probe-Cache, Single-Flight-Refresh und Versionsprüfung. + +**Verhalten, das der Test festnagelt:** +- `EnsureFreshAsync` holt neu, wenn kein Export da ist oder er älter als `maxAge` ist; sonst nichts. +- Zwei parallele Refreshs führen zu **einem** CLI-Aufruf. +- Eine nicht unterstützte Format-Version wird wie ein Fehler behandelt (Klartext in `LastError`, + kein Ersetzen des letzten guten Exports). +- Ein fehlgeschlagener Aufruf lässt den letzten guten Export stehen. + +**Files:** +- Create: `src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs` + +- [ ] **Step 1: Write the fake** + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker; +using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class FakeTokenTrackerClient : ITokenTrackerClient +{ + private readonly SemaphoreSlim _gate = new(0); + + public TokenTrackerProbe Probe { get; set; } = new(true, "1.2.3", true, "v22.1.0", null); + public TokenTrackerRunResult ExportResult { get; set; } = + new(true, TokenTrackerFixtures.SessionsJson, null); + public TokenTrackerRunResult InstallResult { get; set; } = new(true, "added 1 package", null); + + public int ExportCalls; + public int ProbeCalls; + public int InstallCalls; + public DateOnly? LastFrom; + public DateOnly? LastTo; + + /// When true, blocks until is called. + public bool BlockExport { get; set; } + + public void Release() => _gate.Release(); + + public Task ProbeAsync(CancellationToken ct = default) + { + Interlocked.Increment(ref ProbeCalls); + return Task.FromResult(Probe); + } + + public async Task ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) + { + Interlocked.Increment(ref ExportCalls); + LastFrom = from; + LastTo = to; + if (BlockExport) await _gate.WaitAsync(ct); + return ExportResult; + } + + public Task InstallAsync(IProgress? output = null, CancellationToken ct = default) + { + Interlocked.Increment(ref InstallCalls); + output?.Report("added 1 package"); + return Task.FromResult(InstallResult); + } +} +``` + +- [ ] **Step 2: Write the failing test** + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker; + +namespace ClaudeDo.Worker.Tests.Usage.TokenTracker; + +public sealed class TokenTrackerServiceTests +{ + private static readonly DateTime Now = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc); + + private static (TokenTrackerService Service, FakeTokenTrackerClient Client) Build(DateTime? now = null) + { + var client = new FakeTokenTrackerClient(); + var clock = now ?? Now; + return (new TokenTrackerService(client, new TokenTrackerState(), () => clock), client); + } + + [Fact] + public async Task RefreshAsync_StoresParsedExport() + { + var (service, client) = Build(); + + await service.RefreshAsync(); + + Assert.Equal(1, client.ExportCalls); + Assert.Equal(4, service.State.Export!.Sessions.Count); + Assert.Null(service.State.LastError); + } + + [Fact] + public async Task RefreshAsync_RequestsTheConfiguredWindow() + { + var (service, client) = Build(); + + await service.RefreshAsync(); + + Assert.Equal(DateOnly.FromDateTime(Now.ToLocalTime()), client.LastTo); + Assert.Equal( + DateOnly.FromDateTime(Now.ToLocalTime()).AddDays(-TokenTrackerService.WindowDays), + client.LastFrom); + } + + [Fact] + public async Task RefreshAsync_Failure_KeepsLastGoodExport() + { + var (service, client) = Build(); + await service.RefreshAsync(); + + client.ExportResult = new TokenTrackerRunResult(false, "", "tokentracker exited with code 1"); + await service.RefreshAsync(); + + Assert.Equal(4, service.State.Export!.Sessions.Count); + Assert.Equal("tokentracker exited with code 1", service.State.LastError); + } + + [Fact] + public async Task RefreshAsync_UnparseableOutput_IsAnError() + { + var (service, client) = Build(); + client.ExportResult = new TokenTrackerRunResult(true, "not json", null); + + await service.RefreshAsync(); + + Assert.Null(service.State.Export); + Assert.NotNull(service.State.LastError); + } + + [Fact] + public async Task RefreshAsync_UnsupportedFormatVersion_IsAnErrorAndKeepsNoData() + { + var (service, client) = Build(); + client.ExportResult = new TokenTrackerRunResult(true, TokenTrackerFixtures.UnsupportedVersionJson, null); + + await service.RefreshAsync(); + + Assert.Null(service.State.Export); + Assert.Contains("99", service.State.LastError); + } + + [Fact] + public async Task EnsureFreshAsync_FreshExport_DoesNotFetchAgain() + { + var (service, client) = Build(); + await service.RefreshAsync(); + + await service.EnsureFreshAsync(TimeSpan.FromMinutes(15)); + + Assert.Equal(1, client.ExportCalls); + } + + [Fact] + public async Task EnsureFreshAsync_NoExportYet_Fetches() + { + var (service, client) = Build(); + + await service.EnsureFreshAsync(TimeSpan.FromMinutes(15)); + + Assert.Equal(1, client.ExportCalls); + } + + [Fact] + public async Task ConcurrentRefresh_RunsTheCliOnce() + { + var (service, client) = Build(); + client.BlockExport = true; + + var first = service.RefreshAsync(); + var second = service.RefreshAsync(); + client.Release(); + await Task.WhenAll(first, second); + + Assert.Equal(1, client.ExportCalls); + } + + [Fact] + public async Task ProbeAsync_CachesUntilForced() + { + var (service, client) = Build(); + + await service.ProbeAsync(); + await service.ProbeAsync(); + Assert.Equal(1, client.ProbeCalls); + + await service.ProbeAsync(force: true); + Assert.Equal(2, client.ProbeCalls); + } + + [Fact] + public async Task InstallAsync_ReprobesAfterwards() + { + var (service, client) = Build(); + await service.ProbeAsync(); + + var result = await service.InstallAsync(); + + Assert.True(result.Ok); + Assert.Equal(1, client.InstallCalls); + Assert.Equal(2, client.ProbeCalls); + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerServiceTests +``` + +Expected: Build-Fehler `CS0246: The type or namespace name 'TokenTrackerService' could not be found`. + +- [ ] **Step 4: Write minimal implementation** + +```csharp +using ClaudeDo.Worker.Usage.TokenTracker.Interfaces; + +namespace ClaudeDo.Worker.Usage.TokenTracker; + +/// +/// Owns the cached export. One fetch covers days because +/// buildSessionAnalytics walks the whole history regardless of --from/--to +/// (measured 11.5 s), so narrowing the request would buy nothing while making every range switch +/// pay the cost again. +/// +public sealed class TokenTrackerService +{ + public const int WindowDays = 90; + + private readonly ITokenTrackerClient _client; + private readonly Func _utcNow; + private readonly SemaphoreSlim _refreshLock = new(1, 1); + private TokenTrackerProbe? _probe; + + public TokenTrackerService(ITokenTrackerClient client, TokenTrackerState state, Func? utcNow = null) + { + _client = client; + State = state; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + } + + public TokenTrackerState State { get; } + + public async Task ProbeAsync(bool force = false, CancellationToken ct = default) + { + if (!force && _probe is { } cached) return cached; + + var probe = await _client.ProbeAsync(ct); + _probe = probe; + return probe; + } + + /// Fetches only when the cached export is missing or older than + /// . Safe to call on every modal open. + public Task EnsureFreshAsync(TimeSpan maxAge, CancellationToken ct = default) => + State.IsOlderThan(maxAge, _utcNow()) ? RefreshAsync(ct) : Task.CompletedTask; + + public async Task RefreshAsync(CancellationToken ct = default) + { + // A refresh already in flight is the refresh this caller wanted — waiting for it and + // returning is correct, and it stops a burst of range switches from spawning processes. + if (!await _refreshLock.WaitAsync(0, ct)) + { + await _refreshLock.WaitAsync(ct); + _refreshLock.Release(); + return; + } + + try + { + var now = _utcNow(); + var to = DateOnly.FromDateTime(now.ToLocalTime()); + var from = to.AddDays(-WindowDays); + + var run = await _client.ExportAsync(from, to, ct); + if (!run.Ok) + { + State.ReportFailure(run.Error ?? "TokenTracker export failed.", now); + return; + } + + var export = TokenTrackerExportParser.Parse(run.StdOut, now); + if (export is null) + { + State.ReportFailure("TokenTracker returned output we could not parse.", now); + return; + } + + if (export.FormatVersion != TokenTrackerExportParser.SupportedFormatVersion) + { + State.ReportFailure( + $"Unexpected TokenTracker export format v{export.FormatVersion} " + + $"(supported: v{TokenTrackerExportParser.SupportedFormatVersion}).", + now); + return; + } + + State.ReportSuccess(export); + } + finally + { + _refreshLock.Release(); + } + } + + public async Task InstallAsync( + IProgress? output = null, CancellationToken ct = default) + { + var result = await _client.InstallAsync(output, ct); + await ProbeAsync(force: true, ct); + return result; + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerServiceTests +``` + +Expected: PASS, 10 Tests. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TokenTracker/TokenTrackerService.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/FakeTokenTrackerClient.cs tests/ClaudeDo.Worker.Tests/Usage/TokenTracker/TokenTrackerServiceTests.cs +git commit -m "feat(usage): cache the TokenTracker export with single-flight refresh" +``` + +--- + +## Task 9: Hub-DTOs und Status-Methoden + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` +- Modify: `src/ClaudeDo.Worker/Program.cs` + +- [ ] **Step 1: Extend the DTOs** + +In `src/ClaudeDo.Worker/Hub/WorkerHub.cs` die beiden Records ersetzen (aktuell Zeile 161–179). +Die neuen Felder stehen hinten und sind nullable, damit SignalR-Clients ohne sie nicht brechen. + +```csharp +public record ModelUsageRowDto( + DateOnly Date, + string Model, + string Scope, + long InputTokens, + long OutputTokens, + long CacheReadTokens, + long CacheCreationTokens, + int Messages, + double? CostUsd = null); + +public record TaskUsageRowDto( + string TaskId, + string TaskTitle, + string ListId, + string ListName, + string? Model, + int Runs, + long TokensIn, + long TokensOut, + double? CostUsd = null, + int? Retries = null, + bool? Productive = null, + bool? OneShot = null); + +public record TokenTrackerStatusDto( + bool Installed, + string? Version, + bool NodeOk, + string? NodeVersion, + DateTime? LastFetchedUtc, + string? LastError, + int? FormatVersion, + int SessionCount); +``` + +- [ ] **Step 2: Add the service to the hub constructor** + +`_tokenTracker` als Feld neben `_usageReader` (bei Zeile 229) ergänzen: + +```csharp + private readonly ITranscriptUsageReader? _usageReader; + private readonly UsageMonitorService? _usageMonitor; + private readonly TokenTrackerService? _tokenTracker; +``` + +Im Konstruktor den optionalen Parameter **hinten** anhängen (nach `usageMonitor`, bei Zeile 263) +und zuweisen (bei Zeile 297): + +```csharp + TokenTrackerService? tokenTracker = null) +``` +```csharp + _tokenTracker = tokenTracker; +``` + +Oben `using ClaudeDo.Worker.Usage.TokenTracker;` ergänzen. + +- [ ] **Step 3: Add the three hub methods** + +Direkt vor `GetModelUsage` (Zeile 1180) einfügen: + +```csharp + public Task GetTokenTrackerStatus() => HubGuard(async () => + { + if (_tokenTracker is null) + return new TokenTrackerStatusDto(false, null, false, null, null, + "TokenTracker is not configured on this worker.", null, 0); + + var probe = await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted); + return BuildTokenTrackerStatus(probe); + }); + + public Task RefreshTokenTracker() => HubGuard(async () => + { + if (_tokenTracker is null) + throw new InvalidOperationException("TokenTracker is not configured."); + + await _tokenTracker.RefreshAsync(Context.ConnectionAborted); + return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted)); + }); + + public Task InstallTokenTracker() => HubGuard(async () => + { + if (_tokenTracker is null) + throw new InvalidOperationException("TokenTracker is not configured."); + + var progress = new Progress(line => _broadcaster.WorkerLog("Information", line)); + var result = await _tokenTracker.InstallAsync(progress, Context.ConnectionAborted); + if (!result.Ok) + _broadcaster.WorkerLog("Error", result.Error ?? "TokenTracker install failed."); + + return BuildTokenTrackerStatus(await _tokenTracker.ProbeAsync(ct: Context.ConnectionAborted)); + }); + + private TokenTrackerStatusDto BuildTokenTrackerStatus(TokenTrackerProbe probe) + { + var state = _tokenTracker!.State; + return new TokenTrackerStatusDto( + probe.Installed, + probe.Version, + probe.NodeOk, + probe.NodeVersion, + state.Export?.FetchedAtUtc, + state.LastError ?? probe.Error, + state.Export?.FormatVersion, + state.Export?.Sessions.Count ?? 0); + } +``` + +⚠️ Prüfe vor dem Bauen den echten Namen des Broadcaster-Feldes und die Signatur von +`WorkerLog` — grep `_broadcaster.WorkerLog` in `WorkerHub.cs` und übernimm die dort verwendete +Form. Ebenso die genaue Form von `HubGuard` (ob es `Task` oder `Func>` nimmt) aus +`GetModelUsage` direkt darunter. + +- [ ] **Step 4: Register in DI** + +In `src/ClaudeDo.Worker/Program.cs` bei den anderen Usage-Registrierungen (nach Zeile 236) +einfügen und oben `using ClaudeDo.Worker.Usage.TokenTracker;` plus +`using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;` ergänzen: + +```csharp +// TokenTracker — the analytics backend. Optional at runtime: every consumer degrades when the +// CLI is missing, so nothing here is a startup requirement. +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +- [ ] **Step 5: Build** + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +``` + +Expected: Build succeeded, 0 Errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs +git commit -m "feat(usage): expose TokenTracker status, refresh and install on the hub" +``` + +--- + +## Task 10: GetModelUsage und GetTaskUsage umstellen + +**Files:** +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` + +- [ ] **Step 1: Replace GetModelUsage** + +Die bestehende Methode (Zeile 1180–1190) komplett ersetzen: + +```csharp + /// + /// Model breakdown from the cached TokenTracker export. With no export at all we wait for one + /// (the caller already shows a spinner); with a stale one we serve it and refresh behind the + /// caller's back, because a fetch takes ~11 s and blocking a range switch on that is worse + /// than showing a timestamped older number. + /// + public Task> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () => + { + if (_tokenTracker is null) + return (IReadOnlyList)Array.Empty(); + + if (_tokenTracker.State.Export is null) + await _tokenTracker.EnsureFreshAsync(TimeSpan.Zero, Context.ConnectionAborted); + else + _ = _tokenTracker.EnsureFreshAsync(TimeSpan.FromMinutes(15), CancellationToken.None); + + var export = _tokenTracker.State.Export; + if (export is null) + return (IReadOnlyList)Array.Empty(); + + var claudeDoHashes = await ClaudeDoSessionHashesAsync(); + + return (IReadOnlyList)TokenTrackerAggregator + .ByModel(export.Sessions, from, to, claudeDoHashes) + .Select(r => new ModelUsageRowDto( + r.Date, r.Model, r.Scope, + r.InputTokens, r.OutputTokens, r.CacheReadTokens, r.CacheCreationTokens, + r.Sessions, r.CostUsd)) + .ToList(); + }); + + /// Hashes of every session one of our runs owns — the scope split rides on set + /// membership rather than the export's own project_key, which falls back to a bare + /// worktree GUID for exactly the sessions we care about. + private async Task> ClaudeDoSessionHashesAsync() + { + await using var ctx = await _dbFactory.CreateDbContextAsync(Context.ConnectionAborted); + var sessionIds = await ctx.TaskRuns + .Where(r => r.SessionId != null) + .Select(r => r.SessionId!) + .Distinct() + .ToListAsync(Context.ConnectionAborted); + + return sessionIds + .Select(SessionHash.ForClaudeSession) + .Where(h => h is not null) + .ToHashSet()!; + } +``` + +⚠️ Prüfe den echten Property-Namen von `TaskRunEntity.SessionId` (grep `SessionId` in +`src/ClaudeDo.Data/Models/`) und passe an, falls er anders heißt. + +- [ ] **Step 2: Enrich GetTaskUsage** + +In der bestehenden Methode (Zeile 1192–1232) die `Select`-Projektion um die Extras erweitern. +Vor dem `return` einfügen: + +```csharp + var export = _tokenTracker?.State.Export; +``` + +und die Projektion ersetzen: + +```csharp + .Select(g => + { + var task = taskById[g.Key]; + var listName = lists.TryGetValue(task.ListId, out var list) ? list.Name : ""; + var latestModel = g.OrderByDescending(r => r.StartedAt).First().Model; + var extras = export is null + ? null + : TokenTrackerAggregator.ForSessions(export.Sessions, g.Select(r => r.SessionId)); + + return new TaskUsageRowDto( + task.Id, + task.Title, + task.ListId, + listName, + latestModel, + g.Count(), + g.Sum(r => (long)(r.TokensIn ?? 0)), + g.Sum(r => (long)(r.TokensOut ?? 0)), + extras?.CostUsd, + extras?.Retries, + extras?.Productive, + extras?.OneShot); + }) +``` + +- [ ] **Step 3: Build** + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +``` + +Expected: Build succeeded, 0 Errors. + +- [ ] **Step 4: Run the whole worker suite** + +```bash +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +``` + +Expected: alle Tests grün außer denen aus `TranscriptUsageReaderTests`, die `ReadAsync` betreffen +— die räumt Task 11 auf. Falls dort noch alles grün ist, ist das auch in Ordnung; `ReadAsync` +existiert an dieser Stelle noch. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Hub/WorkerHub.cs +git commit -m "feat(usage): serve model and task usage from the TokenTracker export" +``` + +--- + +## Task 11: TranscriptUsageReader abspecken + +**Files:** +- Modify: `src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs` +- Modify: `src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs` +- Modify: `src/ClaudeDo.Worker/Usage/UsageModels.cs` +- Modify: `tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs` + +- [ ] **Step 1: Shrink the interface** + +`src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs` vollständig ersetzen: + +```csharp +namespace ClaudeDo.Worker.Usage.Interfaces; + +public interface ITranscriptUsageReader +{ + /// Cumulative raw token totals for one session's transcript file + /// (located by {sessionId}.jsonl under the projects root), or null when + /// no matching transcript file can be found or read. + Task ReadSessionTotalsAsync(string sessionId, CancellationToken ct = default); +} +``` + +- [ ] **Step 2: Remove the aggregation path from the reader** + +In `src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs` entfernen: + +- die Methode `ReadAsync` (Zeilen 23–69) +- `ResolveScope`, `IsUnderRoot`, `NormalizePath` (Zeilen 159–182) +- die Klasse `Accumulator` (Zeilen 184–188) +- die Felder `_centralRoot` und `_sandboxRoot` samt ihrer Zuweisungen im Konstruktor + +Der Konstruktor wird damit zu: + +```csharp + public TranscriptUsageReader(WorkerConfig cfg, string? projectsRoot = null) + { + _projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects"); + } +``` + +Der `cfg`-Parameter bleibt erhalten, damit die DI-Registrierung unverändert funktioniert; setze +darüber einen Kommentar: + +```csharp + // cfg is unused since the model/scope aggregation moved to TokenTracker, but the parameter + // stays so the DI registration and every existing call site keep working. +``` + +`UsageMessageRecord` verliert Datum und Scope, weil `ReadSessionTotalsAsync` beides nicht liest: + +```csharp + private sealed record UsageMessageRecord( + long InputTokens, long OutputTokens, long CacheReadTokens, long CacheCreationTokens, + string DedupeKey); +``` + +Und in `ReadFile` entfällt entsprechend die Datums-/Scope-Berechnung; der Push wird: + +```csharp + records.Add(new UsageMessageRecord(input, output, cacheRead, cacheCreation, dedupeKey)); +``` + +Die `timestamp`- und `model`-Prüfungen **bleiben** stehen — sie filtern Nicht-Assistant-Zeilen und +``-Modelle, was für die Session-Summen weiterhin richtig ist. Die lokale Variable +`date` und `var cwd = …` fallen weg (sonst Compiler-Warnung „unused"). + +- [ ] **Step 3: Drop the dead models** + +In `src/ClaudeDo.Worker/Usage/UsageModels.cs` `UsageScope` (Zeilen 20–24) und +`UsageAggregateRow` (Zeilen 26–34) löschen. + +- [ ] **Step 4: Prune the reader tests** + +In `tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs` jeden Test löschen, der +`ReadAsync` aufruft, `UsageAggregateRow` oder `UsageScope` verwendet. Die Tests für +`ReadSessionTotalsAsync` bleiben unverändert. Ist danach keine Testmethode mehr übrig, lösche die +Datei ganz. + +- [ ] **Step 5: Build and run the suite** + +```bash +dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release +dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release +``` + +Expected: Build succeeded; alle Tests grün. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Usage/TranscriptUsageReader.cs src/ClaudeDo.Worker/Usage/Interfaces/ITranscriptUsageReader.cs src/ClaudeDo.Worker/Usage/UsageModels.cs tests/ClaudeDo.Worker.Tests/Usage/TranscriptUsageReaderTests.cs +git commit -m "refactor(usage): drop the transcript aggregation path in favour of TokenTracker" +``` + +--- + +## Task 12: UI-Client-Oberfläche + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` + +- [ ] **Step 1: Extend the interface** + +Nach `GetTaskUsageAsync` (Zeile 209) einfügen: + +```csharp + Task GetTokenTrackerStatusAsync(); + Task RefreshTokenTrackerAsync(); + Task InstallTokenTrackerAsync(); +``` + +- [ ] **Step 2: Implement them** + +In `src/ClaudeDo.Ui/Services/WorkerClient.cs` nach `GetTaskUsageAsync` (Zeile 690) einfügen: + +```csharp + public Task GetTokenTrackerStatusAsync() + => TryInvokeAsync("GetTokenTrackerStatus"); + + public Task RefreshTokenTrackerAsync() + => TryInvokeAsync("RefreshTokenTracker"); + + public Task InstallTokenTrackerAsync() + => TryInvokeAsync("InstallTokenTracker"); +``` + +- [ ] **Step 3: Extend the DTOs** + +`ModelUsageRowDto` (Zeile 841) und `TaskUsageRowDto` (Zeile 851) müssen die Worker-Records +spiegeln. Ergänze in beiden dieselben neuen, optionalen Felder wie in Task 9 Schritt 1, und +füge danach den neuen Record an: + +```csharp +public sealed record TokenTrackerStatusDto( + bool Installed, + string? Version, + bool NodeOk, + string? NodeVersion, + DateTime? LastFetchedUtc, + string? LastError, + int? FormatVersion, + int SessionCount); +``` + +- [ ] **Step 4: Build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: Fehler in `ClaudeDo.Ui.Tests`-Fakes sind hier noch nicht sichtbar (anderes Projekt); +`ClaudeDo.App` muss grün bauen. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs +git commit -m "feat(ui): add TokenTracker status, refresh and install to the worker client" +``` + +--- + +## Task 13: Localization-Keys + +Beide Dateien müssen in Parität bleiben — `Localization.Tests` erzwingt das. + +**Files:** +- Modify: `src/ClaudeDo.Localization/locales/en.json` +- Modify: `src/ClaudeDo.Localization/locales/de.json` + +- [ ] **Step 1: Add the English keys** + +In `en.json` im Block `"usageMonitor"` (endet Zeile 532 mit `"columnTotal": "TOTAL"`) das Komma +ergänzen und anhängen: + +```json + "columnTotal": "TOTAL", + "columnCost": "COST", + "columnRetries": "RETRIES", + "analyticsStampFormat": "Analytics as of {0}", + "analyticsNever": "Analytics not loaded yet", + "analyticsRefresh": "Refresh analytics", + "analyticsTotalsFormat": "{0} tokens · ${1}", + "ttMissingTitle": "Token analytics need TokenTracker", + "ttMissingBody": "TokenTracker (MIT) reads your local Claude transcripts and never sees prompts. ClaudeDo only calls its local export — it does not run `init`, installs no hooks and enables no cloud sync.", + "ttInstall": "Install TokenTracker", + "ttNodeHintFormat": "Node 20 or newer is required (found: {0}).", + "ttNodeMissing": "Node 20 or newer is required, but no Node runtime was found.", + "ttErrorFormat": "TokenTracker problem: {0}" +``` + +Im Block `vm.usageMonitor` (Zeile 735–738) nach `thresholdSaveFailed` ergänzen: + +```json + "thresholdSaveFailed": "Couldn't save the threshold: {0}", + "installStarted": "Installing TokenTracker — watch the log strip for progress.", + "installFailed": "Couldn't install TokenTracker: {0}", + "analyticsRefreshFailed": "Couldn't refresh the analytics: {0}" +``` + +- [ ] **Step 2: Add the German keys** + +In `de.json` an denselben zwei Stellen: + +```json + "columnTotal": "GESAMT", + "columnCost": "KOSTEN", + "columnRetries": "RETRIES", + "analyticsStampFormat": "Auswertung: Stand {0}", + "analyticsNever": "Auswertung noch nicht geladen", + "analyticsRefresh": "Auswertung aktualisieren", + "analyticsTotalsFormat": "{0} Tokens · {1} $", + "ttMissingTitle": "Die Token-Auswertung braucht TokenTracker", + "ttMissingBody": "TokenTracker (MIT) liest deine lokalen Claude-Transkripte und sieht keine Prompts. ClaudeDo ruft nur den lokalen Export auf — kein `init`, keine Hooks, kein Cloud-Sync.", + "ttInstall": "TokenTracker installieren", + "ttNodeHintFormat": "Node 20 oder neuer wird benötigt (gefunden: {0}).", + "ttNodeMissing": "Node 20 oder neuer wird benötigt, es wurde aber kein Node gefunden.", + "ttErrorFormat": "TokenTracker-Problem: {0}" +``` + +```json + "thresholdSaveFailed": "Der Schwellwert konnte nicht gespeichert werden: {0}", + "installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.", + "installFailed": "TokenTracker konnte nicht installiert werden: {0}", + "analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}" +``` + +- [ ] **Step 3: Run the parity test** + +```bash +dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release +``` + +Expected: PASS. Bei einem Parity-Fail nennt die Meldung den fehlenden Key — den in der anderen +Datei nachtragen. + +- [ ] **Step 4: Commit** + +```bash +git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json +git commit -m "feat(i18n): add TokenTracker analytics strings in en and de" +``` + +--- + +## Task 14: Usage-Monitor-ViewModel + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs` + +- [ ] **Step 1: Extend the display rows** + +Am Dateiende (Zeilen 492–517) die zwei Records ersetzen: + +```csharp +public sealed record ModelUsageDisplayRow( + string Model, + long ClaudeDoInputTokens, + long ClaudeDoOutputTokens, + long ClaudeDoCacheTokens, + long OtherInputTokens, + long OtherOutputTokens, + long OtherCacheTokens, + double CostUsd) +{ + public double SharePercent { get; init; } + public long ClaudeDoTotal => ClaudeDoInputTokens + ClaudeDoOutputTokens + ClaudeDoCacheTokens; + public long OtherTotal => OtherInputTokens + OtherOutputTokens + OtherCacheTokens; + public long GrandTotal => ClaudeDoTotal + OtherTotal; + public string CostText => CostUsd > 0 ? CostUsd.ToString("0.00") : "—"; +} + +public sealed record TaskUsageDisplayRow( + string TaskId, + string TaskTitle, + string ListName, + string? Model, + int Runs, + long TokensIn, + long TokensOut, + double? CostUsd, + int? Retries) +{ + public long TotalTokens => TokensIn + TokensOut; + public string CostText => CostUsd is > 0 ? CostUsd.Value.ToString("0.00") : "—"; + public string RetriesText => Retries?.ToString() ?? "—"; +} +``` + +- [ ] **Step 2: Add the TokenTracker state to the VM** + +Nach `_taskRows` (Zeile 51) einfügen: + +```csharp + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TokenTrackerMissing))] + [NotifyPropertyChangedFor(nameof(ShowInstallButton))] + [NotifyPropertyChangedFor(nameof(NodeHintText))] + [NotifyPropertyChangedFor(nameof(TokenTrackerErrorText))] + [NotifyPropertyChangedFor(nameof(HasTokenTrackerError))] + [NotifyPropertyChangedFor(nameof(AnalyticsStampText))] + private TokenTrackerStatusDto? _tokenTracker; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(TotalsText))] + private double _totalCostUsd; + + [ObservableProperty] private bool _isInstalling; +``` + +Nach `TasksEmpty` (Zeile 54) die abgeleiteten Properties: + +```csharp + public bool TokenTrackerMissing => TokenTracker is { Installed: false }; + public bool ShowInstallButton => TokenTracker is { Installed: false, NodeOk: true } && !IsInstalling; + + public string NodeHintText => TokenTracker switch + { + { NodeOk: true } => "", + { NodeVersion: { Length: > 0 } v } => Loc.T("modals.usageMonitor.ttNodeHintFormat", v), + _ => Loc.T("modals.usageMonitor.ttNodeMissing"), + }; + + public bool HasTokenTrackerError => TokenTracker?.LastError is { Length: > 0 }; + public string TokenTrackerErrorText => TokenTracker?.LastError is { Length: > 0 } e + ? Loc.T("modals.usageMonitor.ttErrorFormat", e) + : ""; + + public string AnalyticsStampText => TokenTracker?.LastFetchedUtc is { } t + ? Loc.T("modals.usageMonitor.analyticsStampFormat", t.ToLocalTime().ToString("HH:mm")) + : Loc.T("modals.usageMonitor.analyticsNever"); + + public string TotalsText => Loc.T( + "modals.usageMonitor.analyticsTotalsFormat", + ModelRows.Sum(r => r.GrandTotal).ToString("N0"), + TotalCostUsd.ToString("0.00")); +``` + +`ModelRows` muss `TotalsText` mit auffrischen — ergänze bei `_modelRows` (Zeile 45–47) ein +weiteres Attribut: + +```csharp + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ModelsEmpty))] + [NotifyPropertyChangedFor(nameof(TotalsText))] + private IReadOnlyList _modelRows = Array.Empty(); +``` + +Und `IsInstalling` muss den Button-Zustand mitziehen: + +```csharp + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowInstallButton))] + private bool _isInstalling; +``` + +(also die obige `_isInstalling`-Deklaration durch diese ersetzen). + +- [ ] **Step 3: Load the status and carry the cost through** + +`LoadUsageDataAsync` (Zeile 175–196) ersetzen: + +```csharp + private async Task LoadUsageDataAsync() + { + if (!RangeValid) return; + IsBusy = true; + try + { + var from = DateOnly.FromDateTime(StartDate!.Value); + var to = DateOnly.FromDateTime(EndDate!.Value); + var modelRows = await _worker.GetModelUsageAsync(from, to); + var taskRows = await _worker.GetTaskUsageAsync(from, to); + ModelRows = BuildModelDisplayRows(modelRows); + TotalCostUsd = modelRows.Sum(r => r.CostUsd ?? 0); + TaskRows = taskRows + .Select(r => new TaskUsageDisplayRow( + r.TaskId, r.TaskTitle, r.ListName, r.Model, r.Runs, r.TokensIn, r.TokensOut, + r.CostUsd, r.Retries)) + .OrderByDescending(r => r.TotalTokens) + .ToList(); + TokenTracker = await _worker.GetTokenTrackerStatusAsync(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message)); + } + finally { IsBusy = false; } + } +``` + +`BuildModelDisplayRows` (Zeile 300–326) um die Kosten erweitern: `double cost = 0;` neben den +sechs Token-Summen deklarieren, in der Schleife `cost += row.CostUsd ?? 0;` addieren und den +Konstruktoraufruf zu + +```csharp + built.Add(new ModelUsageDisplayRow(group.Key, cdIn, cdOut, cdCache, otIn, otOut, otCache, cost)); +``` + +ändern. + +- [ ] **Step 4: Add the two commands** + +Nach `SetPreset30Days` (Zeile 155) einfügen: + +```csharp + [RelayCommand] + private async Task RefreshAnalytics() + { + try + { + TokenTracker = await _worker.RefreshTokenTrackerAsync(); + await LoadUsageDataAsync(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.analyticsRefreshFailed", ex.Message)); + } + } + + [RelayCommand] + private async Task InstallTokenTracker() + { + IsInstalling = true; + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installStarted")); + try + { + TokenTracker = await _worker.InstallTokenTrackerAsync(); + if (TokenTracker is { Installed: true }) await RefreshAnalytics(); + } + catch (Exception ex) + { + ErrorReported?.Invoke(Loc.T("vm.usageMonitor.installFailed", ex.Message)); + } + finally { IsInstalling = false; } + } +``` + +`ErrorReported` ist der Kanal, den die Shell in `FlashFooterError` hängt — deshalb geht auch die +Erfolgsmeldung „Installation läuft" dort durch, statt in einem eigenen Banner zu landen. + +- [ ] **Step 5: Build** + +```bash +dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release +``` + +Expected: Build succeeded, 0 Errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Modals/UsageMonitorModalViewModel.cs +git commit -m "feat(ui): surface TokenTracker status and cost in the usage monitor" +``` + +--- + +## Task 15: Usage-Monitor-View + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml` + +- [ ] **Step 1: Add the hint card and the analytics header** + +Direkt vor `` (Zeile 150) einfügen: + +```xml + + + + + + + +