# 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.