94 KiB
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 gegenITokenTrackerClient-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.Testsmüsste sonst eineCopyToOutputDirectory-Regel in der csproj bekommen, und Dateipfade in Tests sind eine unnötige Fehlerquelle. Einconst stringinTokenTrackerFixtures.csist genauso eingecheckt. - Build auf dieser Maschine:
dotnet build ClaudeDo.slnxscheitert (braucht .NET 9). Immer einzelne csproj mit-c Releasebauen — ein laufender Worker sperrt dasDebug-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.
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
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
using System.Security.Cryptography;
using System.Text;
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Reproduces TokenTracker's session identity so its export rows can be joined onto our
/// <c>task_runs.session_id</c>. The formula is <c>sha256(source + "\0" + id)</c>, hex, first
/// 24 chars (TokenTracker's <c>lib/session-analytics.js</c>, <c>sessionHash()</c>). The export
/// never carries the raw session id, so this is the only way to attribute a row to a task.
/// </summary>
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
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~SessionHashTests
Expected: PASS, 5 Tests.
- Step 5: Commit
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
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>One row of <c>tokentracker sessions --format json</c>. Field names mirror the
/// export's <c>snake_case</c> keys: <c>cached_input_tokens</c> is the cache *read* count,
/// <c>cache_creation_input_tokens</c> the write count.</summary>
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);
/// <summary>A parsed export plus when we fetched it. <paramref name="FormatVersion"/> is the
/// <c>version</c> field of the session rows — the only compatibility signal the export gives us.</summary>
public sealed record TokenTrackerExport(
int FormatVersion,
IReadOnlyList<TokenTrackerSession> 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);
}
/// <summary>Outcome of one CLI invocation. <paramref name="Ok"/> false always carries an
/// <paramref name="Error"/> — callers turn that into state, never into an exception.</summary>
public sealed record TokenTrackerRunResult(bool Ok, string StdOut, string? Error);
/// <summary>Aggregated per date/model/scope. <c>Scope</c> is <c>"claudedo"</c> or <c>"other"</c>,
/// decided by whether the session hash belongs to one of our runs. <c>Sessions</c> replaces the
/// old assistant-message count — the export has no message granularity.</summary>
public sealed record TokenTrackerModelRow(
DateOnly Date,
string Model,
string Scope,
long InputTokens,
long OutputTokens,
long CacheReadTokens,
long CacheCreationTokens,
int Sessions,
double CostUsd);
/// <summary>What the export adds on top of what <c>task_runs</c> already knows about a task.</summary>
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.
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
public static class TokenTrackerFixtures
{
/// <summary>Session id whose hash is the fixture's first ClaudeDo session.</summary>
public const string ClaudeDoSessionIdA = "77470328-94f7-49d3-b378-a562d1501b5f";
/// <summary>Session id whose hash is the fixture's second ClaudeDo session.</summary>
public const string ClaudeDoSessionIdB = "20ebc1bc-3e6a-4c09-a4b3-19472dffa841";
/// <summary>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.</summary>
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
}
]
}
""";
/// <summary>Same as <see cref="SessionsJson"/> but with a future format version.</summary>
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
dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
Expected: Build succeeded, 0 Errors.
- Step 4: Commit
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
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
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
using System.Text.Json;
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Parses <c>tokentracker sessions --format json</c>. 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.
/// </summary>
public static class TokenTrackerExportParser
{
/// <summary>The <c>version</c> value this parser was written against.</summary>
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<TokenTrackerSession>();
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
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerExportParserTests
Expected: PASS, 11 Tests.
- Step 5: Commit
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
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<TokenTrackerSession> Sessions() =>
TokenTrackerExportParser.Parse(
TokenTrackerFixtures.SessionsJson,
new DateTime(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc))!.Sessions;
private static HashSet<string> 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<string>());
Assert.All(rows, r => Assert.Equal("other", r.Scope));
}
[Fact]
public void ByModel_EmptyInput_ReturnsEmpty()
{
Assert.Empty(TokenTrackerAggregator.ByModel([], RangeStart, RangeEnd, new HashSet<string>()));
}
[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
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.
namespace ClaudeDo.Worker.Usage.TokenTracker;
public sealed record TokenTrackerTotals(long TotalTokens, double CostUsd, int Sessions);
/// <summary>
/// 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.
/// </summary>
public static class TokenTrackerAggregator
{
public const string ScopeClaudeDo = "claudedo";
public const string ScopeOther = "other";
public static IReadOnlyList<TokenTrackerModelRow> ByModel(
IReadOnlyList<TokenTrackerSession> sessions,
DateOnly from,
DateOnly to,
ISet<string> 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();
}
/// <summary>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.</summary>
public static TokenTrackerTaskExtras? ForSessions(
IReadOnlyList<TokenTrackerSession> sessions,
IEnumerable<string?> 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<TokenTrackerSession> 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<TokenTrackerSession> InRange(
IReadOnlyList<TokenTrackerSession> sessions, DateOnly from, DateOnly to) =>
sessions.Where(s => DateOf(s) >= from && DateOf(s) <= to);
/// <summary>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.</summary>
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
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerAggregatorTests
Expected: PASS, 9 Tests.
- Step 5: Commit
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
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
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
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Threadsafe holder for the last successful export. Mirrors <see cref="UsageState"/>: a failed
/// fetch never overwrites a good export, it only records <see cref="LastError"/>.
/// </summary>
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
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerStateTests
Expected: PASS, 5 Tests.
- Step 5: Commit
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
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
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
using System.Globalization;
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// The exact CLI invocations we rely on. <c>--no-git</c> matters: without it TokenTracker runs
/// <c>git log</c> inside every session's working directory, which is both slower and touches
/// repositories we have no business touching. We never invoke <c>init</c> — that would write
/// hooks into the user's global <c>~/.claude/settings.json</c> and switch on cloud sync.
/// </summary>
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
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerArgsTests
Expected: PASS, 4 Tests.
- Step 5: Commit
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
namespace ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
public interface ITokenTrackerClient
{
/// <summary>Checks whether the CLI and a usable Node runtime are present. Never throws.</summary>
Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default);
/// <summary>Runs the sessions export. Never throws — a failure comes back as
/// <see cref="TokenTrackerRunResult.Ok"/> false plus an error message.</summary>
Task<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default);
/// <summary>Runs <c>npm i -g tokentracker-cli</c>, reporting each output line.</summary>
Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? 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
using System.Diagnostics;
using System.Globalization;
using ClaudeDo.Data.Environment;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Starts the TokenTracker CLI. Resolution goes through <see cref="ExecutableResolver"/> because
/// an npm-installed CLI is a <c>.cmd</c> shim, which <c>UseShellExecute = false</c> cannot exec
/// directly. We deliberately never fall back to <c>npx</c> — that would silently download a
/// package behind the user's back.
/// </summary>
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<TokenTrackerProbe> 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<TokenTrackerRunResult> ExportAsync(DateOnly from, DateOnly to, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.Command, TokenTrackerArgs.Export(from, to), ExportTimeout, null, ct);
public Task<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default) =>
RunAsync(TokenTrackerArgs.NpmCommand, TokenTrackerArgs.Install(), InstallTimeout, output, ct);
/// <summary>Visible for the probe's version parsing; returns 0 when unreadable.</summary>
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<TokenTrackerRunResult> RunAsync(
string command,
IReadOnlyList<string> arguments,
TimeSpan timeout,
IProgress<string>? 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<string> ReadLinesAsync(Process process, IProgress<string>? output, CancellationToken ct)
{
var lines = new List<string>();
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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded, 0 Errors.
- Step 4: Commit
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:
EnsureFreshAsyncholt neu, wenn kein Export da ist oder er älter alsmaxAgeist; 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
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;
/// <summary>When true, <see cref="ExportAsync"/> blocks until <see cref="Release"/> is called.</summary>
public bool BlockExport { get; set; }
public void Release() => _gate.Release();
public Task<TokenTrackerProbe> ProbeAsync(CancellationToken ct = default)
{
Interlocked.Increment(ref ProbeCalls);
return Task.FromResult(Probe);
}
public async Task<TokenTrackerRunResult> 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<TokenTrackerRunResult> InstallAsync(IProgress<string>? output = null, CancellationToken ct = default)
{
Interlocked.Increment(ref InstallCalls);
output?.Report("added 1 package");
return Task.FromResult(InstallResult);
}
}
- Step 2: Write the failing test
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
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
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
namespace ClaudeDo.Worker.Usage.TokenTracker;
/// <summary>
/// Owns the cached export. One fetch covers <see cref="WindowDays"/> days because
/// <c>buildSessionAnalytics</c> walks the whole history regardless of <c>--from</c>/<c>--to</c>
/// (measured 11.5 s), so narrowing the request would buy nothing while making every range switch
/// pay the cost again.
/// </summary>
public sealed class TokenTrackerService
{
public const int WindowDays = 90;
private readonly ITokenTrackerClient _client;
private readonly Func<DateTime> _utcNow;
private readonly SemaphoreSlim _refreshLock = new(1, 1);
private TokenTrackerProbe? _probe;
public TokenTrackerService(ITokenTrackerClient client, TokenTrackerState state, Func<DateTime>? utcNow = null)
{
_client = client;
State = state;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
public TokenTrackerState State { get; }
public async Task<TokenTrackerProbe> ProbeAsync(bool force = false, CancellationToken ct = default)
{
if (!force && _probe is { } cached) return cached;
var probe = await _client.ProbeAsync(ct);
_probe = probe;
return probe;
}
/// <summary>Fetches only when the cached export is missing or older than
/// <paramref name="maxAge"/>. Safe to call on every modal open.</summary>
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<TokenTrackerRunResult> InstallAsync(
IProgress<string>? 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
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TokenTrackerServiceTests
Expected: PASS, 10 Tests.
- Step 6: Commit
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.
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:
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):
TokenTrackerService? tokenTracker = null)
_tokenTracker = tokenTracker;
Oben using ClaudeDo.Worker.Usage.TokenTracker; ergänzen.
- Step 3: Add the three hub methods
Direkt vor GetModelUsage (Zeile 1180) einfügen:
public Task<TokenTrackerStatusDto> 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<TokenTrackerStatusDto> 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<TokenTrackerStatusDto> InstallTokenTracker() => HubGuard(async () =>
{
if (_tokenTracker is null)
throw new InvalidOperationException("TokenTracker is not configured.");
var progress = new Progress<string>(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<T> oder Func<Task<T>> 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:
// 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<ITokenTrackerClient, TokenTrackerClient>();
builder.Services.AddSingleton<TokenTrackerState>();
builder.Services.AddSingleton<TokenTrackerService>();
- Step 5: Build
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded, 0 Errors.
- Step 6: Commit
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:
/// <summary>
/// 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.
/// </summary>
public Task<IReadOnlyList<ModelUsageRowDto>> GetModelUsage(DateOnly from, DateOnly to) => HubGuard(async () =>
{
if (_tokenTracker is null)
return (IReadOnlyList<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
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<ModelUsageRowDto>)Array.Empty<ModelUsageRowDto>();
var claudeDoHashes = await ClaudeDoSessionHashesAsync();
return (IReadOnlyList<ModelUsageRowDto>)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();
});
/// <summary>Hashes of every session one of our runs owns — the scope split rides on set
/// membership rather than the export's own <c>project_key</c>, which falls back to a bare
/// worktree GUID for exactly the sessions we care about.</summary>
private async Task<HashSet<string>> 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:
var export = _tokenTracker?.State.Export;
und die Projektion ersetzen:
.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
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded, 0 Errors.
- Step 4: Run the whole worker suite
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
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:
namespace ClaudeDo.Worker.Usage.Interfaces;
public interface ITranscriptUsageReader
{
/// <summary>Cumulative raw token totals for one session's transcript file
/// (located by <c>{sessionId}.jsonl</c> under the projects root), or null when
/// no matching transcript file can be found or read.</summary>
Task<SessionUsageTotals?> 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
_centralRootund_sandboxRootsamt ihrer Zuweisungen im Konstruktor
Der Konstruktor wird damit zu:
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:
// 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:
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:
records.Add(new UsageMessageRecord(input, output, cacheRead, cacheCreation, dedupeKey));
Die timestamp- und model-Prüfungen bleiben stehen — sie filtern Nicht-Assistant-Zeilen und
<synthetic>-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
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
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:
Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync();
Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync();
Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync();
- Step 2: Implement them
In src/ClaudeDo.Ui/Services/WorkerClient.cs nach GetTaskUsageAsync (Zeile 690) einfügen:
public Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync()
=> TryInvokeAsync<TokenTrackerStatusDto>("GetTokenTrackerStatus");
public Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync()
=> TryInvokeAsync<TokenTrackerStatusDto>("RefreshTokenTracker");
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync()
=> TryInvokeAsync<TokenTrackerStatusDto>("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:
public sealed record TokenTrackerStatusDto(
bool Installed,
string? Version,
bool NodeOk,
string? NodeVersion,
DateTime? LastFetchedUtc,
string? LastError,
int? FormatVersion,
int SessionCount);
- Step 4: Build
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
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:
"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:
"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:
"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}"
"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
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
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:
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:
[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:
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:
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ModelsEmpty))]
[NotifyPropertyChangedFor(nameof(TotalsText))]
private IReadOnlyList<ModelUsageDisplayRow> _modelRows = Array.Empty<ModelUsageDisplayRow>();
Und IsInstalling muss den Button-Zustand mitziehen:
[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:
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
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:
[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
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Expected: Build succeeded, 0 Errors.
- Step 6: Commit
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 <TabControl …> (Zeile 150) einfügen:
<!-- TokenTracker hint: shown instead of the analytics header while the CLI is missing -->
<Border DockPanel.Dock="Top" Margin="20,12,20,0"
IsVisible="{Binding TokenTrackerMissing}"
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource StatusReviewBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,10">
<StackPanel Spacing="6">
<TextBlock Classes="title" Text="{loc:Tr modals.usageMonitor.ttMissingTitle}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.usageMonitor.ttMissingBody}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
IsVisible="{Binding !ShowInstallButton}"
Text="{Binding NodeHintText}"/>
<TextBlock Classes="meta" TextWrapping="Wrap"
IsVisible="{Binding HasTokenTrackerError}"
Text="{Binding TokenTrackerErrorText}"/>
<Button Classes="btn" HorizontalAlignment="Left"
IsVisible="{Binding ShowInstallButton}"
Content="{loc:Tr modals.usageMonitor.ttInstall}"
Command="{Binding InstallTokenTrackerCommand}"/>
<Ellipse Classes="spinner" Width="14" Height="14" HorizontalAlignment="Left"
IsVisible="{Binding IsInstalling}"/>
</StackPanel>
</Border>
<!-- Analytics header: totals, freshness stamp, manual refresh -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="12"
Margin="20,12,20,0" VerticalAlignment="Center"
IsVisible="{Binding !TokenTrackerMissing}">
<TextBlock Classes="title" VerticalAlignment="Center" Text="{Binding TotalsText}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding AnalyticsStampText}"/>
<Button Classes="btn" Content="{loc:Tr modals.usageMonitor.analyticsRefresh}"
Command="{Binding RefreshAnalyticsCommand}"/>
</StackPanel>
- 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:
<TextBlock Grid.Column="8" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCost}"/>
und in der Zeilenvorlage nach der Share-Zelle:
<TextBlock Grid.Column="8" Classes="meta" Text="{Binding CostText}"/>
- 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:
<TextBlock Grid.Column="7" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnCost}"/>
<TextBlock Grid.Column="8" Classes="eyebrow" Text="{loc:Tr modals.usageMonitor.columnRetries}"/>
und in der Zeilenvorlage nach der Total-Zelle:
<TextBlock Grid.Column="7" Classes="meta" Text="{Binding CostText}"/>
<TextBlock Grid.Column="8" Classes="meta" Text="{Binding RetriesText}"/>
- Step 4: Build
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
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
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):
public Task<TokenTrackerStatusDto?> GetTokenTrackerStatusAsync() =>
Task.FromResult<TokenTrackerStatusDto?>(new TokenTrackerStatusDto(
Installed: false, Version: null, NodeOk: true, NodeVersion: "v22.0.0",
LastFetchedUtc: null, LastError: null, FormatVersion: null, SessionCount: 0));
public Task<TokenTrackerStatusDto?> RefreshTokenTrackerAsync() => GetTokenTrackerStatusAsync();
public Task<TokenTrackerStatusDto?> InstallTokenTrackerAsync() => GetTokenTrackerStatusAsync();
- Step 3: Build everything
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
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~<TestName>). Grün im
Alleingang heißt Flakiness, nicht Regression; melde es als solches statt es zu „fixen".
- Step 5: Commit
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 zuTranscriptUsageReaderauf „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), demsession_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 gegenSupportedFormatVersion, und der ausdrücklichen Feststellung, dass Limits/Gate/Throttle nicht von TokenTracker kommen. -
Im Abschnitt Per-run token accounting klarstellen, dass
ReadSessionTotalsAsyncbleibt. -
Im Abschnitt Hub surface
GetModelUsage/GetTaskUsageneu 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
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:
- 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.
- Echter Durchlauf ohne TokenTracker: Probe → Hinweis-Karte → Install-Button → Re-Probe →
erste Auswertung. Zum Nachstellen
npm rm -g tokentracker-cliauf einer Maschine, auf der es niemandem wehtut — nicht auf der Entwicklungsmaschine, dort ist das Tool in Benutzung. - Plausibilitätsvergleich: einmal einen 7-Tage-Range in der App gegen
tokentracker sessionsvon Hand vergleichen und prüfen, dass der ClaudeDo/Other-Split und die Kostensumme passen.