Files
ClaudeDo/docs/superpowers/plans/2026-08-24-tokentracker-usage-analytics.md
T

94 KiB
Raw Blame History

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.

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:

  • 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

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 161179). 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 11801190) 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 11921232) 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 2369)
  • ResolveScope, IsUnderRoot, NormalizePath (Zeilen 159182)
  • die Klasse Accumulator (Zeilen 184188)
  • die Felder _centralRoot und _sandboxRoot samt 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 2024) und UsageAggregateRow (Zeilen 2634) 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 735738) 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 492517) 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 4547) 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 175196) 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 300326) 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 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
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.