diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md
index 7f4c091d..40eb1b2c 100644
--- a/src/ClaudeDo.Worker/CLAUDE.md
+++ b/src/ClaudeDo.Worker/CLAUDE.md
@@ -21,6 +21,7 @@ Worker/
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
+ Usage/ — OAuth usage monitor: UsageModels (UsageBucket/UsageLimitRow/UsageSnapshot), ClaudeOAuthUsageClient (reads the access token Claude Code keeps fresh at `~/.claude/.credentials.json`, calls `GET https://api.anthropic.com/api/oauth/usage`; defensive parsing — missing/null buckets → null, missing `limits` → empty list; never logs the token), UsageState (threadsafe singleton; a failed poll never overwrites the last good snapshot, only sets `LastError`), UsageMonitorService (BackgroundService, polls on `usage_poll_interval_seconds`, one poll at startup, logs a failure at most once per distinct error message); interface in Usage/Interfaces/ (IUsageClient)
```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
@@ -180,6 +181,7 @@ Loaded from `~/.todo-app/worker.config.json`:
- `poll_interval_seconds` (int, default 60)
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow)
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
+- `usage_poll_interval_seconds` (default 60, clamped to a minimum of 15 on load) — poll interval for `UsageMonitorService`
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
diff --git a/src/ClaudeDo.Worker/Config/WorkerConfig.cs b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
index afcdff9e..114fe08f 100644
--- a/src/ClaudeDo.Worker/Config/WorkerConfig.cs
+++ b/src/ClaudeDo.Worker/Config/WorkerConfig.cs
@@ -44,6 +44,10 @@ public sealed class WorkerConfig
[JsonPropertyName("online_inbox")]
public OnlineInboxConfig OnlineInbox { get; set; } = new();
+ /// Poll interval for the OAuth usage monitor. Clamped to a minimum of 15s on load.
+ [JsonPropertyName("usage_poll_interval_seconds")]
+ public int UsagePollIntervalSeconds { get; set; } = 60;
+
public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
@@ -71,6 +75,7 @@ public sealed class WorkerConfig
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
+ cfg.UsagePollIntervalSeconds = Math.Max(15, cfg.UsagePollIntervalSeconds);
return cfg;
}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 2cb4f463..83ac6b6f 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -19,6 +19,8 @@ using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
+using ClaudeDo.Worker.Usage;
+using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using Serilog;
@@ -193,6 +195,15 @@ if (cfg.OnlineInbox.Enabled)
builder.Services.AddHostedService();
}
+// OAuth usage monitor: reads the access token Claude Code keeps fresh in
+// ~/.claude/.credentials.json and polls Anthropic's usage endpoint.
+builder.Services.AddSingleton();
+builder.Services.AddHttpClient(client =>
+{
+ client.Timeout = TimeSpan.FromSeconds(5);
+});
+builder.Services.AddHostedService();
+
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
diff --git a/src/ClaudeDo.Worker/Usage/ClaudeOAuthUsageClient.cs b/src/ClaudeDo.Worker/Usage/ClaudeOAuthUsageClient.cs
new file mode 100644
index 00000000..1367fb62
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/ClaudeOAuthUsageClient.cs
@@ -0,0 +1,164 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using ClaudeDo.Data;
+using ClaudeDo.Worker.Usage.Interfaces;
+
+namespace ClaudeDo.Worker.Usage;
+
+///
+/// Fetches the current OAuth usage/limit snapshot from Anthropic using the access token
+/// Claude Code keeps fresh in ~/.claude/.credentials.json. Never refreshes the token
+/// itself, and never logs or surfaces the token value.
+///
+public sealed class ClaudeOAuthUsageClient : IUsageClient
+{
+ private const string UsageUrl = "https://api.anthropic.com/api/oauth/usage";
+
+ private readonly HttpClient _http;
+ private readonly string _credentialsPath;
+
+ public ClaudeOAuthUsageClient(HttpClient http)
+ : this(http, Path.Combine(Paths.Expand("~/.claude"), ".credentials.json")) { }
+
+ internal ClaudeOAuthUsageClient(HttpClient http, string credentialsPath)
+ {
+ _http = http;
+ _credentialsPath = credentialsPath;
+ }
+
+ public async Task GetUsageAsync(CancellationToken ct = default)
+ {
+ var token = ReadAccessToken();
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, UsageUrl);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
+ request.Headers.Add("anthropic-beta", "oauth-2025-04-20");
+
+ using var response = await _http.SendAsync(request, ct);
+ if (!response.IsSuccessStatusCode)
+ throw new InvalidOperationException($"Usage request failed with status {(int)response.StatusCode}.");
+
+ var body = await response.Content.ReadAsStringAsync(ct);
+ return Parse(body);
+ }
+
+ private string ReadAccessToken()
+ {
+ if (!File.Exists(_credentialsPath))
+ throw new InvalidOperationException($"Claude credentials file not found at '{_credentialsPath}'.");
+
+ string json;
+ try
+ {
+ json = File.ReadAllText(_credentialsPath);
+ }
+ catch (IOException ex)
+ {
+ throw new InvalidOperationException($"Failed to read Claude credentials file: {ex.Message}", ex);
+ }
+
+ JsonNode? root;
+ try
+ {
+ root = JsonNode.Parse(json);
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"Failed to parse Claude credentials file: {ex.Message}", ex);
+ }
+
+ var token = root?["claudeAiOauth"]?["accessToken"]?.GetValue();
+ if (string.IsNullOrWhiteSpace(token))
+ throw new InvalidOperationException("Claude credentials file has no accessToken.");
+
+ return token;
+ }
+
+ ///
+ /// Defensive parse of the usage response: missing/null buckets become null, missing
+ /// limits becomes an empty list, and unknown fields are ignored. Only an
+ /// unparsable body throws.
+ ///
+ internal static UsageSnapshot Parse(string json)
+ {
+ JsonNode? root;
+ try
+ {
+ root = JsonNode.Parse(json);
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex);
+ }
+
+ if (root is not JsonObject obj)
+ throw new InvalidOperationException("Usage response was not a JSON object.");
+
+ try
+ {
+ var fiveHour = ParseBucket(obj["five_hour"]);
+ var sevenDay = ParseBucket(obj["seven_day"]);
+ var limits = ParseLimits(obj["limits"] as JsonArray);
+
+ return new UsageSnapshot(fiveHour, sevenDay, limits, DateTime.UtcNow);
+ }
+ catch (InvalidOperationException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to parse usage response: {ex.Message}", ex);
+ }
+ }
+
+ private static UsageBucket? ParseBucket(JsonNode? node)
+ {
+ if (node is not JsonObject obj)
+ return null;
+
+ var utilization = TryGetDouble(obj["utilization"]) ?? 0;
+ var resetsAt = TryGetDateTimeOffset(obj["resets_at"]);
+ return new UsageBucket(utilization, resetsAt);
+ }
+
+ private static List ParseLimits(JsonArray? array)
+ {
+ var result = new List();
+ if (array is null)
+ return result;
+
+ foreach (var item in array)
+ {
+ if (item is not JsonObject row)
+ continue;
+
+ var kind = TryGetString(row["kind"]) ?? "";
+ var group = TryGetString(row["group"]) ?? "";
+ var percent = TryGetDouble(row["percent"]) ?? 0;
+ var severity = TryGetString(row["severity"]) ?? "";
+ var resetsAt = TryGetDateTimeOffset(row["resets_at"]);
+ var scopeModelDisplayName = TryGetString((row["scope"] as JsonObject)?["model"]?["display_name"]);
+ var isActive = TryGetBool(row["is_active"]) ?? false;
+
+ result.Add(new UsageLimitRow(kind, group, percent, severity, resetsAt, scopeModelDisplayName, isActive));
+ }
+
+ return result;
+ }
+
+ private static string? TryGetString(JsonNode? node) =>
+ node is JsonValue value && value.TryGetValue(out string? s) ? s : null;
+
+ private static double? TryGetDouble(JsonNode? node) =>
+ node is JsonValue value && value.TryGetValue(out double d) ? d : null;
+
+ private static bool? TryGetBool(JsonNode? node) =>
+ node is JsonValue value && value.TryGetValue(out bool b) ? b : null;
+
+ private static DateTimeOffset? TryGetDateTimeOffset(JsonNode? node) =>
+ node is JsonValue value && value.TryGetValue(out string? s) && DateTimeOffset.TryParse(s, out var dto)
+ ? dto
+ : null;
+}
diff --git a/src/ClaudeDo.Worker/Usage/Interfaces/IUsageClient.cs b/src/ClaudeDo.Worker/Usage/Interfaces/IUsageClient.cs
new file mode 100644
index 00000000..03e7ef04
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/Interfaces/IUsageClient.cs
@@ -0,0 +1,6 @@
+namespace ClaudeDo.Worker.Usage.Interfaces;
+
+public interface IUsageClient
+{
+ Task GetUsageAsync(CancellationToken ct = default);
+}
diff --git a/src/ClaudeDo.Worker/Usage/UsageModels.cs b/src/ClaudeDo.Worker/Usage/UsageModels.cs
new file mode 100644
index 00000000..dfd662b7
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/UsageModels.cs
@@ -0,0 +1,18 @@
+namespace ClaudeDo.Worker.Usage;
+
+public sealed record UsageBucket(double Utilization, DateTimeOffset? ResetsAt);
+
+public sealed record UsageLimitRow(
+ string Kind,
+ string Group,
+ double Percent,
+ string Severity,
+ DateTimeOffset? ResetsAt,
+ string? ScopeModelDisplayName,
+ bool IsActive);
+
+public sealed record UsageSnapshot(
+ UsageBucket? FiveHour,
+ UsageBucket? SevenDay,
+ IReadOnlyList Limits,
+ DateTime FetchedAtUtc);
diff --git a/src/ClaudeDo.Worker/Usage/UsageMonitorService.cs b/src/ClaudeDo.Worker/Usage/UsageMonitorService.cs
new file mode 100644
index 00000000..43fe314b
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/UsageMonitorService.cs
@@ -0,0 +1,68 @@
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Usage.Interfaces;
+
+namespace ClaudeDo.Worker.Usage;
+
+///
+/// Polls on and keeps
+/// current. Polls once immediately at startup. A failure is logged as a
+/// warning at most once per distinct error message, to avoid log spam on a persistent outage.
+///
+public sealed class UsageMonitorService : BackgroundService
+{
+ private readonly IUsageClient _client;
+ private readonly UsageState _state;
+ private readonly WorkerConfig _config;
+ private readonly ILogger _logger;
+ private string? _lastLoggedError;
+
+ public UsageMonitorService(
+ IUsageClient client, UsageState state, WorkerConfig config, ILogger logger)
+ {
+ _client = client;
+ _state = state;
+ _config = config;
+ _logger = logger;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ while (!stoppingToken.IsCancellationRequested)
+ {
+ await TickAsync(stoppingToken);
+
+ try
+ {
+ await Task.Delay(TimeSpan.FromSeconds(_config.UsagePollIntervalSeconds), stoppingToken);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
+ }
+
+ internal async Task TickAsync(CancellationToken ct)
+ {
+ try
+ {
+ var snapshot = await _client.GetUsageAsync(ct);
+ _state.ReportSuccess(snapshot);
+ _lastLoggedError = null;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _state.ReportFailure(ex.Message, DateTime.UtcNow);
+
+ if (_lastLoggedError != ex.Message)
+ {
+ _logger.LogWarning(ex, "UsageMonitorService: failed to fetch usage: {Message}", ex.Message);
+ _lastLoggedError = ex.Message;
+ }
+ }
+ }
+}
diff --git a/src/ClaudeDo.Worker/Usage/UsageState.cs b/src/ClaudeDo.Worker/Usage/UsageState.cs
new file mode 100644
index 00000000..7b22e47c
--- /dev/null
+++ b/src/ClaudeDo.Worker/Usage/UsageState.cs
@@ -0,0 +1,47 @@
+namespace ClaudeDo.Worker.Usage;
+
+///
+/// Threadsafe holder for the last successful usage snapshot. A failed poll never overwrites
+/// a good snapshot — it only records , leaving the existing snapshot stale.
+///
+public sealed class UsageState
+{
+ private readonly object _lock = new();
+ private UsageSnapshot? _snapshot;
+ private DateTime? _lastAttemptUtc;
+ private string? _lastError;
+
+ public UsageSnapshot? Snapshot
+ {
+ get { lock (_lock) return _snapshot; }
+ }
+
+ public DateTime? LastAttemptUtc
+ {
+ get { lock (_lock) return _lastAttemptUtc; }
+ }
+
+ public string? LastError
+ {
+ get { lock (_lock) return _lastError; }
+ }
+
+ public void ReportSuccess(UsageSnapshot snapshot)
+ {
+ lock (_lock)
+ {
+ _snapshot = snapshot;
+ _lastAttemptUtc = snapshot.FetchedAtUtc;
+ _lastError = null;
+ }
+ }
+
+ public void ReportFailure(string error, DateTime attemptedAtUtc)
+ {
+ lock (_lock)
+ {
+ _lastAttemptUtc = attemptedAtUtc;
+ _lastError = error;
+ }
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/ClaudeOAuthUsageClientTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/ClaudeOAuthUsageClientTests.cs
new file mode 100644
index 00000000..e2e0a721
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Usage/ClaudeOAuthUsageClientTests.cs
@@ -0,0 +1,182 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using ClaudeDo.Worker.Usage;
+
+namespace ClaudeDo.Worker.Tests.Usage;
+
+///
+/// Tests for using a stubbed
+/// and a temp credentials file. No real network call and no real credentials file are touched.
+///
+public sealed class ClaudeOAuthUsageClientTests : IDisposable
+{
+ // Real fixture from the Anthropic OAuth usage endpoint, trimmed to the relevant fields.
+ private const string RealFixture = """
+ {
+ "five_hour": { "utilization": 30.0, "resets_at": "2026-08-05T12:59:59.62334+02:00", "limit_dollars": null },
+ "seven_day": { "utilization": 57.0, "resets_at": "2026-08-06T10:59:59.62336+02:00" },
+ "seven_day_opus": null, "seven_day_sonnet": null, "seven_day_oauth_apps": null,
+ "extra_usage": { "is_enabled": false },
+ "limits": [
+ { "kind": "session", "group": "session", "percent": 30, "severity": "normal", "resets_at": "2026-08-05T12:59:59.62334+02:00", "scope": null, "is_active": false },
+ { "kind": "weekly_all", "group": "weekly", "percent": 57, "severity": "normal", "resets_at": "2026-08-06T10:59:59.62336+02:00", "scope": null, "is_active": true },
+ { "kind": "weekly_scoped", "group": "weekly", "percent": 14, "severity": "normal", "resets_at": "2026-08-06T10:59:59.623573+02:00", "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, "is_active": false }
+ ],
+ "spend": { "percent": 0, "enabled": false },
+ "member_dashboard_available": false
+ }
+ """;
+
+ private readonly string _credentialsPath = Path.Combine(Path.GetTempPath(), $"claudedo-usage-test-{Guid.NewGuid():N}.json");
+
+ public void Dispose()
+ {
+ if (File.Exists(_credentialsPath))
+ File.Delete(_credentialsPath);
+ }
+
+ private void WriteCredentials(string accessToken = "test-access-token") =>
+ File.WriteAllText(_credentialsPath, $$"""{ "claudeAiOauth": { "accessToken": "{{accessToken}}" } }""");
+
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ public List Requests { get; } = new();
+ public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK;
+ public string ResponseBody { get; set; } = "{}";
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct)
+ {
+ Requests.Add(request);
+ var resp = new HttpResponseMessage(ResponseStatus)
+ {
+ Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
+ };
+ return Task.FromResult(resp);
+ }
+ }
+
+ private (ClaudeOAuthUsageClient Client, StubHandler Handler) Build()
+ {
+ var handler = new StubHandler();
+ var http = new HttpClient(handler);
+ return (new ClaudeOAuthUsageClient(http, _credentialsPath), handler);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_ParsesRealFixture()
+ {
+ WriteCredentials();
+ var (client, handler) = Build();
+ handler.ResponseBody = RealFixture;
+
+ var snapshot = await client.GetUsageAsync();
+
+ Assert.NotNull(snapshot.FiveHour);
+ Assert.Equal(30, snapshot.FiveHour!.Utilization);
+ Assert.NotNull(snapshot.SevenDay);
+ Assert.Equal(57, snapshot.SevenDay!.Utilization);
+ Assert.Equal(3, snapshot.Limits.Count);
+
+ var scoped = snapshot.Limits[2];
+ Assert.Equal("weekly_scoped", scoped.Kind);
+ Assert.Equal("Fable", scoped.ScopeModelDisplayName);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_SendsBearerTokenAndBetaHeader()
+ {
+ WriteCredentials("my-secret-token");
+ var (client, handler) = Build();
+ handler.ResponseBody = RealFixture;
+
+ await client.GetUsageAsync();
+
+ var request = handler.Requests[0];
+ Assert.Equal("Bearer", request.Headers.Authorization!.Scheme);
+ Assert.Equal("my-secret-token", request.Headers.Authorization!.Parameter);
+ Assert.Contains("oauth-2025-04-20", request.Headers.GetValues("anthropic-beta"));
+ Assert.Equal("https://api.anthropic.com/api/oauth/usage", request.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_MissingFiveHour_ReturnsNull()
+ {
+ WriteCredentials();
+ var (client, handler) = Build();
+ handler.ResponseBody = """{ "seven_day": { "utilization": 12.0 } }""";
+
+ var snapshot = await client.GetUsageAsync();
+
+ Assert.Null(snapshot.FiveHour);
+ Assert.NotNull(snapshot.SevenDay);
+ Assert.Empty(snapshot.Limits);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_NullFiveHour_ReturnsNull()
+ {
+ WriteCredentials();
+ var (client, handler) = Build();
+ handler.ResponseBody = """{ "five_hour": null, "seven_day": null }""";
+
+ var snapshot = await client.GetUsageAsync();
+
+ Assert.Null(snapshot.FiveHour);
+ Assert.Null(snapshot.SevenDay);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_MissingLimits_ReturnsEmptyList()
+ {
+ WriteCredentials();
+ var (client, handler) = Build();
+ handler.ResponseBody = "{}";
+
+ var snapshot = await client.GetUsageAsync();
+
+ Assert.Empty(snapshot.Limits);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_GarbageBody_ThrowsWithoutLeakingToken()
+ {
+ WriteCredentials("super-secret-token");
+ var (client, handler) = Build();
+ handler.ResponseBody = "not json at all {{{";
+
+ var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync());
+
+ Assert.DoesNotContain("super-secret-token", ex.Message);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_Http401_ThrowsWithoutLeakingToken()
+ {
+ WriteCredentials("super-secret-token");
+ var (client, handler) = Build();
+ handler.ResponseStatus = HttpStatusCode.Unauthorized;
+ handler.ResponseBody = "unauthorized";
+
+ var ex = await Assert.ThrowsAsync(() => client.GetUsageAsync());
+
+ Assert.DoesNotContain("super-secret-token", ex.Message);
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_MissingCredentialsFile_Throws()
+ {
+ var (client, _) = Build(); // credentials file was never written
+
+ await Assert.ThrowsAsync(() => client.GetUsageAsync());
+ }
+
+ [Fact]
+ public async Task GetUsageAsync_NoAccessTokenInCredentials_Throws()
+ {
+ File.WriteAllText(_credentialsPath, """{ "claudeAiOauth": {} }""");
+ var (client, _) = Build();
+
+ await Assert.ThrowsAsync(() => client.GetUsageAsync());
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageMonitorServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageMonitorServiceTests.cs
new file mode 100644
index 00000000..ebd06965
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageMonitorServiceTests.cs
@@ -0,0 +1,68 @@
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Usage;
+using ClaudeDo.Worker.Usage.Interfaces;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ClaudeDo.Worker.Tests.Usage;
+
+public sealed class UsageMonitorServiceTests
+{
+ private sealed class FakeClient : IUsageClient
+ {
+ public Queue> Results { get; } = new();
+ public int CallCount { get; private set; }
+
+ public Task GetUsageAsync(CancellationToken ct = default)
+ {
+ CallCount++;
+ var next = Results.Count > 0 ? Results.Dequeue() : () => throw new InvalidOperationException("no result queued");
+ return Task.FromResult(next());
+ }
+ }
+
+ private static UsageSnapshot MakeSnapshot() => new(new UsageBucket(1, null), null, [], DateTime.UtcNow);
+
+ [Fact]
+ public async Task TickAsync_Success_UpdatesState()
+ {
+ var client = new FakeClient();
+ client.Results.Enqueue(MakeSnapshot);
+ var state = new UsageState();
+ var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger.Instance);
+
+ await service.TickAsync(CancellationToken.None);
+
+ Assert.NotNull(state.Snapshot);
+ Assert.Null(state.LastError);
+ }
+
+ [Fact]
+ public async Task TickAsync_Failure_SetsErrorButDoesNotThrow()
+ {
+ var client = new FakeClient();
+ client.Results.Enqueue(() => throw new InvalidOperationException("network unreachable"));
+ var state = new UsageState();
+ var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger.Instance);
+
+ await service.TickAsync(CancellationToken.None);
+
+ Assert.Equal("network unreachable", state.LastError);
+ Assert.Null(state.Snapshot);
+ }
+
+ [Fact]
+ public async Task TickAsync_FailureAfterSuccess_KeepsOldSnapshot()
+ {
+ var client = new FakeClient();
+ client.Results.Enqueue(MakeSnapshot);
+ client.Results.Enqueue(() => throw new InvalidOperationException("down"));
+ var state = new UsageState();
+ var service = new UsageMonitorService(client, state, new WorkerConfig(), NullLogger.Instance);
+
+ await service.TickAsync(CancellationToken.None);
+ await service.TickAsync(CancellationToken.None);
+
+ Assert.NotNull(state.Snapshot);
+ Assert.Equal("down", state.LastError);
+ }
+}
diff --git a/tests/ClaudeDo.Worker.Tests/Usage/UsageStateTests.cs b/tests/ClaudeDo.Worker.Tests/Usage/UsageStateTests.cs
new file mode 100644
index 00000000..09017b64
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/Usage/UsageStateTests.cs
@@ -0,0 +1,48 @@
+using ClaudeDo.Worker.Usage;
+
+namespace ClaudeDo.Worker.Tests.Usage;
+
+public sealed class UsageStateTests
+{
+ private static UsageSnapshot MakeSnapshot(double fiveHourUtilization) => new(
+ new UsageBucket(fiveHourUtilization, null),
+ null,
+ [],
+ DateTime.UtcNow);
+
+ [Fact]
+ public void ReportSuccess_SetsSnapshotAndClearsError()
+ {
+ var state = new UsageState();
+ state.ReportFailure("boom", DateTime.UtcNow);
+
+ state.ReportSuccess(MakeSnapshot(42));
+
+ Assert.NotNull(state.Snapshot);
+ Assert.Equal(42, state.Snapshot!.FiveHour!.Utilization);
+ Assert.Null(state.LastError);
+ }
+
+ [Fact]
+ public void ReportFailure_AfterSuccess_KeepsOldSnapshotButSetsError()
+ {
+ var state = new UsageState();
+ state.ReportSuccess(MakeSnapshot(10));
+
+ state.ReportFailure("network down", DateTime.UtcNow);
+
+ Assert.NotNull(state.Snapshot);
+ Assert.Equal(10, state.Snapshot!.FiveHour!.Utilization);
+ Assert.Equal("network down", state.LastError);
+ }
+
+ [Fact]
+ public void NoAttemptYet_SnapshotAndErrorAreNull()
+ {
+ var state = new UsageState();
+
+ Assert.Null(state.Snapshot);
+ Assert.Null(state.LastError);
+ Assert.Null(state.LastAttemptUtc);
+ }
+}