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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add the cost column to the model table**
+
+Im Models-Tab die drei `ColumnDefinitions="*,80,80,80,80,80,80,60"` (Zeilen 158 und 172) jeweils
+auf `"*,80,80,80,80,80,80,60,80"` ändern, im Header nach der SHARE-Spalte ergänzen:
+
+```xml
+
+```
+
+und in der Zeilenvorlage nach der Share-Zelle:
+
+```xml
+
+```
+
+- [ ] **Step 3: Add cost and retries to the task table**
+
+Im Tasks-Tab beide `ColumnDefinitions="*,110,90,60,80,80,80"` (Zeilen 195 und 208) auf
+`"*,110,90,60,80,80,80,80,70"` ändern, im Header nach TOTAL ergänzen:
+
+```xml
+
+
+```
+
+und in der Zeilenvorlage nach der Total-Zelle:
+
+```xml
+
+
+```
+
+- [ ] **Step 4: Build**
+
+```bash
+dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
+```
+
+Expected: Build succeeded. Compiled bindings sind streng — ein Tippfehler in einem Property-Namen
+bricht hier den Build, nicht erst zur Laufzeit.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Ui/Views/Modals/UsageMonitorModalView.axaml
+git commit -m "feat(ui): show cost columns and the TokenTracker hint card in the usage monitor"
+```
+
+---
+
+## Task 16: Fakes nachziehen und alles bauen
+
+Das Erweitern von `IWorkerClient` bricht die handgeschriebenen Fakes. Diese Aufgabe schließt das
+und stellt sicher, dass alle sechs Testprojekte grün sind.
+
+**Files:**
+- Modify: Fakes in `tests/ClaudeDo.Ui.Tests/` (Pfade in Schritt 1 ermitteln)
+
+- [ ] **Step 1: Find the fakes**
+
+```bash
+grep -rln "IWorkerClient" tests/
+```
+
+Jede Datei, die `IWorkerClient` implementiert, braucht die drei neuen Methoden.
+
+- [ ] **Step 2: Add the members to every fake**
+
+In jedem gefundenen Fake ergänzen (Rückgabe „nicht installiert", damit bestehende Tests den
+Hinweis-Pfad sehen und nicht in Nullreferenzen laufen):
+
+```csharp
+ public Task GetTokenTrackerStatusAsync() =>
+ Task.FromResult(new TokenTrackerStatusDto(
+ Installed: false, Version: null, NodeOk: true, NodeVersion: "v22.0.0",
+ LastFetchedUtc: null, LastError: null, FormatVersion: null, SessionCount: 0));
+
+ public Task RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
+
+ public Task InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
+```
+
+- [ ] **Step 3: Build everything**
+
+```bash
+dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
+```
+
+Expected: beide Build succeeded, 0 Errors.
+
+- [ ] **Step 4: Run all six suites**
+
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
+dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
+dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
+dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
+dotnet test tests/ClaudeDo.Installer.Tests/ClaudeDo.Installer.Tests.csproj -c Release
+dotnet test tests/ClaudeDo.Releases.Tests/ClaudeDo.Releases.Tests.csproj -c Release
+```
+
+Expected: alle grün. **Wichtig:** `Ui.Tests` hat eine bekannte Reihenfolge-Abhängigkeit — schlägt
+dort ein Test fehl, lauf ihn einzeln nach (`--filter FullyQualifiedName~`). Grün im
+Alleingang heißt Flakiness, nicht Regression; melde es als solches statt es zu „fixen".
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/
+git commit -m "test(ui): teach the worker-client fakes about TokenTracker status"
+```
+
+---
+
+## Task 17: Dokumentation
+
+**Files:**
+- Modify: `docs/explore-notes/usage-monitoring.md`
+- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
+- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
+
+- [ ] **Step 1: Update the explore note**
+
+In `docs/explore-notes/usage-monitoring.md`:
+
+- Kopfzeile „Last verified against commit" auf den aktuellen HEAD setzen
+ (`git rev-parse --short HEAD`) und das Datum auf den Umsetzungstag.
+- Im Abschnitt *Components (`Usage/`)* die Zeile zu `TranscriptUsageReader` auf
+ „Reads one session's cumulative totals for per-run accounting; **no** aggregation" ändern.
+- Einen neuen Abschnitt **„Analytics via TokenTracker"** ergänzen mit: Datenquelle
+ (`tokentracker sessions --no-git --format json`), dem `session_hash`-Join
+ (`sha256("claude\0"+id)[0..24]`), dem 90-Tage-Fetch mit lokaler Range-Filterung, der
+ 15-Minuten-Frische-Regel, dem Verhalten bei fehlendem CLI, der Versionsprüfung gegen
+ `SupportedFormatVersion`, und der ausdrücklichen Feststellung, dass Limits/Gate/Throttle
+ **nicht** von TokenTracker kommen.
+- Im Abschnitt *Per-run token accounting* klarstellen, dass `ReadSessionTotalsAsync` bleibt.
+- Im Abschnitt *Hub surface* `GetModelUsage`/`GetTaskUsage` neu beschreiben und die drei neuen
+ Methoden ergänzen.
+
+- [ ] **Step 2: Update the Worker CLAUDE.md**
+
+Im Abschnitt *Folder Layout* die `Usage/`-Zeile erweitern:
+
+```
+ Usage/ — OAuth usage monitor, gate, throttle, per-session token reader;
+ TokenTracker/ = the external analytics backend (cost + per-model/per-task breakdown)
+```
+
+- [ ] **Step 3: Update the Ui CLAUDE.md**
+
+In der Modal-VM-Tabelle die `UsageMonitorModalViewModel`-Zeile um einen Satz ergänzen: der
+Analytics-Teil (Modelle/Tasks/Kosten) kommt aus dem TokenTracker-Export, zeigt bei fehlendem CLI
+eine Hinweis-Karte mit Install-Button, und Gauges/Gate bleiben davon unberührt.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/explore-notes/usage-monitoring.md src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
+git commit -m "docs(usage): document TokenTracker as the analytics backend"
+```
+
+---
+
+## Offene Verifikationspunkte (nicht Teil der Tasks)
+
+Diese drei Punkte kann kein Test abdecken — sie gehören in die Übergabe an den Nutzer:
+
+1. **Visueller Pass** über den umgebauten Analytics-Block, die Kostenspalten und die Hinweis-Karte,
+ in **beiden** Zuständen (TokenTracker installiert / nicht installiert). Nie behaupten, die UI
+ funktioniere, ohne sie gesehen zu haben.
+2. **Echter Durchlauf ohne TokenTracker:** Probe → Hinweis-Karte → Install-Button → Re-Probe →
+ erste Auswertung. Zum Nachstellen `npm rm -g tokentracker-cli` auf einer Maschine, auf der es
+ niemandem wehtut — **nicht** auf der Entwicklungsmaschine, dort ist das Tool in Benutzung.
+3. **Plausibilitätsvergleich:** einmal einen 7-Tage-Range in der App gegen `tokentracker sessions`
+ von Hand vergleichen und prüfen, dass der ClaudeDo/Other-Split und die Kostensumme passen.