From 2f163393220c012f319aee7451635d80f960a02c Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:55:21 +0200 Subject: [PATCH 01/21] Support scoped quota data and consistent usage status --- CHANGELOG.md | 11 + CodexUsageDock.Tests/QuotaProtocolTests.cs | 315 ++++++++++++++++ CodexUsageDock.Tests/ReliabilityTests.cs | 7 +- CodexUsageDock.Tests/TestEnvironment.cs | 10 +- CodexUsageDock.Tests/UsageContextTests.cs | 132 +++++++ CodexUsageDock.Tests/UsageDataTests.cs | 5 +- CodexUsageDock.Tests/UsageDiagnosticsTests.cs | 104 ++++++ CodexUsageDock.Tests/UsageFreshnessTests.cs | 182 ++++++++++ CodexUsageDock/AdaptiveWeeklyForecast.cs | 2 + CodexUsageDock/CodexAppServerReader.cs | 165 +++++++-- .../CodexUsageDockCommandsProvider.cs | 8 + CodexUsageDock/CodexUsageService.cs | 127 +++++-- CodexUsageDock/LocalStorage.cs | 7 + .../Pages/CodexUsageDiagnosticsPage.cs | 341 ++++++++++++++++++ CodexUsageDock/Pages/CodexUsageDockPage.cs | 307 +++++++++++++--- .../Pages/CodexUsageDockSettingsPage.cs | 2 +- CodexUsageDock/UsageData.cs | 15 +- CodexUsageDock/UsageDockItem.cs | 115 +++++- CodexUsageDock/UsageFreshness.cs | 65 ++++ CodexUsageDock/UsagePresentation.cs | 9 + CodexUsageDock/UsageTrendHistory.cs | 2 +- CodexUsageDock/WeeklyUsageHistoryStore.cs | 2 + PRIVACY.md | 2 + README.md | 13 +- SPRINTS.md | 27 ++ 25 files changed, 1849 insertions(+), 126 deletions(-) create mode 100644 CodexUsageDock.Tests/QuotaProtocolTests.cs create mode 100644 CodexUsageDock.Tests/UsageContextTests.cs create mode 100644 CodexUsageDock.Tests/UsageDiagnosticsTests.cs create mode 100644 CodexUsageDock.Tests/UsageFreshnessTests.cs create mode 100644 CodexUsageDock/Pages/CodexUsageDiagnosticsPage.cs create mode 100644 CodexUsageDock/UsageFreshness.cs create mode 100644 CodexUsageDock/UsagePresentation.cs create mode 100644 SPRINTS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a6d0d71..87cf07d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,20 @@ Each entry links to the commit or pull request that introduced the change. ## [Unreleased] +### Added + +- Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. +- Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. + +### Fixed + +- Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. +- Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. + ### Changed - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) +- Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ## [0.6.1] - 2026-09-09 diff --git a/CodexUsageDock.Tests/QuotaProtocolTests.cs b/CodexUsageDock.Tests/QuotaProtocolTests.cs new file mode 100644 index 0000000..b6e90a7 --- /dev/null +++ b/CodexUsageDock.Tests/QuotaProtocolTests.cs @@ -0,0 +1,315 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class QuotaProtocolTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void RichMapOverridesOnlyTheMatchingDefaultBucket() + { + var snapshot = Parse(""" + { + "rateLimits": { + "limitId": "codex", + "primary": { "usedPercent": 99, "windowDurationMins": 300, "resetsAt": 1788973200 } + }, + "rateLimitsByLimitId": { + "codex": { + "limitName": "Codex", + "primary": { "usedPercent": 20, "windowDurationMins": 300, "resetsAt": 1788973200 }, + "secondary": { "usedPercent": 30, "windowDurationMins": 10080, "resetsAt": 1789560000 } + }, + "codex-spark": { + "limitName": "Spark", + "primary": { "usedPercent": 90, "windowDurationMins": 300, "resetsAt": 1788973200 }, + "secondary": { "usedPercent": 40, "windowDurationMins": 1440, "resetsAt": 1789041600 } + } + } + } + """); + + Assert.Equal(20, snapshot.Primary!.UsedPercent); + Assert.Equal(30, snapshot.Secondary!.UsedPercent); + Assert.Equal("codex", snapshot.DefaultBucketId); + Assert.Collection( + snapshot.Buckets!, + bucket => Assert.Equal("codex", bucket.Id), + bucket => + { + Assert.Equal("codex-spark", bucket.Id); + Assert.Equal("Spark", bucket.Name); + Assert.Equal(90, bucket.Primary!.UsedPercent); + Assert.Equal(1440, bucket.Secondary!.WindowMinutes); + }); + } + + [Fact] + public void LegacyResponseKeepsHistoricalWindowClassification() + { + var snapshot = Parse(""" + { + "rateLimits": { + "primary": { "usedPercent": 35, "windowDurationMins": 10080, "resetsAt": 1789560000 }, + "secondary": { "usedPercent": 15, "windowDurationMins": 300, "resetsAt": 1788973200 } + } + } + """, """{"result":{"account":{"planType":"pro"}}}"""); + + Assert.Equal(15, snapshot.Primary!.UsedPercent); + Assert.Equal(35, snapshot.Secondary!.UsedPercent); + Assert.Equal("pro", snapshot.PlanType); + Assert.Equal("codex", Assert.Single(snapshot.Buckets!).Id); + Assert.Equal("codex", snapshot.DefaultBucketId); + Assert.Equal(UsageDataSource.AppServer, snapshot.Source); + Assert.Equal(Now, snapshot.UpdatedAt); + Assert.Equal(Now, snapshot.LastAttemptAt); + Assert.Null(snapshot.AccountKey); + Assert.Null(snapshot.OrdinaryUsageAllowed); + } + + [Fact] + public void ExplicitLegacyBucketIdSelectsTheMatchingRichBucket() + { + var snapshot = Parse(""" + { + "rateLimits": { "limitId": "codex-custom", "primary": null, "secondary": null }, + "rateLimitsByLimitId": { + "codex": { "primary": { "usedPercent": 95, "windowDurationMins": 300, "resetsAt": 1788973200 } }, + "codex-custom": { "primary": { "usedPercent": 10, "windowDurationMins": 300, "resetsAt": 1788973200 } } + } + } + """); + + Assert.Equal("codex-custom", snapshot.DefaultBucketId); + Assert.Equal(10, snapshot.Primary!.UsedPercent); + Assert.Equal(2, snapshot.Buckets!.Count); + } + + [Fact] + public void MapOnlyResponseDoesNotTreatAnotherCategoryAsTheDefaultQuota() + { + var snapshot = Parse(""" + { + "rateLimitsByLimitId": { + "codex-spark": { + "primary": { "usedPercent": 10, "windowDurationMins": 300, "resetsAt": 1788973200 }, + "secondary": { "usedPercent": 20, "windowDurationMins": 10080, "resetsAt": 1789560000 } + } + } + } + """); + + Assert.Null(snapshot.Primary); + Assert.Null(snapshot.Secondary); + Assert.Null(snapshot.DefaultBucketId); + Assert.Equal("codex-spark", Assert.Single(snapshot.Buckets!).Id); + } + + [Fact] + public void MapOnlyResponseRecognizesTheExplicitCodexDefault() + { + var snapshot = Parse(""" + { + "rateLimitsByLimitId": { + "codex": { "primary": { "usedPercent": 10, "windowDurationMins": 300, "resetsAt": 1788973200 } } + } + } + """); + + Assert.Equal("codex", snapshot.DefaultBucketId); + Assert.Equal(10, snapshot.Primary!.UsedPercent); + } + + [Fact] + public void UnfamiliarWindowDurationIsPreservedWithoutMislabelingIt() + { + var snapshot = Parse(""" + { + "rateLimits": { + "limitName": "Daily allowance", + "primary": { "usedPercent": 25, "windowDurationMins": 1440, "resetsAt": 1789041600 }, + "secondary": null + } + } + """); + + Assert.Null(snapshot.Primary); + Assert.Null(snapshot.Secondary); + var bucket = Assert.Single(snapshot.Buckets!); + Assert.Equal("Daily allowance", bucket.Name); + Assert.Equal(1440, bucket.Primary!.WindowMinutes); + Assert.Equal(75, bucket.Primary.RemainingPercent); + } + + [Fact] + public void CreditsOnlyResponseDoesNotRequireAnActiveQuotaWindow() + { + var snapshot = Parse(""" + { "credits": { "hasCredits": true, "unlimited": false, "balance": "12.50" } } + """); + + Assert.Null(snapshot.Primary); + Assert.Null(snapshot.Secondary); + Assert.Empty(snapshot.Buckets!); + Assert.Equal(new CreditBalance(true, false, "12.50"), snapshot.Credits); + } + + [Fact] + public void ExplicitlyInactiveWindowsAndUnknownResetsRemainUnknown() + { + var snapshot = Parse(""" + { "rateLimits": { "primary": null, "secondary": null }, "rateLimitResetCredits": null } + """); + + Assert.Null(snapshot.Primary); + Assert.Null(snapshot.Secondary); + Assert.Null(snapshot.ResetCredits); + Assert.Single(snapshot.Buckets!); + } + + [Theory] + [InlineData("{}", null)] + [InlineData("{\"availableCount\":null}", null)] + [InlineData("{\"availableCount\":\"0\"}", null)] + [InlineData("{\"availableCount\":-1}", null)] + [InlineData("{\"availableCount\":0,\"credits\":null}", 0)] + [InlineData("{\"availableCount\":2,\"credits\":[]}", 2)] + public void ResetCountDistinguishesUnavailableFromConfirmedZero(string resetJson, int? expectedCount) + { + var snapshot = Parse("{\"rateLimits\":{},\"rateLimitResetCredits\":" + resetJson + "}"); + + Assert.Equal(expectedCount, snapshot.ResetCredits?.AvailableCount); + } + + [Fact] + public void InvalidOptionalEntriesDoNotDiscardValidBuckets() + { + var snapshot = Parse(""" + { + "rateLimits": { "planType": "pro" }, + "rateLimitsByLimitId": { + "invalid-array": [], + "invalid-null": null, + "invalid\nkey": {}, + "codex": { + "limitName": ["ignored"], + "primary": { "usedPercent": "25", "windowDurationMins": 300, "resetsAt": 1788973200 }, + "secondary": { "usedPercent": 30, "windowDurationMins": 10080, "resetsAt": 1789560000 } + }, + "codex-spark": { "limitName": " Spark\n allowance\t " } + }, + "ordinaryUsageAllowed": "true", + "rateLimitResetCredits": [] + } + """, """{"result":{"account":[]}}"""); + + Assert.Null(snapshot.Primary); + Assert.Equal(30, snapshot.Secondary!.UsedPercent); + Assert.Null(snapshot.Buckets![0].Name); + Assert.Equal("Spark allowance", snapshot.Buckets[1].Name); + Assert.Equal(2, snapshot.Buckets.Count); + Assert.Null(snapshot.OrdinaryUsageAllowed); + Assert.Null(snapshot.ResetCredits); + Assert.Null(snapshot.PlanType); + } + + [Fact] + public void BucketsAreBoundedDeduplicatedAndKeepTheDefaultEvenWhenItIsLast() + { + var map = Enumerable.Range(0, 40).Select(index => $"\"quota-{index}\":{{}}"); + var snapshot = Parse("{\"rateLimitsByLimitId\":{" + string.Join(',', map) + + ",\"quota-0\":{},\"codex\":{\"limitName\":\"Default\"}}}"); + + Assert.Equal(32, snapshot.Buckets!.Count); + Assert.Equal("codex", snapshot.Buckets[0].Id); + Assert.Equal(32, snapshot.Buckets.Select(bucket => bucket.Id).Distinct(StringComparer.Ordinal).Count()); + Assert.Equal("codex", snapshot.DefaultBucketId); + } + + [Fact] + public void UnsafeOrOversizedIdsAreSkippedAndLabelsAreBounded() + { + var oversizedId = new string('a', 129); + var oversizedLabel = new string('n', 200); + var snapshot = Parse(JsonSerializer.Serialize(new + { + rateLimitsByLimitId = new Dictionary + { + [oversizedId] = new { }, + ["bad id"] = new { }, + ["codex"] = new { limitName = oversizedLabel }, + }, + })); + + var bucket = Assert.Single(snapshot.Buckets!); + Assert.Equal("codex", bucket.Id); + Assert.Equal(80, bucket.Name!.Length); + } + + [Fact] + public void AccountScopeHashesOnlyTheUsageResponseIdentity() + { + var first = Parse("""{"rateLimits":{},"accountId":"synthetic-account-A"}""", + """{"result":{"account":{"email":"synthetic@example.invalid","planType":"pro"}}}"""); + var same = Parse("""{"rateLimits":{},"accountId":"synthetic-account-A"}"""); + var other = Parse("""{"rateLimits":{},"accountId":"synthetic-account-B"}"""); + var unverified = Parse("""{"rateLimits":{}}""", + """{"result":{"account":{"email":"synthetic@example.invalid","accountId":"synthetic-account-A"}}}"""); + + Assert.NotNull(first.AccountKey); + Assert.Equal(64, first.AccountKey.Length); + Assert.Equal(first.AccountKey, same.AccountKey); + Assert.NotEqual(first.AccountKey, other.AccountKey); + Assert.Null(unverified.AccountKey); + var serialized = JsonSerializer.Serialize(first); + Assert.DoesNotContain("synthetic-account", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("synthetic@example.invalid", serialized, StringComparison.Ordinal); + } + + [Theory] + [InlineData("null")] + [InlineData("123")] + [InlineData("\"\"")] + [InlineData("\" \"")] + [InlineData("\"synthetic\\naccount\"")] + public void InvalidIdentityDoesNotCreateAnAccountScope(string accountJson) + { + var snapshot = Parse("{\"rateLimits\":{},\"accountId\":" + accountJson + "}"); + + Assert.Null(snapshot.AccountKey); + } + + [Theory] + [InlineData("true", true)] + [InlineData("false", false)] + [InlineData("null", null)] + [InlineData("\"false\"", null)] + public void BackendPermissionIsPreservedWithoutInferringRecovery(string allowedJson, bool? expected) + { + var snapshot = Parse("{\"rateLimits\":{},\"ordinaryUsageAllowed\":" + allowedJson + "}"); + + Assert.Equal(expected, snapshot.OrdinaryUsageAllowed); + } + + [Theory] + [InlineData("[]")] + [InlineData("null")] + [InlineData("{}")] + [InlineData("{\"rateLimits\":[]}")] + public void UnusableResponseFailsWithoutExposingPayloads(string json) + { + var error = Assert.Throws(() => Parse(json)); + + Assert.StartsWith("Codex app-server did not return", error.Message, StringComparison.Ordinal); + } + + private static CodexUsageSnapshot Parse(string rateJson, string accountJson = "{}") + { + using var rates = JsonDocument.Parse(rateJson); + using var account = JsonDocument.Parse(accountJson); + return CodexAppServerReader.ParseSnapshot(rates.RootElement, account.RootElement, Now); + } +} diff --git a/CodexUsageDock.Tests/ReliabilityTests.cs b/CodexUsageDock.Tests/ReliabilityTests.cs index b1ec88d..d910b62 100644 --- a/CodexUsageDock.Tests/ReliabilityTests.cs +++ b/CodexUsageDock.Tests/ReliabilityTests.cs @@ -174,10 +174,10 @@ public void InjectedHistoryStoresRemainIsolated() using var other = new TestEnvironment(); using var first = _environment.CreateService(); using var second = other.CreateService(); - first.RecordHistory(Snapshot(80), Now); + first.RecordHistory(Snapshot(80) with { AccountKey = "test-account" }, Now); Assert.Single(first.WeeklyHistory); Assert.Empty(second.WeeklyHistory); - Assert.Single(new WeeklyUsageHistoryStore(_environment.PathFor("weekly.json")).Load(Now)); + Assert.Single(new WeeklyUsageHistoryStore(_environment.PathFor("weekly.json")).ForContext("test-account|codex").Load(Now)); Assert.False(File.Exists(other.PathFor("weekly.json"))); Assert.False(File.Exists(other.PathFor("adaptive.json"))); } @@ -254,7 +254,8 @@ public async Task SlowTokenReadDoesNotBlockLimitsOrAllowStaleTokensToPublish() await service.RefreshAsync().WaitAsync(TestTimeout); await started.Task.WaitAsync(TestTimeout); Assert.False(service.IsLoading); - Assert.Same(snapshot, service.Current); + Assert.Equal(snapshot, service.Current with { LastAttemptAt = null }); + Assert.NotNull(service.Current.LastAttemptAt); var originalTokens = service.TokenRefreshTask; snapshot = snapshot with { Secondary = snapshot.Secondary! with { ResetsAt = Now.AddDays(6) } }; await service.RefreshAsync().WaitAsync(TestTimeout); diff --git a/CodexUsageDock.Tests/TestEnvironment.cs b/CodexUsageDock.Tests/TestEnvironment.cs index ad6fc6c..5ab4830 100644 --- a/CodexUsageDock.Tests/TestEnvironment.cs +++ b/CodexUsageDock.Tests/TestEnvironment.cs @@ -20,19 +20,21 @@ internal CodexUsageService CreateService( Func localSessionReader, WeeklyUsageHistoryStore? weeklyHistoryStore = null, AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, - Func>? localTokenUsageReader = null) => + Func>? localTokenUsageReader = null, + Func? clock = null) => new(appServerReader, localSessionReader, weeklyHistoryStore ?? new WeeklyUsageHistoryStore(PathFor("weekly.json")), adaptiveWeeklyUsageStore ?? new AdaptiveWeeklyUsageStore(PathFor("adaptive.json")), - localTokenUsageReader); + localTokenUsageReader, clock); internal CodexUsageService CreateService( Func> appServerReader, Func localSessionReader, WeeklyUsageHistoryStore? weeklyHistoryStore = null, AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, - Func>? localTokenUsageReader = null) => - CreateService(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader); + Func>? localTokenUsageReader = null, + Func? clock = null) => + CreateService(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader, clock); public void Dispose() { diff --git a/CodexUsageDock.Tests/UsageContextTests.cs b/CodexUsageDock.Tests/UsageContextTests.cs new file mode 100644 index 0000000..fd2c147 --- /dev/null +++ b/CodexUsageDock.Tests/UsageContextTests.cs @@ -0,0 +1,132 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageContextTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + public void Dispose() => _environment.Dispose(); + + private static CodexUsageSnapshot Snapshot(string? account, double used = 20) => new( + new(used, 300, Now.AddHours(4)), new(used, 10080, Now.AddDays(6)), + null, null, null, Now, UsageDataSource.AppServer, null, + AccountKey: account, DefaultBucketId: "codex"); + + [Fact] + public void AccountAndCategoryHistoryAreSeparatedAndRestoredOnlyAfterIdentification() + { + using (var service = _environment.CreateService()) + { + service.RecordHistory(Snapshot("account-a"), Now); + service.RecordHistory(Snapshot("account-b", 70), Now); + Assert.Equal(30, Assert.Single(service.WeeklyHistory).RemainingPercent); + service.RecordHistory(Snapshot("account-a", 50) with { DefaultBucketId = "review" }, Now); + Assert.Equal(50, Assert.Single(service.WeeklyHistory).RemainingPercent); + service.RecordHistory(Snapshot("account-a"), Now); + Assert.Equal(80, Assert.Single(service.WeeklyHistory).RemainingPercent); + } + + using var restarted = _environment.CreateService(); + Assert.Empty(restarted.WeeklyHistory); + restarted.RecordHistory(Snapshot("account-b", 70), Now); + Assert.Equal(30, Assert.Single(restarted.WeeklyHistory).RemainingPercent); + } + + [Fact] + public void UnverifiedAndLegacyHistoryDoNotSeedVerifiedAccounts() + { + new WeeklyUsageHistoryStore(_environment.PathFor("weekly.json")).Save([new(Now.AddMinutes(-1), 1)]); + using var service = _environment.CreateService(); + service.RecordHistory(Snapshot(null), Now); + Assert.Single(service.WeeklyHistory); + Assert.Null(service.AdaptiveWeeklyHistory.ActiveCycle); + service.RecordHistory(Snapshot("known", 50), Now); + Assert.Equal(50, Assert.Single(service.WeeklyHistory).RemainingPercent); + Assert.Equal(1, Assert.Single(new WeeklyUsageHistoryStore(_environment.PathFor("weekly.json")).Load(Now)).RemainingPercent); + } + + [Fact] + public void UnverifiedCategoriesHaveSeparateInMemoryHistory() + { + using var service = _environment.CreateService(); + service.RecordHistory(Snapshot(null), Now); + service.RecordHistory(Snapshot(null, 90) with { DefaultBucketId = "review", UpdatedAt = Now.AddMinutes(1) }, Now.AddMinutes(1)); + Assert.Equal(10, Assert.Single(service.WeeklyHistory).RemainingPercent); + Assert.False(File.Exists(_environment.PathFor("weekly.json"))); + } + + [Fact] + public void SwitchingAccountsAfterReenablingDoesNotReplayPausedObservations() + { + using var service = _environment.CreateService(); + service.RecordHistory(Snapshot("a", 0), Now); + service.RecordHistory(Snapshot("a", 10) with { UpdatedAt = Now.AddMinutes(1) }, Now.AddMinutes(1)); + service.SetAdaptiveWeeklyForecastEnabled(false); + service.RecordHistory(Snapshot("a", 20) with { UpdatedAt = Now.AddMinutes(2) }, Now.AddMinutes(2)); + service.RecordHistory(Snapshot("b", 50), Now.AddMinutes(2)); + service.SetAdaptiveWeeklyForecastEnabled(true); + service.RecordHistory(Snapshot("b", 60) with { UpdatedAt = Now.AddMinutes(3) }, Now.AddMinutes(3)); + service.RecordHistory(Snapshot("a", 30) with { UpdatedAt = Now.AddMinutes(4) }, Now.AddMinutes(4)); + Assert.Equal(10, service.AdaptiveWeeklyHistory.ActiveCycle!.ConsumedPercent); + } + + [Fact] + public async Task FailureKeepsOriginalConfirmedTimestampAndDoesNotLearnAnotherSample() + { + var failed = false; + using var service = _environment.CreateService( + _ => failed ? Task.FromException(new IOException("private information")) : Task.FromResult(Snapshot("known")), + () => throw new IOException("private fallback"), clock: () => Now); + await service.RefreshAsync(); + failed = true; + await service.RefreshAsync(); + Assert.Equal(UsageDataSource.LastConfirmed, service.Current.Source); + Assert.Equal(Now, service.Current.UpdatedAt); + Assert.NotNull(service.Current.LastAttemptAt); + Assert.Equal(80, service.Current.Secondary!.RemainingPercent); + Assert.Single(service.WeeklyHistory); + Assert.DoesNotContain("private", service.Current.Error!, StringComparison.Ordinal); + } + + [Fact] + public async Task NewerUnverifiedLogCannotReplaceAConfirmedAccount() + { + var failed = false; + using var service = _environment.CreateService( + _ => failed ? Task.FromException(new IOException()) : Task.FromResult(Snapshot("known")), + () => Snapshot(null, 99) with { Source = UsageDataSource.LocalSession, UpdatedAt = Now.AddMinutes(1) }, clock: () => Now); + await service.RefreshAsync(); + failed = true; + await service.RefreshAsync(); + Assert.Equal(UsageDataSource.LastConfirmed, service.Current.Source); + Assert.Equal("known", service.Current.AccountKey); + Assert.Equal(80, service.Current.Secondary!.RemainingPercent); + } + + [Fact] + public async Task PresentationCapturesAnAccountAndItsHistoryTogether() + { + var next = Snapshot("a", 10); + using var service = _environment.CreateService(_ => Task.FromResult(next), () => next, clock: () => Now); + await service.RefreshAsync(); + var first = service.GetPresentation(); + next = Snapshot("b", 90); + await service.RefreshAsync(); + var second = service.GetPresentation(); + Assert.Equal("a", first.Usage.AccountKey); + Assert.Equal(90, Assert.Single(first.WeeklyHistory).RemainingPercent); + Assert.Equal("b", second.Usage.AccountKey); + Assert.Equal(10, Assert.Single(second.WeeklyHistory).RemainingPercent); + } + + [Fact] + public void ContextPathsDoNotContainExternalIdentifiersOrEscapeStorage() + { + var path = _environment.PathFor("weekly.json"); + var scoped = LocalStorage.ContextPath(path, "../../private@example.com"); + Assert.StartsWith(Path.GetDirectoryName(path) + Path.DirectorySeparatorChar, scoped, StringComparison.Ordinal); + Assert.DoesNotContain("private", scoped, StringComparison.Ordinal); + Assert.Equal("weekly.json", Path.GetFileName(scoped)); + } +} diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 983fecb..66598a2 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -1838,6 +1838,7 @@ public void WeeklyHistoryPersistsAcrossServiceRestartsAndStaysSeparateFromFiveHo Secondary = new RateLimitWindow(40, 10080, now.AddDays(6)), UpdatedAt = now, Source = UsageDataSource.AppServer, + AccountKey = "test-account", }; try { @@ -1852,6 +1853,8 @@ public void WeeklyHistoryPersistsAcrossServiceRestartsAndStaysSeparateFromFiveHo using var restarted = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot, new WeeklyUsageHistoryStore(path)); Assert.Empty(restarted.PrimaryHistory); + Assert.Empty(restarted.WeeklyHistory); + restarted.RecordHistory(snapshot, now); Assert.Single(restarted.WeeklyHistory); } finally @@ -2150,7 +2153,7 @@ public async Task ReenablingAdaptiveForecastDoesNotLearnMeasurementsCollectedWhi null, updatedAt, UsageDataSource.AppServer, - null); + null, AccountKey: "test-account"); var latest = CreateSnapshot(70, now.AddMinutes(-1)); try diff --git a/CodexUsageDock.Tests/UsageDiagnosticsTests.cs b/CodexUsageDock.Tests/UsageDiagnosticsTests.cs new file mode 100644 index 0000000..4b0c9c8 --- /dev/null +++ b/CodexUsageDock.Tests/UsageDiagnosticsTests.cs @@ -0,0 +1,104 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageDiagnosticsTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void ResetCreditsDistinguishMissingFieldFromZeroAndDetailCount() + { + var missing = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(resetCredits: null), + TimeSpan.FromMinutes(1), + Now, + version: "0.6.1"); + var zero = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(resetCredits: new RateLimitResetCredits(0, [])), + TimeSpan.FromMinutes(1), + Now, + version: "0.6.1"); + var withDetails = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(resetCredits: new RateLimitResetCredits(3, [new RateLimitResetCredit("reset", "available", null)])), + TimeSpan.FromMinutes(1), + Now, + version: "0.6.1"); + + Assert.Contains("Reset credits:** unknown (field missing)", missing, StringComparison.Ordinal); + Assert.Contains("Reset credit details:** unknown (field missing)", missing, StringComparison.Ordinal); + Assert.Contains("Reset credits:** 0 available", zero, StringComparison.Ordinal); + Assert.Contains("Reset credit details:** 0 provided", zero, StringComparison.Ordinal); + Assert.Contains("Reset credits:** 3 available", withDetails, StringComparison.Ordinal); + Assert.Contains("Reset credit details:** 1 provided", withDetails, StringComparison.Ordinal); + } + + [Fact] + public void FutureMeasurementIsReportedAsClockSkewAndNotFresh() + { + var text = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(updatedAt: Now.AddMinutes(2)), + TimeSpan.FromMinutes(1), + Now, + version: "0.6.1"); + + Assert.Contains("Freshness:** future timestamp (clock skew suspected)", text, StringComparison.Ordinal); + Assert.Contains("Measurement time (UTC):** 2026-09-09 12:02:00 UTC", text, StringComparison.Ordinal); + } + + [Fact] + public void DiagnosticsDoNotExposeAccountKeysOrRawErrors() + { + const string accountKey = "account-secret-value"; + const string rawError = "C:\\Users\\Alice\\.codex\\auth.json bearer-token"; + var text = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(accountKey: accountKey, error: rawError), + TimeSpan.FromMinutes(1), + Now, + historyStorageError: rawError, + version: "0.6.1"); + + Assert.DoesNotContain(accountKey, text, StringComparison.Ordinal); + Assert.DoesNotContain(rawError, text, StringComparison.Ordinal); + Assert.Contains("hashed identifier present; value withheld", text, StringComparison.Ordinal); + Assert.Contains("error recorded (details withheld)", text, StringComparison.Ordinal); + } + + [Fact] + public void MissingBucketsAreDifferentFromAnAvailableEmptyBucketList() + { + var missing = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(buckets: null), + TimeSpan.FromMinutes(1), + Now, + version: "0.6.1"); + var empty = CodexUsageDiagnosticsPage.FormatDiagnostics( + Snapshot(buckets: []), + TimeSpan.FromMinutes(1), + Now, + version: "0.6.1"); + + Assert.Contains("Quota buckets:** unknown (field missing)", missing, StringComparison.Ordinal); + Assert.Contains("Quota buckets:** available (0)", empty, StringComparison.Ordinal); + } + + private static CodexUsageSnapshot Snapshot( + DateTimeOffset? updatedAt = null, + RateLimitResetCredits? resetCredits = null, + IReadOnlyList? buckets = null, + string? accountKey = null, + string? error = null) => new( + null, + null, + null, + null, + resetCredits, + updatedAt ?? Now, + UsageDataSource.AppServer, + error, + buckets, + accountKey, + null, + Now, + "codex"); +} diff --git a/CodexUsageDock.Tests/UsageFreshnessTests.cs b/CodexUsageDock.Tests/UsageFreshnessTests.cs new file mode 100644 index 0000000..688d789 --- /dev/null +++ b/CodexUsageDock.Tests/UsageFreshnessTests.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageFreshnessTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + + [Theory] + [InlineData(1, 5)] + [InlineData(5, 5)] + [InlineData(15, 15)] + public void SharedPolicyUsesAtLeastFiveMinutes(int refreshMinutes, int expectedMinutes) + { + var refreshInterval = TimeSpan.FromMinutes(refreshMinutes); + + Assert.Equal(TimeSpan.FromMinutes(expectedMinutes), UsageFreshness.MaximumAge(refreshInterval)); + Assert.Equal(UsageFreshness.MaximumAge(refreshInterval), UsageTrendHistory.Freshness(refreshInterval)); + } + + [Fact] + public void FreshnessClassifiesUnknownFutureAndLastConfirmedSafely() + { + Assert.Equal( + UsageFreshnessState.Unknown, + UsageFreshness.Classify(null, Now, TimeSpan.FromMinutes(1))); + Assert.Equal( + UsageFreshnessState.Future, + UsageFreshness.Classify(Now.AddMinutes(1), Now, TimeSpan.FromMinutes(1))); + Assert.Equal( + UsageFreshnessState.LastConfirmed, + UsageFreshness.Classify(Now, Now, TimeSpan.FromMinutes(1), isLastConfirmed: true)); + } + + [Fact] + public void DataStatusUsesTheSharedFiveMinuteMinimum() + { + var snapshot = Snapshot(updatedAt: Now.AddMinutes(-4)); + var status = CodexUsageDockPage.FormatDataStatus(snapshot, Now, TimeSpan.FromMinutes(1)); + + Assert.Contains("just updated", status, StringComparison.Ordinal); + Assert.DoesNotContain("possibly outdated", status, StringComparison.Ordinal); + } + + [Fact] + public void StaleSummaryDoesNotClaimPlentyOfAllowance() + { + var snapshot = Snapshot(updatedAt: Now.AddMinutes(-6)); + var summary = CodexUsageDockPage.FormatSummary(snapshot, Now, TimeSpan.FromMinutes(1)); + + Assert.Contains("Usage data is stale", summary, StringComparison.Ordinal); + Assert.Contains("Last known allowance", summary, StringComparison.Ordinal); + Assert.DoesNotContain("Plenty of allowance", summary, StringComparison.Ordinal); + } + + [Fact] + public void LastConfirmedSuppressesForecastButPreservesObservedChart() + { + var reset = Now.AddDays(6); + var snapshot = Snapshot(updatedAt: Now.AddHours(-1)) with + { + Primary = null, + Secondary = new RateLimitWindow(20, 10080, reset), + Source = UsageDataSource.LastConfirmed, + }; + var history = new[] + { + new UsageHistoryEntry(Now.AddMinutes(-10), 90), + new UsageHistoryEntry(Now, 80), + }; + + using var document = JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson( + snapshot, + Now, + isLoading: false, + primaryHistory: [], + weeklyHistory: history, + refreshInterval: TimeSpan.FromMinutes(1))); + var root = document.RootElement; + + Assert.Contains("Last confirmed usage", root.GetProperty("statusTitle").GetString(), StringComparison.Ordinal); + Assert.Contains("Projection unavailable", root.GetProperty("weeklyProjection").GetString(), StringComparison.Ordinal); + Assert.Equal("Forecast unavailable until usage data is refreshed.", root.GetProperty("weeklyForecastStatus").GetString()); + Assert.True(root.GetProperty("weeklyTrendAvailable").GetBoolean()); + Assert.StartsWith("data:image/svg+xml;utf8,", root.GetProperty("weeklyTrendChartUrl").GetString(), StringComparison.Ordinal); + } + + [Fact] + public void ExpiredWindowIsReportedAsUnknownInsteadOfZero() + { + var snapshot = Snapshot(updatedAt: Now) with + { + Primary = new RateLimitWindow(100, 300, Now.AddMinutes(-1)), + Secondary = null, + }; + + var summary = CodexUsageDockPage.FormatSummary(snapshot, Now, TimeSpan.FromMinutes(1)); + using var document = JsonDocument.Parse(CodexUsageDockPage.FormatMainDataJson( + snapshot, + Now, + isLoading: false, + primaryHistory: [], + weeklyHistory: [], + refreshInterval: TimeSpan.FromMinutes(1))); + + Assert.Contains("allowance unknown", summary, StringComparison.Ordinal); + Assert.DoesNotContain("0%", summary, StringComparison.Ordinal); + Assert.False(document.RootElement.GetProperty("fiveHourAvailable").GetBoolean()); + } + + [Fact] + public void AdditionalBucketsUseSeparateTruthfulDurationRows() + { + var snapshot = Snapshot(updatedAt: Now) with + { + Buckets = + [ + new RateLimitBucket( + "default", + "Default", + new RateLimitWindow(10, 60, Now.AddHours(1)), + new RateLimitWindow(20, 10080, Now.AddDays(6))), + new RateLimitBucket( + "coding", + "Coding ", + new RateLimitWindow(25, 15, Now.AddMinutes(10)), + new RateLimitWindow(50, 90, Now.AddHours(1))), + new RateLimitBucket( + "review", + "Review", + new RateLimitWindow(25, 15, Now.AddMinutes(10)), + new RateLimitWindow(50, 90, Now.AddHours(1))), + ], + DefaultBucketId = "default", + }; + + var details = CodexUsageDockPage.FormatAdditionalBucketDetails(snapshot, Now); + + Assert.Contains("Other quota windows", details, StringComparison.Ordinal); + Assert.Contains("Coding \\", details, StringComparison.Ordinal); + Assert.Contains("15-minute", details, StringComparison.Ordinal); + Assert.Contains("90-minute", details, StringComparison.Ordinal); + Assert.Contains("Default", details, StringComparison.Ordinal); + Assert.Contains("1-hour", details, StringComparison.Ordinal); + Assert.Contains("75% available", details, StringComparison.Ordinal); + Assert.Contains("50% available", details, StringComparison.Ordinal); + Assert.Equal(2, details.Split("75% available", StringSplitOptions.None).Length - 1); + } + + [Fact] + public void DockFreshnessUsesTheConfiguredInterval() + { + var snapshot = Snapshot(updatedAt: Now.AddMinutes(-6)); + + var status = UsageDockItem.FormatSourceFreshness(snapshot, Now, TimeSpan.FromMinutes(1)); + + Assert.Contains("Stale", status, StringComparison.Ordinal); + Assert.Contains("6 minutes", status, StringComparison.Ordinal); + } + + [Fact] + public void FreshBlockedAccountHasAnExplicitOverallStatus() + { + var snapshot = Snapshot(updatedAt: Now) with { OrdinaryUsageAllowed = false }; + + var summary = CodexUsageDockPage.FormatSummary(snapshot, Now, TimeSpan.FromMinutes(1)); + + Assert.Contains("Ordinary usage blocked", summary, StringComparison.Ordinal); + Assert.DoesNotContain("Plenty of allowance", summary, StringComparison.Ordinal); + } + + private static CodexUsageSnapshot Snapshot(DateTimeOffset updatedAt) => new( + new RateLimitWindow(20, 300, Now.AddHours(4)), + new RateLimitWindow(30, 10080, Now.AddDays(6)), + "pro", + null, + null, + updatedAt, + UsageDataSource.AppServer, + null); +} diff --git a/CodexUsageDock/AdaptiveWeeklyForecast.cs b/CodexUsageDock/AdaptiveWeeklyForecast.cs index 6b81f10..1c11318 100644 --- a/CodexUsageDock/AdaptiveWeeklyForecast.cs +++ b/CodexUsageDock/AdaptiveWeeklyForecast.cs @@ -44,6 +44,8 @@ internal AdaptiveWeeklyUsageStore(string path) internal static AdaptiveWeeklyUsageStore CreateDefault() => new(LocalStorage.GetPath(FileName)); + internal AdaptiveWeeklyUsageStore ForContext(string context) => new(LocalStorage.ContextPath(_path, context)); + internal string? StorageError { get; private set; } internal AdaptiveWeeklyUsageHistory Snapshot => new( diff --git a/CodexUsageDock/CodexAppServerReader.cs b/CodexUsageDock/CodexAppServerReader.cs index fb6e6c6..7918867 100644 --- a/CodexUsageDock/CodexAppServerReader.cs +++ b/CodexUsageDock/CodexAppServerReader.cs @@ -1,5 +1,6 @@ using System.Buffers; using System.Diagnostics; +using System.Security.Cryptography; using System.Text; using System.Text.Json; @@ -7,6 +8,8 @@ namespace CodexUsageDock; internal static class CodexAppServerReader { + private const int MaximumBucketCount = 32; + internal static async Task ReadAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -60,24 +63,7 @@ await SendAsync( ThrowIfError(rateResponse.RootElement); var rateResult = rateResponse.RootElement.GetProperty("result"); - var limits = rateResult.GetProperty("rateLimits"); - var windows = RateLimitWindowParser.Classify( - RateLimitWindowParser.TryParse(limits, "primary", "usedPercent", "windowDurationMins", "resetsAt"), - RateLimitWindowParser.TryParse(limits, "secondary", "usedPercent", "windowDurationMins", "resetsAt")); - RateLimitWindowParser.ThrowIfNoKnownWindow(windows); - var credits = ParseCredits(rateResult, limits); - var resetCredits = ParseResetCredits(rateResult); - var plan = ParsePlan(accountResponse.RootElement); - - return new CodexUsageSnapshot( - windows.FiveHour, - windows.Weekly, - plan, - credits, - resetCredits, - DateTimeOffset.Now, - UsageDataSource.AppServer, - null); + return ParseSnapshot(rateResult, accountResponse.RootElement, DateTimeOffset.Now); } finally { @@ -96,6 +82,133 @@ await SendAsync( } } + internal static CodexUsageSnapshot ParseSnapshot(JsonElement rateResult, JsonElement accountResponse, DateTimeOffset now) + { + if (rateResult.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException("Codex app-server did not return a usage result."); + } + + TryGetObject(rateResult, "rateLimits", out var legacyLimits); + var defaultId = ReadLimitId(legacyLimits, "limitId") ?? "codex"; + var defaultLimits = legacyLimits; + if (TryGetObject(rateResult, "rateLimitsByLimitId", out var limitsById) + && TryGetObject(limitsById, defaultId, out var richDefault)) + { + defaultLimits = richDefault; + } + + var buckets = new List(); + var bucketIds = new HashSet(StringComparer.Ordinal); + RateLimitBucket? defaultBucket = null; + if (defaultLimits.ValueKind == JsonValueKind.Object) + { + defaultBucket = ParseBucket(defaultId, defaultLimits); + buckets.Add(defaultBucket); + bucketIds.Add(defaultId); + } + + if (limitsById.ValueKind == JsonValueKind.Object) + { + foreach (var entry in limitsById.EnumerateObject()) + { + if (buckets.Count >= MaximumBucketCount) + { + break; + } + + if (entry.Value.ValueKind == JsonValueKind.Object + && IsValidLimitId(entry.Name) + && bucketIds.Add(entry.Name)) + { + buckets.Add(ParseBucket(entry.Name, entry.Value)); + } + } + } + + var windows = RateLimitWindowParser.Classify(defaultBucket?.Primary, defaultBucket?.Secondary); + var credits = ParseCredits(rateResult, defaultLimits); + var resetCredits = ParseResetCredits(rateResult); + var ordinaryUsageAllowed = rateResult.TryGetProperty("ordinaryUsageAllowed", out var allowed) + && allowed.ValueKind is JsonValueKind.True or JsonValueKind.False + ? allowed.GetBoolean() + : (bool?)null; + if (buckets.Count == 0 && credits is null && resetCredits is null && ordinaryUsageAllowed is null) + { + throw new InvalidOperationException("Codex app-server did not return usable usage information."); + } + + return new CodexUsageSnapshot( + windows.FiveHour, + windows.Weekly, + ParsePlan(accountResponse) ?? ReadLabel(defaultLimits, "planType", 32), + credits, + resetCredits, + now, + UsageDataSource.AppServer, + null, + buckets.ToArray(), + ParseAccountKey(rateResult), + ordinaryUsageAllowed, + now, + defaultBucket?.Id); + } + + private static RateLimitBucket ParseBucket(string id, JsonElement limits) => new( + id, + ReadLabel(limits, "limitName", 80), + RateLimitWindowParser.TryParse(limits, "primary", "usedPercent", "windowDurationMins", "resetsAt"), + RateLimitWindowParser.TryParse(limits, "secondary", "usedPercent", "windowDurationMins", "resetsAt")); + + private static string? ParseAccountKey(JsonElement result) + { + if (!result.TryGetProperty("accountId", out var value) || value.ValueKind != JsonValueKind.String) + { + return null; + } + + var accountId = value.GetString(); + if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 512 + || accountId.Any(character => char.IsControl(character) || char.IsWhiteSpace(character))) + { + return null; + } + + var hash = SHA256.HashData(Encoding.UTF8.GetBytes("CodexUsageDock:account:v1:" + accountId)); + return Convert.ToHexString(hash); + } + + private static string? ReadLimitId(JsonElement limits, string propertyName) + { + if (limits.ValueKind != JsonValueKind.Object + || !limits.TryGetProperty(propertyName, out var value) + || value.ValueKind != JsonValueKind.String) + { + return null; + } + + var id = value.GetString(); + return id is not null && IsValidLimitId(id) ? id : null; + } + + private static bool IsValidLimitId(string id) => id.Length is > 0 and <= 128 + && id.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_' or '.' or ':' or '/'); + + private static string? ReadLabel(JsonElement element, string propertyName, int maxLength) => + element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.String + ? UsageText.SanitizeExternal(value.GetString(), maxLength) + : null; + + private static bool TryGetObject(JsonElement element, string propertyName, out JsonElement value) + { + value = default; + return element.ValueKind == JsonValueKind.Object + && element.TryGetProperty(propertyName, out value) + && value.ValueKind == JsonValueKind.Object; + } + internal static string GetSafeWorkingDirectory(string executable) { if (Path.IsPathRooted(executable) && Path.GetDirectoryName(executable) is { Length: > 0 } directory) @@ -155,11 +268,11 @@ internal static async Task> ReadResponsesAsync(Tex internal static CreditBalance? ParseCredits(JsonElement result, JsonElement limits) { var credits = default(JsonElement); - if (limits.TryGetProperty("credits", out var nested) && nested.ValueKind == JsonValueKind.Object) + if (TryGetObject(limits, "credits", out var nested)) { credits = nested; } - else if (!result.TryGetProperty("credits", out credits) || credits.ValueKind != JsonValueKind.Object) + else if (!TryGetObject(result, "credits", out credits)) { return null; } @@ -187,7 +300,8 @@ internal static async Task> ReadResponsesAsync(Tex internal static RateLimitResetCredits? ParseResetCredits(JsonElement result) { - if (!result.TryGetProperty("rateLimitResetCredits", out var resets) + if (result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("rateLimitResetCredits", out var resets) || resets.ValueKind != JsonValueKind.Object || !resets.TryGetProperty("availableCount", out var count) || count.ValueKind != JsonValueKind.Number @@ -391,15 +505,12 @@ private static void ThrowIfError(JsonElement response) private static string? ParsePlan(JsonElement accountResponse) { - if (!accountResponse.TryGetProperty("result", out var result) - || !result.TryGetProperty("account", out var account) - || account.ValueKind == JsonValueKind.Null - || !account.TryGetProperty("planType", out var plan) - || plan.ValueKind != JsonValueKind.String) + if (!TryGetObject(accountResponse, "result", out var result) + || !TryGetObject(result, "account", out var account)) { return null; } - return UsageText.SanitizeExternal(plan.GetString(), 32); + return ReadLabel(account, "planType", 32); } } diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index fd93b29..7fb2d06 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -12,6 +12,7 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly UsageDockItem _resetsAndCredits; private readonly ICommandItem[] _commands; private readonly CodexUsageDockPage _details; + private readonly CodexUsageDiagnosticsPage _diagnostics; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -30,6 +31,7 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); var details = _details = new CodexUsageDockPage(_usage, _settings); + _diagnostics = new CodexUsageDiagnosticsPage(_usage); _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -47,6 +49,11 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Subtitle = "Choose what appears in the Dock", Icon = new IconInfo("\uE713"), }, + new CommandItem(_diagnostics) + { + Title = "Codex Usage diagnostics", + Subtitle = "Source, freshness, supported fields, and safe troubleshooting details", + }, ]; _settings.Changed += OnSettingsChanged; @@ -135,6 +142,7 @@ public override void Dispose() _weekly.Dispose(); _resetsAndCredits.Dispose(); _details.Dispose(); + _diagnostics.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index ddaa4be..2432fa0 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -12,9 +12,15 @@ internal sealed partial class CodexUsageService : IDisposable private readonly object _refreshStateLock = new(); private readonly List _primaryHistory = []; private readonly List _weeklyHistory = []; - private readonly WeeklyUsageHistoryStore _weeklyHistoryStore; - private readonly AdaptiveWeeklyUsageStore _adaptiveWeeklyUsageStore; + private readonly WeeklyUsageHistoryStore _baseWeeklyHistoryStore; + private readonly AdaptiveWeeklyUsageStore _baseAdaptiveWeeklyUsageStore; + private WeeklyUsageHistoryStore _weeklyHistoryStore; + private AdaptiveWeeklyUsageStore _adaptiveWeeklyUsageStore; + private string? _historyContext; + private string? _memoryHistoryContext; + private CodexUsageSnapshot? _lastConfirmed; private readonly CancellationTokenSource _lifetimeCancellation = new(); + private readonly Func _clock; private readonly Func> _appServerReader; private readonly Func _localSessionReader; private readonly Func>? _localTokenUsageReader; @@ -43,14 +49,16 @@ internal CodexUsageService( Func localSessionReader, WeeklyUsageHistoryStore weeklyHistoryStore, AdaptiveWeeklyUsageStore adaptiveWeeklyUsageStore, - Func>? localTokenUsageReader = null) + Func>? localTokenUsageReader = null, + Func? clock = null) { _appServerReader = appServerReader; _localSessionReader = localSessionReader; _localTokenUsageReader = localTokenUsageReader; - _weeklyHistoryStore = weeklyHistoryStore; - _adaptiveWeeklyUsageStore = adaptiveWeeklyUsageStore; - _weeklyHistory.AddRange(_weeklyHistoryStore.Load(DateTimeOffset.Now)); + _clock = clock ?? (() => DateTimeOffset.Now); + _weeklyHistoryStore = _baseWeeklyHistoryStore = weeklyHistoryStore; + _adaptiveWeeklyUsageStore = _baseAdaptiveWeeklyUsageStore = adaptiveWeeklyUsageStore; + // Legacy files have no account identity. They must never seed a verified account. } internal CodexUsageService( @@ -58,8 +66,9 @@ internal CodexUsageService( Func localSessionReader, WeeklyUsageHistoryStore weeklyHistoryStore, AdaptiveWeeklyUsageStore adaptiveWeeklyUsageStore, - Func>? localTokenUsageReader = null) - : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader) + Func>? localTokenUsageReader = null, + Func? clock = null) + : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader, clock) { } @@ -67,6 +76,19 @@ internal CodexUsageService( public LocalTokenUsageSnapshot CurrentTokenUsage { get; private set; } = LocalTokenUsageSnapshot.Unavailable; + internal UsagePresentation GetPresentation() + { + lock (_refreshStateLock) + { + lock (_historyLock) + { + return new(Current, _primaryHistory.ToArray(), _weeklyHistory.ToArray(), + _historyContext is null ? new([], null) : _adaptiveWeeklyUsageStore.Snapshot, + CurrentTokenUsage, _isLoading); + } + } + } + internal Task TokenRefreshTask { get @@ -119,7 +141,7 @@ public AdaptiveWeeklyUsageHistory AdaptiveWeeklyHistory { lock (_historyLock) { - return _adaptiveWeeklyUsageStore.Snapshot; + return _historyContext is null ? new([], null) : _adaptiveWeeklyUsageStore.Snapshot; } } } @@ -178,7 +200,7 @@ internal bool ClearAdaptiveWeeklyHistory() { lock (_historyLock) { - return _adaptiveWeeklyUsageStore.Clear(); + return _historyContext is null || _adaptiveWeeklyUsageStore.Clear(); } } @@ -224,15 +246,43 @@ internal void RecordHistory(CodexUsageSnapshot snapshot, DateTimeOffset? recorde { lock (_historyLock) { - var now = recordedAt ?? DateTimeOffset.Now; + var now = recordedAt ?? _clock(); + if (snapshot.Source is UsageDataSource.LastConfirmed or UsageDataSource.Unavailable or UsageDataSource.Initializing) + { + _primaryHistory.RemoveAll(entry => entry.RecordedAt < now.AddHours(-5)); + _weeklyHistory.RemoveAll(entry => entry.RecordedAt < now.AddDays(-7)); + return; + } + + var context = snapshot.AccountKey is { Length: > 0 } account + ? $"{account}|{snapshot.DefaultBucketId ?? "codex"}" + : null; + var memoryContext = context ?? $"unverified|{snapshot.DefaultBucketId ?? "codex"}"; + if (!string.Equals(memoryContext, _memoryHistoryContext, StringComparison.Ordinal)) + { + _memoryHistoryContext = memoryContext; + _historyContext = context; + _primaryHistory.Clear(); + _weeklyHistory.Clear(); + _weeklyHistoryStore = context is null ? _baseWeeklyHistoryStore : _baseWeeklyHistoryStore.ForContext(context); + _adaptiveWeeklyUsageStore = context is null ? _baseAdaptiveWeeklyUsageStore : _baseAdaptiveWeeklyUsageStore.ForContext(context); + if (context is not null) + { + _weeklyHistory.AddRange(_weeklyHistoryStore.Load(now)); + } + // A returning account may have been observed while learning was paused. + // Resume at this measurement rather than replaying another context's gap. + _adaptiveWeeklyForecastNeedsBaseline = _adaptiveWeeklyForecastEnabled; + } + RecordWindowHistory(_primaryHistory, snapshot.Primary, snapshot.UpdatedAt, now, now - TimeSpan.FromHours(5)); var weeklyHistoryChanged = RecordWindowHistory(_weeklyHistory, snapshot.Secondary, snapshot.UpdatedAt, now, now - TimeSpan.FromDays(7)); - if (weeklyHistoryChanged) + if (weeklyHistoryChanged && context is not null) { _weeklyHistoryStore.Save(_weeklyHistory); } - if (_adaptiveWeeklyForecastEnabled && (weeklyHistoryChanged || _adaptiveWeeklyForecastNeedsBaseline)) + if (context is not null && _adaptiveWeeklyForecastEnabled && (weeklyHistoryChanged || _adaptiveWeeklyForecastNeedsBaseline)) { if (_adaptiveWeeklyForecastNeedsBaseline) { @@ -287,7 +337,6 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella var snapshot = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); if (!cancellationToken.IsCancellationRequested && TryPublish(snapshot)) { - RecordHistory(snapshot); tokenSnapshot = snapshot; } } @@ -297,11 +346,8 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella catch (Exception error) { TraceFailure("unexpected refresh", error); - var unavailable = CreateUnavailableSnapshot(); - if (TryPublish(unavailable)) - { - RecordHistory(unavailable); - } + var unavailable = _lastConfirmed is null ? CreateUnavailableSnapshot() : LastConfirmedSnapshot(_clock()); + _ = TryPublish(unavailable); } finally { @@ -321,9 +367,15 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella private async Task ReadSnapshotAsync(CancellationToken cancellationToken) { + var attemptedAt = _clock(); try { - return await _appServerReader(cancellationToken).ConfigureAwait(false); + var live = await _appServerReader(cancellationToken).ConfigureAwait(false); + if (live.Source == UsageDataSource.AppServer) + { + _lastConfirmed = live; + } + return live with { LastAttemptAt = attemptedAt }; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -335,7 +387,15 @@ private async Task ReadSnapshotAsync(CancellationToken cance try { var fallback = await Task.Run(() => _localSessionReader(cancellationToken), cancellationToken).ConfigureAwait(false); - return fallback with { Error = LiveDataUnavailableMessage }; + // Session logs do not normally identify the signed-in account. A newer + // unverified log must not replace a confirmed, account-scoped measurement. + if (_lastConfirmed is { } confirmed + && (fallback.UpdatedAt <= confirmed.UpdatedAt + || (confirmed.AccountKey is not null && fallback.AccountKey != confirmed.AccountKey))) + { + return LastConfirmedSnapshot(attemptedAt); + } + return fallback with { Error = LiveDataUnavailableMessage, LastAttemptAt = attemptedAt }; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -344,22 +404,32 @@ private async Task ReadSnapshotAsync(CancellationToken cance catch (Exception fallbackError) { TraceFailure("local session fallback", fallbackError); - return CreateUnavailableSnapshot(); + return _lastConfirmed is null + ? CreateUnavailableSnapshot() with { LastAttemptAt = attemptedAt } + : LastConfirmedSnapshot(attemptedAt); } } } + private CodexUsageSnapshot LastConfirmedSnapshot(DateTimeOffset attemptedAt) => _lastConfirmed! with + { + Source = UsageDataSource.LastConfirmed, + LastAttemptAt = attemptedAt, + Error = LiveDataUnavailableMessage, + }; + private async Task ReadTokenUsageAsync( CodexUsageSnapshot snapshot, CancellationToken cancellationToken) { if (snapshot.Secondary is not { WindowMinutes: > 0 } weekly) { - return LocalTokenUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + return LocalTokenUsageSnapshot.Unavailable with { UpdatedAt = _clock() }; } var windowStart = weekly.ResetsAt - TimeSpan.FromMinutes(weekly.WindowMinutes); - var windowEnd = DateTimeOffset.Now < weekly.ResetsAt ? DateTimeOffset.Now : weekly.ResetsAt; + var now = _clock(); + var windowEnd = now < weekly.ResetsAt ? now : weekly.ResetsAt; try { return await _localTokenUsageReader!(windowStart, windowEnd, TimeZoneInfo.Local, cancellationToken).ConfigureAwait(false); @@ -371,7 +441,7 @@ private async Task ReadTokenUsageAsync( catch (Exception error) { TraceFailure("local token usage read", error); - return LocalTokenUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + return LocalTokenUsageSnapshot.Unavailable with { UpdatedAt = _clock() }; } } @@ -380,6 +450,7 @@ private void StartTokenRefresh(CodexUsageSnapshot snapshot) lock (_refreshStateLock) { if (_disposed || _localTokenUsageReader is null || snapshot.Secondary is null + || snapshot.Source is UsageDataSource.LastConfirmed or UsageDataSource.Unavailable or UsageDataSource.Initializing || _tokenReadTask is { IsCompleted: false }) { return; @@ -446,7 +517,9 @@ private bool TryPublish(CodexUsageSnapshot snapshot) return false; } + RecordHistory(snapshot); if (Current.Secondary?.ResetsAt != snapshot.Secondary?.ResetsAt + || Current.AccountKey != snapshot.AccountKey || Current.DefaultBucketId != snapshot.DefaultBucketId || snapshot.Source == UsageDataSource.Unavailable) { CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; @@ -458,9 +531,9 @@ private bool TryPublish(CodexUsageSnapshot snapshot) } } - private static CodexUsageSnapshot CreateUnavailableSnapshot() => CodexUsageSnapshot.Loading with + private CodexUsageSnapshot CreateUnavailableSnapshot() => CodexUsageSnapshot.Loading with { - UpdatedAt = DateTimeOffset.Now, + UpdatedAt = _clock(), Source = UsageDataSource.Unavailable, Error = AllDataUnavailableMessage, }; diff --git a/CodexUsageDock/LocalStorage.cs b/CodexUsageDock/LocalStorage.cs index 9e0a866..e24a4df 100644 --- a/CodexUsageDock/LocalStorage.cs +++ b/CodexUsageDock/LocalStorage.cs @@ -1,9 +1,16 @@ using System.Diagnostics; +using System.Security.Cryptography; +using System.Text; namespace CodexUsageDock; internal static class LocalStorage { + internal static string ContextPath(string path, string context) => Path.Combine( + Path.GetDirectoryName(Path.GetFullPath(path))!, "contexts", + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(context))).ToLowerInvariant(), + Path.GetFileName(path)); + internal static string GetPath(string fileName) => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodexUsageDock", fileName); diff --git a/CodexUsageDock/Pages/CodexUsageDiagnosticsPage.cs b/CodexUsageDock/Pages/CodexUsageDiagnosticsPage.cs new file mode 100644 index 0000000..ca88606 --- /dev/null +++ b/CodexUsageDock/Pages/CodexUsageDiagnosticsPage.cs @@ -0,0 +1,341 @@ +using System.Globalization; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +/// +/// Presents a bounded, privacy-preserving snapshot of the data used by the extension. +/// +internal sealed partial class CodexUsageDiagnosticsPage : ContentPage, IDisposable +{ + private const int MaximumBucketDetails = 16; + private readonly object _presentationLock = new(); + private readonly CodexUsageService _service; + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + public CodexUsageDiagnosticsPage(CodexUsageService service) + { + _service = service; + Name = "Open"; + Title = "Codex Usage diagnostics"; + Icon = new IconInfo("\uE946"); + _service.Updated += OnUpdated; + UpdatePresentation(); + } + + public override IContent[] GetContent() + { + lock (_presentationLock) + { + return [_content]; + } + } + + internal static string FormatDiagnostics( + CodexUsageSnapshot snapshot, + TimeSpan refreshInterval, + DateTimeOffset now, + string? historyStorageError = null, + string? version = null) + { + ArgumentNullException.ThrowIfNull(snapshot); + + var safeVersion = UsageText.SanitizeExternal(version ?? CodexUsageDockMetadata.Version, 32) ?? "unknown"; + var source = FormatSource(snapshot.Source); + var sourceConfigured = FormatSourceConfiguration(snapshot.Source); + var measurementTime = FormatTimestamp(snapshot.Source is UsageDataSource.Unavailable or UsageDataSource.Initializing + ? null + : snapshot.UpdatedAt); + var lastAttemptTime = FormatTimestamp(snapshot.LastAttemptAt); + var freshness = FormatFreshness(snapshot, refreshInterval, now); + var resetDiagnostics = FormatResetDiagnostics(snapshot.ResetCredits); + var bucketDiagnostics = FormatBucketDiagnostics(snapshot.Buckets); + var accountContext = FormatAccountContext(snapshot); + var historyContext = FormatHistoryContext(snapshot); + var storageStatus = string.IsNullOrWhiteSpace(historyStorageError) + ? "no error reported" + : "error recorded (details withheld)"; + var ordinaryUsage = snapshot.OrdinaryUsageAllowed switch + { + true => "allowed", + false => "not allowed", + _ => "unknown (field missing)", + }; + + return $""" + # Codex Usage diagnostics + + These details are bounded and omit account identifiers, tokens, file paths, and raw error messages. + + ## Application and refresh + + - **Application version (source build):** {UsageText.EscapeMarkdown(safeVersion)} + - **Source:** {source} + - **Source configured:** {sourceConfigured} + - **Measurement time (UTC):** {measurementTime} + - **Last attempt time (UTC):** {lastAttemptTime} + - **Refresh interval:** {FormatDuration(refreshInterval)} + - **Freshness:** {freshness} + + ## Quota and account context + + {bucketDiagnostics} + {resetDiagnostics} + - **Ordinary usage:** {ordinaryUsage} + {accountContext} + {historyContext} + + ## Local persistence + + - **History persistence:** {storageStatus} + """.Trim(); + } + + private static string FormatSource(UsageDataSource source) => source switch + { + UsageDataSource.AppServer => "AppServer — standalone Codex CLI app-server", + UsageDataSource.LocalSession => "LocalSession — local Codex session metadata", + UsageDataSource.LastConfirmed => "LastConfirmed — last confirmed Codex usage", + UsageDataSource.Unavailable => "Unavailable — no usable usage data", + _ => "Initializing — waiting for a usage measurement", + }; + + private static string FormatSourceConfiguration(UsageDataSource source) => source switch + { + UsageDataSource.AppServer => "live app-server source selected", + UsageDataSource.LocalSession => "local session fallback selected", + UsageDataSource.LastConfirmed => "last confirmed measurement retained; live source unavailable", + UsageDataSource.Unavailable => "no usable source", + _ => "not determined (initializing)", + }; + + private static string FormatFreshness(CodexUsageSnapshot snapshot, TimeSpan refreshInterval, DateTimeOffset now) + { + DateTimeOffset? timestamp = snapshot.Source is UsageDataSource.Unavailable or UsageDataSource.Initializing + ? null + : snapshot.UpdatedAt; + var state = UsageFreshness.Classify( + timestamp, + now, + refreshInterval, + snapshot.Source == UsageDataSource.LastConfirmed); + + return state switch + { + UsageFreshnessState.Fresh => $"fresh (age {FormatAge(snapshot.UpdatedAt, now)}; threshold {FormatDuration(UsageFreshness.MaximumAge(refreshInterval))})", + UsageFreshnessState.Stale => $"stale (age {FormatAge(snapshot.UpdatedAt, now)}; threshold {FormatDuration(UsageFreshness.MaximumAge(refreshInterval))})", + UsageFreshnessState.Future => "future timestamp (clock skew suspected)", + UsageFreshnessState.LastConfirmed => $"last confirmed (age {FormatAge(snapshot.UpdatedAt, now)})", + _ => "unknown (measurement time unavailable)", + }; + } + + private static string FormatBucketDiagnostics(IReadOnlyList? buckets) + { + if (buckets is null) + { + return "- **Quota buckets:** unknown (field missing)"; + } + + var lines = new List + { + $"- **Quota buckets:** available ({buckets.Count.ToString(CultureInfo.InvariantCulture)})", + }; + var detailCount = Math.Min(buckets.Count, MaximumBucketDetails); + for (var index = 0; index < detailCount; index++) + { + var bucket = buckets[index]; + if (bucket is null) + { + lines.Add($" - Bucket {index + 1}: no usable details"); + continue; + } + + var windows = new List(2); + AddWindowDescription(windows, "primary", bucket.Primary); + AddWindowDescription(windows, "secondary", bucket.Secondary); + var detail = windows.Count == 0 ? "no recognized windows" : string.Join(", ", windows); + lines.Add($" - Bucket {index + 1}: {detail}"); + } + + if (buckets.Count > detailCount) + { + lines.Add($" - {buckets.Count - detailCount} additional bucket(s) omitted from this bounded report."); + } + + return string.Join(Environment.NewLine, lines); + } + + private static void AddWindowDescription(List descriptions, string role, RateLimitWindow? window) + { + if (window is null) + { + return; + } + + descriptions.Add($"{role} {FormatWindowDuration(window.WindowMinutes)}"); + } + + private static string FormatResetDiagnostics(RateLimitResetCredits? resets) + { + if (resets is null) + { + return "- **Reset credits:** unknown (field missing)\n- **Reset credit details:** unknown (field missing)"; + } + + var available = Math.Max(0, resets.AvailableCount).ToString(CultureInfo.InvariantCulture); + var details = resets.Credits is null + ? "unknown (field missing)" + : $"{resets.Credits.Count.ToString(CultureInfo.InvariantCulture)} provided"; + return $"- **Reset credits:** {available} available\n- **Reset credit details:** {details}"; + } + + private static string FormatAccountContext(CodexUsageSnapshot snapshot) => + $"- **Account context:** {(string.IsNullOrWhiteSpace(snapshot.AccountKey) ? "unavailable (account identity not provided)" : "available (hashed identifier present; value withheld)")}"; + + private static string FormatHistoryContext(CodexUsageSnapshot snapshot) + { + var accountVerified = !string.IsNullOrWhiteSpace(snapshot.AccountKey); + var bucketVerified = !string.IsNullOrWhiteSpace(snapshot.DefaultBucketId); + return accountVerified && bucketVerified + ? "- **Adaptive history context:** verified account and quota bucket; scoped learning can be persisted" + : "- **Adaptive history context:** unverified account or quota bucket; adaptive learning is paused (in-memory only)"; + } + + private static string FormatTimestamp(DateTimeOffset? timestamp) + { + if (timestamp is null || timestamp == DateTimeOffset.MinValue || timestamp == DateTimeOffset.MaxValue) + { + return "unknown"; + } + + try + { + return timestamp.Value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture); + } + catch (ArgumentOutOfRangeException) + { + return "unknown"; + } + } + + private static string FormatAge(DateTimeOffset timestamp, DateTimeOffset now) + { + try + { + var age = now - timestamp; + return age < TimeSpan.Zero ? "future" : FormatDuration(age); + } + catch (ArgumentOutOfRangeException) + { + return "unknown"; + } + } + + private static string FormatDuration(TimeSpan duration) + { + if (duration < TimeSpan.Zero) + { + return "future"; + } + + if (duration.TotalDays >= 1) + { + return $"{(int)duration.TotalDays}d"; + } + + if (duration.TotalHours >= 1) + { + return $"{(int)duration.TotalHours}h"; + } + + if (duration.TotalMinutes >= 1) + { + return $"{Math.Max(1, (int)duration.TotalMinutes)}m"; + } + + return $"{Math.Max(1, (int)duration.TotalSeconds)}s"; + } + + private static string FormatWindowDuration(int minutes) + { + if (minutes <= 0) + { + return "unknown duration"; + } + + if (minutes % (24 * 60) == 0) + { + return $"{minutes / (24 * 60)}d"; + } + + if (minutes % 60 == 0) + { + return $"{minutes / 60}h"; + } + + return $"{minutes}m"; + } + + private void UpdatePresentation() + { + var body = FormatDiagnostics( + _service.Current, + _service.RefreshInterval, + DateTimeOffset.Now, + _service.HistoryStorageError); + + lock (_presentationLock) + { + if (_disposed) + { + return; + } + + _content = new MarkdownContent(body); + Commands = + [ + new CommandContextItem(new RefreshUsageCommand(_service)) + { + Title = "Refresh now", + }, + new CommandContextItem(new CopyTextCommand(body)) + { + Title = "Copy safe diagnostics", + }, + ]; + } + } + + private void OnUpdated(object? sender, EventArgs e) + { + UpdatePresentation(); + lock (_presentationLock) + { + if (_disposed) + { + return; + } + } + + RaiseItemsChanged(0); + } + + public void Dispose() + { + lock (_presentationLock) + { + if (_disposed) + { + return; + } + + _disposed = true; + _service.Updated -= OnUpdated; + } + + GC.SuppressFinalize(this); + } +} diff --git a/CodexUsageDock/Pages/CodexUsageDockPage.cs b/CodexUsageDock/Pages/CodexUsageDockPage.cs index e0b35dc..cbaca81 100644 --- a/CodexUsageDock/Pages/CodexUsageDockPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockPage.cs @@ -63,9 +63,10 @@ internal static string FormatMainDataJson( AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, LocalTokenUsageSnapshot? tokenUsage = null) { - var dataAvailable = snapshot.Source != UsageDataSource.Unavailable; + var freshness = GetFreshnessState(snapshot, now, refreshInterval); + var dataAvailable = IsDataAvailable(snapshot, freshness); var maximumSampleAge = TrendFreshness(refreshInterval); - var (statusTitle, statusDescription) = FormatSummaryParts(snapshot, now); + var (statusTitle, statusDescription) = FormatSummaryParts(snapshot, now, refreshInterval); var data = new JsonObject { ["isLoading"] = isLoading, @@ -103,6 +104,9 @@ internal static string FormatMainDataJson( weeklyHistory, weeklyTrend, dataAvailable, + snapshot.Source is UsageDataSource.AppServer + or UsageDataSource.LocalSession + or UsageDataSource.LastConfirmed, now, TrendMaximumGap(refreshInterval), tokenUsage); @@ -113,7 +117,8 @@ internal static string FormatMainDataJson( internal static string FormatDetailsBody( CodexUsageSnapshot snapshot, DateTimeOffset now, - IReadOnlyList? weeklyHistory = null) => $""" + IReadOnlyList? weeklyHistory = null, + TimeSpan? refreshInterval = null) => $""" ## Resets and credits {FormatResetSummary(snapshot.ResetCredits, now)} @@ -122,44 +127,190 @@ internal static string FormatDetailsBody( {FormatRestorationDetails(snapshot.Secondary, weeklyHistory ?? [], now)} + {FormatAdditionalBucketDetails(snapshot, now)} + ## Account and data - **Plan:** {FormatPlan(snapshot.PlanType)} - - **Status:** {FormatDataStatus(snapshot, now)} + - **Status:** {FormatDataStatus(snapshot, now, refreshInterval)} - **Source:** {snapshot.SourceDisplayName} {FormatError(snapshot)} """; - internal static string FormatSummary(CodexUsageSnapshot snapshot, DateTimeOffset now) + internal static string FormatAdditionalBucketDetails(CodexUsageSnapshot snapshot, DateTimeOffset now) + { + if (snapshot.Buckets is not { Count: > 0 } buckets) + { + return string.Empty; + } + + var rows = new List(); + foreach (var bucket in buckets) + { + var label = UsageText.SanitizeExternal(bucket.Name) + ?? UsageText.SanitizeExternal(bucket.Id) + ?? "Additional quota"; + var isDefaultBucket = string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal); + AppendAdditionalBucketWindow(rows, label, "primary", bucket.Primary, snapshot, now, isDefaultBucket); + AppendAdditionalBucketWindow(rows, label, "secondary", bucket.Secondary, snapshot, now, isDefaultBucket); + } + + return rows.Count == 0 + ? string.Empty + : $"## Other quota windows\n\n{string.Join(Environment.NewLine, rows)}"; + } + + private static void AppendAdditionalBucketWindow( + List rows, + string label, + string role, + RateLimitWindow? window, + CodexUsageSnapshot snapshot, + DateTimeOffset now, + bool skipKnownDefaultWindow) + { + if (window is null + || skipKnownDefaultWindow && (window == snapshot.Primary || window == snapshot.Secondary) + || !UsageFreshness.IsValidWindow(window, now)) + { + return; + } + + var safeLabel = UsageText.EscapeMarkdown(label); + var duration = FormatWindowDuration(window.WindowMinutes); + rows.Add($"- **{safeLabel} ({role}, {duration}):** {window.RemainingPercent:0}% available · resets {FormatRelativeTime(window.ResetsAt, now)}."); + } + + private static string FormatWindowDuration(int windowMinutes) + { + if (windowMinutes == (int)TimeSpan.FromHours(5).TotalMinutes) + { + return "5-hour"; + } + + if (windowMinutes == (int)TimeSpan.FromDays(7).TotalMinutes) + { + return "weekly"; + } + + if (windowMinutes > 0 && windowMinutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) + { + return $"{windowMinutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; + } + + if (windowMinutes > 0 && windowMinutes % 60 == 0) + { + return $"{windowMinutes / 60}-hour"; + } + + return $"{windowMinutes}-minute"; + } + + internal static string FormatSummary( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan? refreshInterval = null) { - var (title, description) = FormatSummaryParts(snapshot, now); + var (title, description) = FormatSummaryParts(snapshot, now, refreshInterval); return $"> **{title}** \n> {description}"; } - private static (string Title, string Description) FormatSummaryParts(CodexUsageSnapshot snapshot, DateTimeOffset now) + private static (string Title, string Description) FormatSummaryParts( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan? refreshInterval = null) { - var activeWindow = snapshot.Primary is { } primary && snapshot.Secondary is { } secondary + var primaryWindow = UsageFreshness.IsValidWindow(snapshot.Primary, now) ? snapshot.Primary : null; + var secondaryWindow = UsageFreshness.IsValidWindow(snapshot.Secondary, now) ? snapshot.Secondary : null; + var activeWindow = primaryWindow is { } primary && secondaryWindow is { } secondary ? (primary.RemainingPercent <= secondary.RemainingPercent ? primary : secondary) - : snapshot.Primary ?? snapshot.Secondary; + : primaryWindow ?? secondaryWindow; + var freshness = GetFreshnessState(snapshot, now, refreshInterval); + if (freshness == UsageFreshnessState.Fresh && snapshot.OrdinaryUsageAllowed == false) + { + return ( + "Status: Ordinary usage blocked", + "Ordinary usage is currently blocked for this account."); + } + if (activeWindow is null) { - return ("Status: Usage allowance unknown", FormatDataStatus(snapshot, now)); + var statusTitle = freshness switch + { + UsageFreshnessState.LastConfirmed => "Status: Last confirmed usage", + UsageFreshnessState.Stale => "Status: Usage data is stale", + UsageFreshnessState.Future => "Status: Usage timestamp is in the future", + _ => "Status: Usage allowance unknown", + }; + return (statusTitle, FormatDataStatus(snapshot, now, refreshInterval)); + } + + if (freshness is UsageFreshnessState.LastConfirmed or UsageFreshnessState.Stale) + { + var state = freshness == UsageFreshnessState.LastConfirmed ? "Last confirmed usage" : "Usage data is stale"; + var description = $"Last known allowance: {FormatWindowSummary(activeWindow, snapshot, now)}"; + return ($"Status: {state}", description); + } + + if (freshness == UsageFreshnessState.Future) + { + return ( + "Status: Usage timestamp is in the future", + $"{FormatWindowSummary(activeWindow, snapshot, now)} Timestamp needs confirmation."); + } + + if (freshness == UsageFreshnessState.Unknown) + { + return ( + "Status: Usage freshness unknown", + $"{FormatWindowSummary(activeWindow, snapshot, now)}"); } var remaining = activeWindow.RemainingPercent; - var title = remaining switch + var allowanceTitle = remaining switch { <= 10 => "Almost at your limit", <= 30 => "Limited allowance available", _ => "Plenty of allowance available", }; - var weekly = snapshot.Primary is not null && snapshot.Secondary is not null - ? $" Weekly allowance: {snapshot.Secondary.RemainingPercent:0}%." + var weekly = primaryWindow is not null && secondaryWindow is not null + ? $" Weekly allowance: {secondaryWindow.RemainingPercent:0}%." : string.Empty; - var limitingWindow = snapshot.Primary is not null && snapshot.Secondary is not null - ? (ReferenceEquals(activeWindow, snapshot.Secondary) ? "Weekly window: " : "5-hour window: ") + var limitingWindow = primaryWindow is not null && secondaryWindow is not null + ? (ReferenceEquals(activeWindow, secondaryWindow) ? "Weekly window: " : "5-hour window: ") : string.Empty; - return ($"Status: {title}", $"{limitingWindow}{remaining:0}% available; resets {FormatRelativeTime(activeWindow.ResetsAt, now)}.{weekly}"); + return ($"Status: {allowanceTitle}", $"{limitingWindow}{remaining:0}% available; resets {FormatRelativeTime(activeWindow.ResetsAt, now)}.{weekly}"); + } + + private static UsageFreshnessState GetFreshnessState( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan? refreshInterval) + { + return snapshot.Source switch + { + UsageDataSource.LastConfirmed => UsageFreshnessState.LastConfirmed, + UsageDataSource.AppServer or UsageDataSource.LocalSession => + UsageFreshness.Classify(snapshot.UpdatedAt, now, refreshInterval ?? TimeSpan.FromMinutes(1)), + _ => UsageFreshnessState.Unknown, + }; + } + + private static bool IsDataAvailable(CodexUsageSnapshot snapshot, UsageFreshnessState freshness) => + snapshot.Source is UsageDataSource.AppServer or UsageDataSource.LocalSession + && freshness == UsageFreshnessState.Fresh; + + private static string FormatWindowSummary( + RateLimitWindow window, + CodexUsageSnapshot snapshot, + DateTimeOffset now) + { + var label = ReferenceEquals(window, snapshot.Secondary) && snapshot.Primary is not null + ? "Weekly window" + : ReferenceEquals(window, snapshot.Primary) && snapshot.Secondary is not null + ? "5-hour window" + : "Usage window"; + return $"{label}: {window.RemainingPercent:0}% available; resets {FormatRelativeTime(window.ResetsAt, now)}."; } internal static string FormatCredits(CreditBalance? credits) @@ -213,6 +364,11 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi return $"## {name}\n\n**{inactiveMessage ?? "Not available"}**"; } + if (!UsageFreshness.IsValidWindow(window, now)) + { + return $"## {name}\n\n**Window data expired or invalid; waiting for refreshed data.**"; + } + return $"## {name}\n\n**{window.RemainingPercent:0}% available** \nResets {FormatRelativeTime(window.ResetsAt, now)} · {FormatLocalTime(window.ResetsAt, "ddd d MMM HH:mm")}"; } @@ -230,10 +386,13 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi AdaptiveWeeklyUsageHistory? adaptiveWeeklyHistory = null, bool adaptiveWeeklyForecastEnabled = false) { - data[$"{prefix}Available"] = window is not null; - if (window is null) + var usableWindow = UsageFreshness.IsValidWindow(window, now); + data[$"{prefix}Available"] = usableWindow; + if (!usableWindow || window is null) { - data[$"{prefix}State"] = inactiveMessage; + data[$"{prefix}State"] = window is null + ? inactiveMessage + : "Window data expired or invalid; waiting for refreshed data."; return null; } @@ -245,7 +404,9 @@ internal static string FormatWindow(string name, RateLimitWindow? window, DateTi var elapsedPercent = window.WindowMinutes > 0 ? Math.Clamp((now - windowStartsAt).TotalMinutes / window.WindowMinutes * 100, 0, 100) : 0; - var (paceStatus, paceColor) = FormatPaceStatus(usedPercent, elapsedPercent); + var (paceStatus, paceColor) = dataAvailable + ? FormatPaceStatus(usedPercent, elapsedPercent) + : ("Pace unavailable until refreshed", "Default"); data[$"{prefix}UsedPercent"] = $"{usedPercent:0}%"; data[$"{prefix}ElapsedPercent"] = $"{elapsedPercent:0}%"; data[$"{prefix}UsedBarUrl"] = UsageDashboardCard.CreateProgressBarImageUrl(usedPercent, palette); @@ -275,20 +436,25 @@ private static void AddWeeklyTrendData( IReadOnlyList history, UsageTrendAnalyzer.TrendAnalysis? trend, bool dataAvailable, + bool canShowObservedHistory, DateTimeOffset now, TimeSpan maximumGap, LocalTokenUsageSnapshot? tokenUsage) { data["weeklyTrendAvailable"] = false; data["weeklyRestorationAvailable"] = false; - if (window is null || trend is null || !dataAvailable) + if (window is not { } validWindow + || !UsageFreshness.IsValidWindow(validWindow, now) + || !canShowObservedHistory) { return; } - data["weeklyForecastStatus"] = trend.ForecastStatus; + data["weeklyForecastStatus"] = dataAvailable && trend is not null + ? trend.ForecastStatus + : "Forecast unavailable until usage data is refreshed."; - var windowStartsAt = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + var windowStartsAt = validWindow.ResetsAt - TimeSpan.FromMinutes(validWindow.WindowMinutes); var chartHistory = history.Where(sample => sample.RecordedAt >= windowStartsAt).ToArray(); if (chartHistory.Length < 2) { @@ -297,10 +463,10 @@ private static void AddWeeklyTrendData( var chart = WeeklyUsageTrendChartRenderer.Create( history, - window, + validWindow, now, maximumGap, - trend.Forecast, + dataAvailable ? trend?.Forecast : null, tokenUsage); if (chart is null) { @@ -310,19 +476,22 @@ private static void AddWeeklyTrendData( data["weeklyTrendAvailable"] = true; data["weeklyTrendChartUrl"] = chart.ImageUrl; data["weeklyTrendChartAlt"] = chart.AltText; + var forecastLegend = dataAvailable && trend?.Forecast is not null + ? "dashed: forecast" + : "forecast unavailable until usage data is refreshed"; data["weeklyTrendLegend"] = tokenUsage?.Status switch { - LocalTokenUsageStatus.Complete => "Solid: remaining allowance (%) · dashed: forecast · bars: local tokens per day · amber: detected restorations", - LocalTokenUsageStatus.Partial => "Solid: remaining allowance (%) · dashed: forecast · bars: partial local tokens per day · amber: detected restorations", - _ => "Solid: remaining allowance (%) · dashed: forecast · local token data unavailable · amber: detected restorations", + LocalTokenUsageStatus.Complete => $"Solid: remaining allowance (%) · {forecastLegend} · bars: local tokens per day · amber: detected restorations", + LocalTokenUsageStatus.Partial => $"Solid: remaining allowance (%) · {forecastLegend} · bars: partial local tokens per day · amber: detected restorations", + _ => $"Solid: remaining allowance (%) · {forecastLegend} · local token data unavailable · amber: detected restorations", }; - var restorations = WeeklyAllowanceRestoration.Detect(history, window, now); + var restorations = WeeklyAllowanceRestoration.Detect(history, validWindow, now); if (restorations.Length > 0) { data["weeklyRestorationAvailable"] = true; data["weeklyRestorationSummary"] = FormatRestorationSummary(restorations); - if (trend.Forecast is not null) + if (dataAvailable && trend?.Forecast is not null) { data["weeklyProjection"] = $"{trend.Message} Forecast restarted from the latest restoration."; } @@ -414,7 +583,7 @@ internal static string FormatRelativeTime(DateTimeOffset target, DateTimeOffset } internal static string FormatTrend(IReadOnlyList history, DateTimeOffset now, bool dataAvailable = true) => - $"## Usage trend\n\n{FormatTrendBodyForReset(history, null, null, now, dataAvailable, TimeSpan.FromMinutes(5))}"; + $"## Usage trend\n\n{FormatTrendBodyForReset(history, null, null, now, dataAvailable, UsageFreshness.MaximumAge(TimeSpan.FromMinutes(1)))}"; internal static string FormatTrend( string title, @@ -484,7 +653,7 @@ private static string FormatTrendBodyForReset( } private static TimeSpan TrendFreshness(TimeSpan refreshInterval) => - UsageTrendHistory.Freshness(refreshInterval); + UsageFreshness.MaximumAge(refreshInterval); private static TimeSpan TrendMaximumGap(TimeSpan refreshInterval) => UsageTrendHistory.MaximumGap(refreshInterval); @@ -497,25 +666,37 @@ internal static string FormatResetSummary(RateLimitResetCredits? resets, DateTim return $"- **Resets:** {resets.AvailableCount.ToString(CultureInfo.CurrentCulture)} available{expiry}"; } - internal static string FormatDataStatus(CodexUsageSnapshot snapshot, DateTimeOffset now) + internal static string FormatDataStatus( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan? refreshInterval = null) { if (snapshot.Source == UsageDataSource.Unavailable) { - return $"Data not available · last attempt at {FormatLocalTime(snapshot.UpdatedAt, "HH:mm")}"; + return snapshot.LastAttemptAt is { } lastAttempt + ? $"Data not available · last attempt at {FormatLocalTime(lastAttempt, "HH:mm")}" + : "Data not available · last attempt unknown"; } - var age = now - snapshot.UpdatedAt; - var freshness = age < TimeSpan.FromMinutes(2) - ? $"just updated at {FormatLocalTime(snapshot.UpdatedAt, "HH:mm")}" - : $"possibly outdated · updated {FormatRelativeAge(age)} ago"; var mode = snapshot.Source switch { UsageDataSource.AppServer => "Live", UsageDataSource.LocalSession => "Local fallback data", + UsageDataSource.LastConfirmed => "Last confirmed usage", UsageDataSource.Initializing => "Loading data", UsageDataSource.Unavailable => "Data not available", _ => "Data not available", }; + + var state = GetFreshnessState(snapshot, now, refreshInterval); + var freshness = state switch + { + UsageFreshnessState.Fresh => $"just updated at {FormatLocalTime(snapshot.UpdatedAt, "HH:mm")}", + UsageFreshnessState.Stale => $"possibly outdated · updated {FormatAge(snapshot.UpdatedAt, now)} ago", + UsageFreshnessState.Future => $"timestamp is in the future ({FormatLocalTime(snapshot.UpdatedAt, "HH:mm")})", + UsageFreshnessState.LastConfirmed => $"last confirmed {FormatAge(snapshot.UpdatedAt, now)} ago", + _ => "data age unknown", + }; return $"{mode} · {freshness}"; } @@ -527,9 +708,31 @@ internal static string FormatPlan(string? plan) : UsageText.EscapeMarkdown(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(safePlan.Replace('_', ' '))); } - private static string FormatRelativeAge(TimeSpan age) => age < TimeSpan.FromHours(1) - ? $"{Math.Max(1, (int)age.TotalMinutes)} minutes" - : age < TimeSpan.FromDays(1) ? $"{(int)age.TotalHours} hours" : $"{(int)age.TotalDays} days"; + private static string FormatAge(DateTimeOffset timestamp, DateTimeOffset now) + { + TimeSpan age; + try + { + age = now - timestamp; + } + catch (ArgumentOutOfRangeException) + { + return "an unknown time"; + } + + if (age < TimeSpan.Zero) + { + return "in the future"; + } + + return age < TimeSpan.FromMinutes(1) + ? "less than a minute" + : age < TimeSpan.FromHours(1) + ? $"{Math.Max(1, (int)age.TotalMinutes)} minutes" + : age < TimeSpan.FromDays(1) + ? $"{(int)age.TotalHours} hours" + : $"{(int)age.TotalDays} days"; + } internal static string FormatError(CodexUsageSnapshot snapshot) { @@ -538,6 +741,11 @@ internal static string FormatError(CodexUsageSnapshot snapshot) return $"> **Live Codex data is unavailable. Showing local fallback data.** \n> {CodexUsageService.LiveDataUnavailableMessage}"; } + if (snapshot.Source == UsageDataSource.LastConfirmed) + { + return $"> **Live Codex data is unavailable. Showing last confirmed usage.** \n> {CodexUsageService.LiveDataUnavailableMessage}"; + } + return snapshot.Source == UsageDataSource.Unavailable ? $"> **Codex usage data is unavailable.** \n> {CodexUsageService.AllDataUnavailableMessage}" : string.Empty; @@ -555,20 +763,21 @@ private void UpdatePresentation() return; } - var snapshot = _usage.Current; + var presentation = _usage.GetPresentation(); + var snapshot = presentation.Usage; var now = DateTimeOffset.Now; - IsLoading = _usage.IsLoading; + IsLoading = presentation.IsLoading; _mainContent.DataJson = FormatMainDataJson( snapshot, now, - _usage.IsLoading, - _usage.PrimaryHistory, - _usage.WeeklyHistory, + presentation.IsLoading, + presentation.PrimaryHistory, + presentation.WeeklyHistory, _usage.RefreshInterval, _settings.UseAdaptiveWeeklyForecast, - _usage.AdaptiveWeeklyHistory, - _usage.CurrentTokenUsage); - _details.Body = FormatDetailsBody(snapshot, now, _usage.WeeklyHistory) + presentation.AdaptiveWeeklyHistory, + presentation.TokenUsage); + _details.Body = FormatDetailsBody(snapshot, now, presentation.WeeklyHistory, _usage.RefreshInterval) + (_usage.HistoryStorageError is { } error ? $"\n\n> **Local storage:** {error}" : string.Empty); } diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 4ca0c76..7db332a 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -77,7 +77,7 @@ internal CodexUsageDockSettingsPage(string path) Result = CommandResult.KeepOpen(), }, "Delete learned forecast history?", - "This permanently removes local adaptive weekly forecast history from this device.", + "This permanently removes learned forecast history for the currently identified account and quota category. Other accounts are kept.", () => true) { Name = "Delete learned forecast history", diff --git a/CodexUsageDock/UsageData.cs b/CodexUsageDock/UsageData.cs index ad489c0..41a6c1a 100644 --- a/CodexUsageDock/UsageData.cs +++ b/CodexUsageDock/UsageData.cs @@ -64,6 +64,12 @@ internal sealed record RateLimitWindow(double UsedPercent, int WindowMinutes, Da internal readonly record struct ClassifiedRateLimitWindows(RateLimitWindow? FiveHour, RateLimitWindow? Weekly); +internal sealed record RateLimitBucket( + string Id, + string? Name, + RateLimitWindow? Primary, + RateLimitWindow? Secondary); + internal sealed record CreditBalance(bool HasCredits, bool Unlimited, string? Balance); internal sealed record RateLimitResetCredit(string? Title, string? Status, DateTimeOffset? ExpiresAt); @@ -109,6 +115,7 @@ internal enum UsageDataSource Initializing, AppServer, LocalSession, + LastConfirmed, Unavailable, } @@ -120,12 +127,18 @@ internal sealed record CodexUsageSnapshot( RateLimitResetCredits? ResetCredits, DateTimeOffset UpdatedAt, UsageDataSource Source, - string? Error) + string? Error, + IReadOnlyList? Buckets = null, + string? AccountKey = null, + bool? OrdinaryUsageAllowed = null, + DateTimeOffset? LastAttemptAt = null, + string? DefaultBucketId = null) { public string SourceDisplayName => Source switch { UsageDataSource.AppServer => "standalone Codex CLI app-server", UsageDataSource.LocalSession => "local Codex session metadata (desktop app, CLI, or another client)", + UsageDataSource.LastConfirmed => "last confirmed Codex usage", UsageDataSource.Unavailable => "not available", _ => "initializing", }; diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index f5db827..36c1a51 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -31,6 +31,7 @@ public UsageDockItem(CodexUsageService usage, UsageDockItemKind kind, CodexUsage private void UpdateText() { var snapshot = _usage.Current; + var now = DateTimeOffset.Now; var window = _kind == UsageDockItemKind.FiveHour ? snapshot.Primary : snapshot.Secondary; if (snapshot.Source == UsageDataSource.Unavailable) { @@ -42,7 +43,9 @@ private void UpdateText() if (_kind == UsageDockItemKind.ResetsAndCredits) { Title = FormatResetsAndCredits(snapshot); - Subtitle = FormatResetExpiry(snapshot.ResetCredits, DateTimeOffset.Now); + Subtitle = CombineStatusAndDetail( + FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), + FormatResetExpiry(snapshot.ResetCredits, now)); Icon = new IconInfo("\uE777"); return; } @@ -54,20 +57,24 @@ private void UpdateText() Title = _kind == UsageDockItemKind.FiveHour && dataWasLoaded ? "5h inactive" : $"{label} --"; Subtitle = _kind == UsageDockItemKind.FiveHour && dataWasLoaded ? "No five-hour limit currently active" - : snapshot.Source == UsageDataSource.Unavailable - ? "Codex usage unavailable" - : "Waiting for Codex"; + : FormatSourceFreshness(snapshot, now, _usage.RefreshInterval); Icon = new IconInfo("\uE783"); return; } - Title = $"{label} {window.RemainingPercent:0}%"; - Subtitle = _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; - if (snapshot.Source == UsageDataSource.LocalSession) + if (!UsageFreshness.IsValidWindow(window, now)) { - Subtitle = FormatFallbackAge(snapshot, DateTimeOffset.Now) - + (Subtitle.Length > 0 ? $" · {Subtitle}" : string.Empty); + Title = $"{label} --"; + Subtitle = CombineStatusAndDetail( + FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), + "Window data expired or invalid"); + Icon = new IconInfo("\uE783"); + return; } + + Title = $"{label} {window.RemainingPercent:0}%"; + var reset = _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; + Subtitle = CombineStatusAndDetail(FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), reset); Icon = new IconInfo(window.RemainingPercent <= 10 ? "\uE7BA" : "\uE916"); } @@ -75,13 +82,101 @@ private void UpdateText() internal static string FormatFallbackAge(CodexUsageSnapshot snapshot, DateTimeOffset now) { - var age = now - snapshot.UpdatedAt; + TimeSpan age; + try + { + age = now - snapshot.UpdatedAt; + } + catch (ArgumentOutOfRangeException) + { + return "Fallback · age unavailable"; + } + + if (age < TimeSpan.Zero) + { + return "Fallback · timestamp is in the future"; + } + return age < TimeSpan.FromMinutes(1) ? "Fallback · less than a minute old" : age < TimeSpan.FromHours(1) ? $"Fallback · {(int)age.TotalMinutes} minutes old" : age < TimeSpan.FromDays(1) ? $"Fallback · {(int)age.TotalHours} hours old" : $"Fallback · {(int)age.TotalDays} days old"; } + internal static string FormatSourceFreshness( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan? refreshInterval = null) + { + if (snapshot.Source == UsageDataSource.Unavailable) + { + return "Codex usage unavailable"; + } + + if (snapshot.Source == UsageDataSource.Initializing) + { + return "Waiting for Codex"; + } + + if (snapshot.Source == UsageDataSource.LastConfirmed) + { + return FormatConfirmedAge(snapshot, now); + } + + var state = UsageFreshness.Classify( + snapshot.UpdatedAt, + now, + refreshInterval ?? TimeSpan.FromMinutes(1)); + return state switch + { + UsageFreshnessState.Fresh when snapshot.Source == UsageDataSource.LocalSession => FormatFallbackAge(snapshot, now), + UsageFreshnessState.Fresh => $"Live · just updated at {FormatLocalTime(snapshot.UpdatedAt)}", + UsageFreshnessState.Stale => $"Stale · updated {FormatAge(snapshot.UpdatedAt, now)} ago", + UsageFreshnessState.Future => $"Timestamp is in the future ({FormatLocalTime(snapshot.UpdatedAt)})", + _ => "Usage age unavailable", + }; + } + + private static string FormatConfirmedAge(CodexUsageSnapshot snapshot, DateTimeOffset now) + { + var age = FormatAge(snapshot.UpdatedAt, now); + return age == "in the future" + ? "Last confirmed · timestamp is in the future" + : $"Last confirmed · {age} old"; + } + + private static string FormatAge(DateTimeOffset timestamp, DateTimeOffset now) + { + TimeSpan age; + try + { + age = now - timestamp; + } + catch (ArgumentOutOfRangeException) + { + return "age unavailable"; + } + + if (age < TimeSpan.Zero) + { + return "in the future"; + } + + return age < TimeSpan.FromMinutes(1) + ? "less than a minute" + : age < TimeSpan.FromHours(1) + ? $"{(int)age.TotalMinutes} minutes" + : age < TimeSpan.FromDays(1) + ? $"{(int)age.TotalHours} hours" + : $"{(int)age.TotalDays} days"; + } + + private static string FormatLocalTime(DateTimeOffset value) => + value.ToLocalTime().ToString("HH:mm", CultureInfo.CurrentCulture); + + private static string CombineStatusAndDetail(string status, string detail) => + detail.Length == 0 ? status : $"{status} · {detail}"; + internal static string FormatResetsAndCredits(CodexUsageSnapshot snapshot) { var resets = snapshot.ResetCredits is null ? "--" : snapshot.ResetCredits.AvailableCount.ToString(CultureInfo.CurrentCulture); diff --git a/CodexUsageDock/UsageFreshness.cs b/CodexUsageDock/UsageFreshness.cs new file mode 100644 index 0000000..15ac4d4 --- /dev/null +++ b/CodexUsageDock/UsageFreshness.cs @@ -0,0 +1,65 @@ +namespace CodexUsageDock; + +internal enum UsageFreshnessState +{ + Unknown, + Fresh, + Stale, + Future, + LastConfirmed, +} + +internal static class UsageFreshness +{ + internal static readonly TimeSpan MinimumAge = TimeSpan.FromMinutes(5); + + internal static TimeSpan MaximumAge(TimeSpan refreshInterval) => + refreshInterval > MinimumAge ? refreshInterval : MinimumAge; + + internal static UsageFreshnessState Classify( + DateTimeOffset? updatedAt, + DateTimeOffset now, + TimeSpan refreshInterval, + bool isLastConfirmed = false) + { + if (isLastConfirmed) + { + return UsageFreshnessState.LastConfirmed; + } + + if (updatedAt is not { } timestamp) + { + return UsageFreshnessState.Unknown; + } + + try + { + var age = now - timestamp; + if (age < TimeSpan.Zero) + { + return UsageFreshnessState.Future; + } + + return age <= MaximumAge(refreshInterval) + ? UsageFreshnessState.Fresh + : UsageFreshnessState.Stale; + } + catch (ArgumentOutOfRangeException) + { + return UsageFreshnessState.Unknown; + } + } + + internal static bool IsFresh( + DateTimeOffset? updatedAt, + DateTimeOffset now, + TimeSpan refreshInterval, + bool isLastConfirmed = false) => + Classify(updatedAt, now, refreshInterval, isLastConfirmed) == UsageFreshnessState.Fresh; + + internal static bool IsValidWindow(RateLimitWindow? window, DateTimeOffset now) => + window is { WindowMinutes: > 0 } candidate + && double.IsFinite(candidate.UsedPercent) + && candidate.UsedPercent is >= 0 and <= 100 + && candidate.ResetsAt > now; +} diff --git a/CodexUsageDock/UsagePresentation.cs b/CodexUsageDock/UsagePresentation.cs new file mode 100644 index 0000000..4158d43 --- /dev/null +++ b/CodexUsageDock/UsagePresentation.cs @@ -0,0 +1,9 @@ +namespace CodexUsageDock; + +internal sealed record UsagePresentation( + CodexUsageSnapshot Usage, + IReadOnlyList PrimaryHistory, + IReadOnlyList WeeklyHistory, + AdaptiveWeeklyUsageHistory AdaptiveWeeklyHistory, + LocalTokenUsageSnapshot TokenUsage, + bool IsLoading); diff --git a/CodexUsageDock/UsageTrendHistory.cs b/CodexUsageDock/UsageTrendHistory.cs index 47b5a75..74a3cd0 100644 --- a/CodexUsageDock/UsageTrendHistory.cs +++ b/CodexUsageDock/UsageTrendHistory.cs @@ -3,7 +3,7 @@ namespace CodexUsageDock; internal static class UsageTrendHistory { internal static TimeSpan Freshness(TimeSpan refreshInterval) => - refreshInterval > TimeSpan.FromMinutes(5) ? refreshInterval : TimeSpan.FromMinutes(5); + UsageFreshness.MaximumAge(refreshInterval); internal static TimeSpan MaximumGap(TimeSpan refreshInterval) => TimeSpan.FromTicks(Freshness(refreshInterval).Ticks * 3); diff --git a/CodexUsageDock/WeeklyUsageHistoryStore.cs b/CodexUsageDock/WeeklyUsageHistoryStore.cs index 3155f22..7397015 100644 --- a/CodexUsageDock/WeeklyUsageHistoryStore.cs +++ b/CodexUsageDock/WeeklyUsageHistoryStore.cs @@ -15,6 +15,8 @@ internal WeeklyUsageHistoryStore(string path) internal static WeeklyUsageHistoryStore CreateDefault() => new(LocalStorage.GetPath(FileName)); + internal WeeklyUsageHistoryStore ForContext(string context) => new(LocalStorage.ContextPath(_path, context)); + internal string? StorageError { get; private set; } internal IReadOnlyList Load(DateTimeOffset now) diff --git a/PRIVACY.md b/PRIVACY.md index 5c34a74..787feda 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -20,6 +20,8 @@ Settings are saved explicitly in `CodexUsageDock/settings.json` under the curren The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt from the current weekly window after a restart. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles; measurements collected while paused are not added later. Users can also delete the learned profiles from Codex Usage settings. +Account-scoped history uses a one-way hash of the account identity supplied by Codex, combined with the default quota category, as an opaque local directory name. Raw account identifiers and email addresses are not stored or shown in diagnostics. Legacy history without account attribution is not imported into a verified account; unverified observations remain in memory and do not train saved forecasts. The last confirmed usage snapshot is retained only in memory during an outage, with its original timestamp. Diagnostics exposes field availability and bounded status messages, not raw service errors, credentials, or personal paths. + ## Permissions The Windows `runFullTrust` capability is required to run the packaged Command Palette COM server and to communicate with the locally installed Codex process. It is not used to bypass Windows security controls or access unrelated user data. diff --git a/README.md b/README.md index 90ecac4..9501f54 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ The package includes the required .NET runtime. You do not need the .NET SDK to ## Install -Microsoft Store version `0.5.3` is publicly available and is the supported production installer. Install [Codex Usage Dock from the Microsoft Store](https://apps.microsoft.com/detail/9NFCPJXQG9FG). +Microsoft Store is the supported production installer. Install [Codex Usage Dock from the Microsoft Store](https://apps.microsoft.com/detail/9NFCPJXQG9FG). GitHub Actions artifacts are inputs for Microsoft Store certification, not public installers. Do not distribute or install them directly. @@ -64,6 +64,7 @@ Remove **Codex Usage Dock** from **Windows Settings > Apps > Installed apps**. ## Troubleshooting +- Open **Codex Usage diagnostics** for the running build, measurement time, latest refresh attempt, source, and reset-field availability. **Copy diagnostics** copies only the safe report, without account identifiers, paths, or raw service errors. A missing reset count is different from an explicitly reported zero. - Confirm that a standalone `codex.exe` or `codex.cmd` is available on `PATH`, or set `CODEX_USAGE_DOCK_CODEX_PATH` to its full path and restart PowerToys. The extension will show local fallback data when no launchable CLI is found. - Confirm that Codex is signed in. - Confirm that PowerToys Command Palette is enabled and running. @@ -73,7 +74,7 @@ Remove **Codex Usage Dock** from **Windows Settings > Apps > Installed apps**. ## Distribution status -Microsoft Store product `9NFCPJXQG9FG` is the only production and update channel. Version `0.5.3` is publicly available in the Store. The GitHub `v0.5.3` release records the corresponding source release and does not contain an unsigned installer. +Microsoft Store product `9NFCPJXQG9FG` is the only production and update channel. GitHub releases identify source versions; a source release does not establish Store rollout or the version installed on a device. Check Microsoft Store for available updates and Windows Apps settings for the installed package version. Diagnostics reports the running extension build separately. ## Privacy @@ -81,6 +82,14 @@ The extension runs locally. It talks to the standalone Codex CLI app-server and See the full [Privacy Policy](PRIVACY.md). +## Data reliability + +Details retains separate quota categories returned by newer Codex versions, including durations other than five hours or one week. The familiar Dock entries and weekly forecast describe only the default category. Percentages from different categories are never added or averaged. Credits remain usable even when there are no percentage windows. + +Freshness uses the greater of five minutes and the configured refresh interval throughout the UI and forecasts. After a failed refresh, a previous live measurement can remain visible as **Last confirmed** with its original timestamp; projections and new history learning pause. An unverified session log cannot replace a confirmed account measurement. At first launch, local session fallback is still available if live data cannot be obtained. + +When Codex supplies an account identity, weekly history and learned profiles are stored separately for that account and default quota category using opaque hashed directory names. History appears only after the account is identified. Older history files have no identity and are not imported into an account. Without a verified account identity, recent observations remain in memory and adaptive learning is paused. + ## Development Build, test, Store packaging, and release instructions are in [DEVELOPMENT.md](DEVELOPMENT.md). diff --git a/SPRINTS.md b/SPRINTS.md new file mode 100644 index 0000000..890a88d --- /dev/null +++ b/SPRINTS.md @@ -0,0 +1,27 @@ +# Usage assistant implementation + +This series implements the recommended Codex-first roadmap, followed by a small, optional Claude pilot. Source work uses separate feature branches and pull requests. Merge, Store publication, and installation are separate steps. + +| Sprint | Feature branch | Scope | Status | +| --- | --- | --- | --- | +| 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | Implemented; 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | +| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | Planned | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Planned | +| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | + +Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. + +## Acceptance and verification + +- Preserve old CLI compatibility and optional-field availability. Unknown values must never turn into zero. +- Keep account, category, provider, source, and timestamp semantics explicit; do not mix quota percentages or treat local tokens as billed cost. +- Only fresh, attributable measurements can trigger account alerts or train persistent forecasts. +- Keep local storage bounded and export explicit. Do not modify authentication or a user's existing Claude statusline automatically. +- Test parsing, state transitions, persistence, cancellation, and unsafe inputs using isolated synthetic fixtures. +- Run native ARM64 tests and x64/ARM64 builds; use PR CI for its required x64 tests and Store-package checks. Record integration limitations separately from build results. + +Gemini/Cursor/Copilot expansion, a standalone tray app, cloud sync, team dashboards, and a full session manager remain deferred as recommended by the research. Forecast quality labels describe available evidence and are not a claim of empirically calibrated confidence. + +### Sprint 1 verification + +Native ARM64 tests passed (186/186); application builds have no warnings. Existing test-name analyzer warnings remain unchanged. The x64 .NET 10 test runtime is unavailable locally, so x64 test execution belongs to PR CI. The integration preflight passed manifest, COM identity, generated-output freshness, self-contained runtime, and asset checks. Package registration and AppExtension discovery were unavailable in this test context; Command Palette reload, visual behavior, Store installation, and real-account compatibility were not verified. Tests use isolated synthetic data and do not consume actual reset credits. From c573da092c47f34e67d3ae4686ad52c44d46601b Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:56:17 +0200 Subject: [PATCH 02/21] Link reliability sprint to pull request 18 --- CHANGELOG.md | 10 +++++----- SPRINTS.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87cf07d..d1b7db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,18 +10,18 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. -- Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. +- Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) +- Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Fixed -- Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. -- Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. +- Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) +- Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Changed - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) -- Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. +- Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ## [0.6.1] - 2026-09-09 diff --git a/SPRINTS.md b/SPRINTS.md index 890a88d..98bce0d 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -4,7 +4,7 @@ This series implements the recommended Codex-first roadmap, followed by a small, | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | -| 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | Implemented; 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | +| 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | | 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | Planned | | 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Planned | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | From e742b21c796494abb2bd9768a9c77443d5a049b4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:22:25 +0200 Subject: [PATCH 03/21] Add quiet alerts, account activity, and Dock source controls --- CHANGELOG.md | 3 + CodexUsageDock.Tests/AccountUsageTests.cs | 275 ++++++++++ CodexUsageDock.Tests/ProviderDockTests.cs | 60 +++ .../SourceAndActivityServiceTests.cs | 121 +++++ CodexUsageDock.Tests/TestEnvironment.cs | 10 +- CodexUsageDock.Tests/UsageAlertTests.cs | 363 +++++++++++++ CodexUsageDock.Tests/UsagePreferenceTests.cs | 182 +++++++ CodexUsageDock/AccountUsageData.cs | 129 +++++ CodexUsageDock/CodexAppServerReader.cs | 167 ++++-- CodexUsageDock/CodexSourceOptions.cs | 47 ++ .../CodexUsageDockCommandsProvider.cs | 71 ++- .../CodexUsageService.AccountActivity.cs | 94 ++++ CodexUsageDock/CodexUsageService.cs | 126 ++++- .../Pages/CodexAccountActivityPage.cs | 131 +++++ .../Pages/CodexUsageDockSettingsPage.cs | 99 +++- CodexUsageDock/RefreshUsageCommand.cs | 3 +- CodexUsageDock/UsageAlerts.cs | 478 ++++++++++++++++++ CodexUsageDock/UsageDockItem.cs | 29 +- PRIVACY.md | 2 + README.md | 10 + SPRINTS.md | 8 +- 21 files changed, 2335 insertions(+), 73 deletions(-) create mode 100644 CodexUsageDock.Tests/AccountUsageTests.cs create mode 100644 CodexUsageDock.Tests/ProviderDockTests.cs create mode 100644 CodexUsageDock.Tests/SourceAndActivityServiceTests.cs create mode 100644 CodexUsageDock.Tests/UsageAlertTests.cs create mode 100644 CodexUsageDock.Tests/UsagePreferenceTests.cs create mode 100644 CodexUsageDock/AccountUsageData.cs create mode 100644 CodexUsageDock/CodexSourceOptions.cs create mode 100644 CodexUsageDock/CodexUsageService.AccountActivity.cs create mode 100644 CodexUsageDock/Pages/CodexAccountActivityPage.cs create mode 100644 CodexUsageDock/UsageAlerts.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b7db9..fc85c05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Each entry links to the commit or pull request that introduced the change. ### Added +- Optional quiet usage alerts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. +- Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. +- Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. - Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/AccountUsageTests.cs b/CodexUsageDock.Tests/AccountUsageTests.cs new file mode 100644 index 0000000..a38bc21 --- /dev/null +++ b/CodexUsageDock.Tests/AccountUsageTests.cs @@ -0,0 +1,275 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class AccountUsageTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void ParsesServerCalendarDaysAndNullableSummaryWithoutInventingValues() + { + var result = Parse(""" + { + "summary": { + "lifetimeTokens": 123456, + "peakDailyTokens": 0, + "longestRunningTurnSec": 3600, + "currentStreakDays": null, + "longestStreakDays": 12 + }, + "dailyUsageBuckets": [ + { "startDate": "2026-09-09", "tokens": 2000 }, + { "startDate": "2026-09-07", "tokens": 0 } + ] + } + """); + + Assert.Equal(AccountUsageStatus.Available, result.Status); + Assert.Equal("synthetic-hash", result.AccountKey); + Assert.Equal(Now, result.UpdatedAt); + Assert.Equal(123456, result.LifetimeTokens); + Assert.Equal(0, result.PeakDailyTokens); + Assert.Equal(3600, result.LongestRunningTurnSeconds); + Assert.Null(result.CurrentStreakDays); + Assert.Equal(12, result.LongestStreakDays); + Assert.Collection(result.Days, + day => Assert.Equal(new DailyTokenUsage(new DateOnly(2026, 9, 7), 0), day), + day => Assert.Equal(new DailyTokenUsage(new DateOnly(2026, 9, 9), 2000), day)); + } + + [Fact] + public void InvalidDatesNegativeTokensOverflowAndDuplicatesYieldPartialData() + { + var result = Parse(""" + { + "summary": { "lifetimeTokens": -1, "peakDailyTokens": 9223372036854775808, "currentStreakDays": "10" }, + "dailyUsageBuckets": [ + { "startDate": "2026-09-09", "tokens": 100 }, + { "startDate": "2026-09-09", "tokens": 200 }, + { "startDate": "2026-02-30", "tokens": 100 }, + { "startDate": "2026-9-8", "tokens": 100 }, + { "startDate": "2026-09-08T00:00:00Z", "tokens": 100 }, + { "startDate": "2026-09-08", "tokens": -1 }, + { "startDate": "2026-09-07", "tokens": 9223372036854775808 }, + { "startDate": "2026-09-06", "tokens": "100" }, + null + ] + } + """); + + Assert.Equal(AccountUsageStatus.Partial, result.Status); + Assert.Equal(100, Assert.Single(result.Days).TotalTokens); + Assert.Null(result.LifetimeTokens); + Assert.Null(result.PeakDailyTokens); + Assert.Null(result.CurrentStreakDays); + } + + [Fact] + public void RetainsAtMost366LatestServerDaysAndMarksTruncation() + { + var start = new DateOnly(2024, 1, 1); + var daily = Enumerable.Range(0, 370).Select(index => new + { + startDate = start.AddDays(index).ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture), + tokens = index, + }); + + var result = Parse(JsonSerializer.Serialize(new { dailyUsageBuckets = daily })); + + Assert.Equal(AccountUsageStatus.Partial, result.Status); + Assert.Equal(AccountUsageParser.MaximumDays, result.Days.Count); + Assert.Equal(start.AddDays(4), result.Days[0].Date); + Assert.Equal(start.AddDays(369), result.Days[^1].Date); + } + + [Theory] + [InlineData("{}")] + [InlineData("null")] + [InlineData("[]")] + [InlineData("{\"summary\":{\"lifetimeTokens\":null},\"dailyUsageBuckets\":null}")] + public void MissingDataIsUnavailableRatherThanZero(string json) + { + var result = Parse(json); + + Assert.Equal(AccountUsageStatus.Unavailable, result.Status); + Assert.Empty(result.Days); + Assert.Null(result.LifetimeTokens); + Assert.Null(result.AccountKey); + } + + [Fact] + public void SummaryWithoutDailyDataIsPartialAndConfirmedEmptyRowsRemainAvailable() + { + var summaryOnly = Parse("""{"summary":{"lifetimeTokens":0},"dailyUsageBuckets":null}"""); + var emptyDays = Parse("""{"summary":{"lifetimeTokens":null},"dailyUsageBuckets":[]}"""); + + Assert.Equal(AccountUsageStatus.Partial, summaryOnly.Status); + Assert.Equal(0, summaryOnly.LifetimeTokens); + Assert.Equal(AccountUsageStatus.Available, emptyDays.Status); + Assert.Empty(emptyDays.Days); + Assert.Null(emptyDays.LifetimeTokens); + } + + [Fact] + public async Task ActivityIsReadBetweenMatchingAccountChecks() + { + var requests = new List<(string Method, int Id)>(); + var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((method, id, _) => + { + requests.Add((method, id)); + return Task.FromResult(JsonDocument.Parse(method == "account/usage/read" + ? """{"result":{"dailyUsageBuckets":[{"startDate":"2026-09-09","tokens":100}]}}""" + : """{"result":{"accountId":"synthetic-account"}}""")); + }); + + Assert.Equal(new[] { ("account/rateLimits/read", 2), ("account/usage/read", 3), ("account/rateLimits/read", 4) }, requests); + Assert.Equal(AccountUsageStatus.Available, result.Status); + Assert.Equal(100, Assert.Single(result.Days).TotalTokens); + Assert.Equal(64, result.AccountKey!.Length); + Assert.DoesNotContain("synthetic-account", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Fact] + public async Task AccountChangeDiscardsEveryUsageValue() + { + var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((method, id, _) => + Task.FromResult(JsonDocument.Parse(id switch + { + 2 => """{"result":{"accountId":"synthetic-account-A"}}""", + 3 => """{"result":{"summary":{"lifetimeTokens":123},"dailyUsageBuckets":[{"startDate":"2026-09-09","tokens":100}]}}""", + _ => """{"result":{"accountId":"synthetic-account-B"}}""", + }))); + + Assert.Equal(AccountUsageStatus.Unavailable, result.Status); + Assert.Empty(result.Days); + Assert.Null(result.AccountKey); + Assert.Null(result.LifetimeTokens); + } + + [Fact] + public async Task MissingInitialIdentitySkipsTheAccountUsageRequest() + { + var count = 0; + var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((_, _, _) => + { + count++; + return Task.FromResult(JsonDocument.Parse("""{"result":{"accountId":null}}""")); + }); + + Assert.Equal(1, count); + Assert.Equal(AccountUsageStatus.Unavailable, result.Status); + } + + [Theory] + [InlineData("{\"result\":{\"accountId\":null}}")] + [InlineData("{\"error\":{\"code\":-1,\"message\":\"private details\"}}")] + public async Task MissingFinalIdentityDiscardsActivity(string finalResponse) + { + var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((_, id, _) => + Task.FromResult(JsonDocument.Parse(id switch + { + 2 => """{"result":{"accountId":"synthetic-account"}}""", + 3 => """{"result":{"dailyUsageBuckets":[]}}""", + _ => finalResponse, + }))); + + Assert.Equal(AccountUsageStatus.Unavailable, result.Status); + Assert.Null(result.AccountKey); + Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Fact] + public async Task MethodNotFoundIsUnsupportedWithoutReadingOrExposingErrorText() + { + var requests = 0; + var result = await CodexAppServerReader.ReadAccountUsageSequenceAsync((_, id, _) => + { + requests++; + return Task.FromResult(JsonDocument.Parse(id == 2 + ? """{"result":{"accountId":"synthetic-account"}}""" + : """{"error":{"code":-32601,"message":"private path or credential"}}""")); + }); + + Assert.Equal(AccountUsageStatus.Unsupported, result.Status); + Assert.Equal(2, requests); + Assert.Empty(result.Days); + Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Fact] + public async Task PreCanceledActivityReadDoesNotStartARequest() + { + await Assert.ThrowsAsync(() => + CodexAppServerReader.ReadAccountUsageSequenceAsync((_, _, _) => throw new InvalidOperationException("Must not run"), + new CancellationToken(canceled: true))); + } + + [Fact] + public async Task ResponseReaderIgnoresUnrelatedMalformedIdentifiers() + { + using var reader = new StringReader(""" + [] + {"id":"2","result":{}} + {"id":2,"result":{}} + """); + + var responses = await CodexAppServerReader.ReadResponsesAsync(reader, CancellationToken.None, 2); + using var response = responses[2]; + Assert.Equal(2, response.RootElement.GetProperty("id").GetInt32()); + } + + [Fact] + public async Task ResponseReaderRejectsOversizedMessagesWithABoundedError() + { + using var reader = new StringReader(new string('x', 1_048_577)); + + var error = await Assert.ThrowsAsync(() => + CodexAppServerReader.ReadResponsesAsync(reader, CancellationToken.None, 2)); + + Assert.Equal("Codex app-server returned an oversized response.", error.Message); + } + + [Fact] + public void ConfiguredSourceUsesProcessArgumentsAndAnEnvironmentVariable() + { + var executable = Path.Combine(Path.GetTempPath(), "synthetic cli", "codex.exe"); + var home = Path.Combine(Path.GetTempPath(), "synthetic profile & account"); + + var info = CodexAppServerReader.CreateStartInfo(new CodexSourceOptions(executable, home)); + + Assert.Equal(executable, info.FileName); + Assert.Equal(new[] { "app-server", "--stdio" }, info.ArgumentList); + Assert.Equal(home, info.Environment["CODEX_HOME"]); + Assert.Equal(Path.GetDirectoryName(executable), info.WorkingDirectory); + Assert.False(info.UseShellExecute); + Assert.True(info.CreateNoWindow); + Assert.Empty(info.Arguments); + } + + [Fact] + public void ActivityPageShowsAtMost30ServerDatesAndNeverAccountIdentifiers() + { + var start = new DateOnly(2026, 8, 1); + var snapshot = new AccountUsageSnapshot("sensitive-account-key", Now, + Enumerable.Range(0, 40).Select(index => new DailyTokenUsage(start.AddDays(index), index)).ToArray(), + AccountUsageStatus.Partial, LifetimeTokens: 0); + + var text = CodexAccountActivityPage.FormatActivity(snapshot); + + Assert.Contains("Partial data", text, StringComparison.Ordinal); + Assert.Contains("Their time zone is not specified", text, StringComparison.Ordinal); + Assert.Contains("**Lifetime tokens:** 0", text, StringComparison.Ordinal); + Assert.Contains("**Peak daily tokens:** Not reported", text, StringComparison.Ordinal); + Assert.Equal(30, text.Split('\n').Count(line => line.StartsWith("| 2026-", StringComparison.Ordinal))); + Assert.DoesNotContain("sensitive-account-key", text, StringComparison.Ordinal); + Assert.DoesNotContain("| 2026-08-01", text, StringComparison.Ordinal); + } + + private static AccountUsageSnapshot Parse(string json) + { + using var document = JsonDocument.Parse(json); + return AccountUsageParser.Parse(document.RootElement, "synthetic-hash", Now); + } +} diff --git a/CodexUsageDock.Tests/ProviderDockTests.cs b/CodexUsageDock.Tests/ProviderDockTests.cs new file mode 100644 index 0000000..6fdea69 --- /dev/null +++ b/CodexUsageDock.Tests/ProviderDockTests.cs @@ -0,0 +1,60 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class ProviderDockTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + public void Dispose() => _environment.Dispose(); + + [Fact] + public async Task TogglingAlertsOffAndOnWithoutARefreshEstablishesANewBaseline() + { + File.WriteAllText(_environment.PathFor("settings.json"), """{"enableUsageAlerts":"true"}"""); + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var reset = now.AddHours(4); + var remaining = 40.0; + CodexUsageSnapshot Snapshot() => new(new(100 - remaining, 300, reset), null, null, null, null, + now, UsageDataSource.AppServer, null, AccountKey: "a", DefaultBucketId: "codex"); + using var service = _environment.CreateService(_ => Task.FromResult(Snapshot()), Snapshot, clock: () => now); + var settings = _environment.CreateSettings(); + var messages = new List(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, messages.Add, () => now); + await service.RefreshAsync(); + settings.GetContent().OfType().Last().SubmitForm("""{"enableUsageAlerts":"false"}""", "{}"); + settings.GetContent().OfType().Last().SubmitForm("""{"enableUsageAlerts":"true"}""", "{}"); + remaining = 5; + now = now.AddMinutes(1); + await service.RefreshAsync(); + Assert.Empty(messages); + remaining = 30; + now = now.AddMinutes(1); + await service.RefreshAsync(); + remaining = 8; + now = now.AddMinutes(1); + await service.RefreshAsync(); + Assert.Single(messages); + } + + [Fact] + public void SeparateDockBandsHaveStableRestorableIdentities() + { + File.WriteAllText(_environment.PathFor("settings.json"), """{"separateDockItems":"true"}"""); + using var service = _environment.CreateService(); + using var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings(), _ => { }); + var bands = provider.GetDockBands()!; + Assert.Equal(3, bands.Length); + Assert.Equal(3, bands.Select(item => item.Command.Id).Distinct(StringComparer.Ordinal).Count()); + foreach (var band in bands) + { + Assert.Single(Assert.IsAssignableFrom(band.Command).GetItems()); + Assert.Equal(band.Command.Id, provider.GetCommandItem(band.Command.Id)!.Command.Id); + } + Assert.Null(provider.GetCommandItem("unknown")); + Assert.Null(provider.GetCommandItem(string.Empty)); + var combined = provider.GetCommandItem("nl.mathijs.codexusage.dock"); + Assert.Equal(3, Assert.IsAssignableFrom(combined!.Command).GetItems().Length); + } +} diff --git a/CodexUsageDock.Tests/SourceAndActivityServiceTests.cs b/CodexUsageDock.Tests/SourceAndActivityServiceTests.cs new file mode 100644 index 0000000..636e2d6 --- /dev/null +++ b/CodexUsageDock.Tests/SourceAndActivityServiceTests.cs @@ -0,0 +1,121 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class SourceAndActivityServiceTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + public void Dispose() => _environment.Dispose(); + + private static CodexUsageSnapshot Quota(string account) => new(null, new(20, 10080, Now.AddDays(3)), + null, null, null, Now, UsageDataSource.AppServer, null, AccountKey: account, DefaultBucketId: "codex"); + + [Fact] + public void SourcePathsAreExplicitAndErrorsDoNotExposeTheirValues() + { + var home = _environment.PathFor("profile"); + Directory.CreateDirectory(home); + var executable = _environment.PathFor("codex.exe"); + File.WriteAllText(executable, "synthetic fixture; never executed"); + Assert.True(CodexSourceOptions.TryCreate(executable, home, out var options, out var error)); + Assert.Null(error); + Assert.Equal(executable, options.ExecutablePath); + Assert.Equal(home, options.HomePath); + Assert.False(CodexSourceOptions.TryCreate("private-relative-path", null, out _, out error)); + Assert.DoesNotContain("private-relative-path", error!, StringComparison.Ordinal); + Assert.False(CodexSourceOptions.TryCreate(executable + " --argument", home, out _, out _)); + } + + [Fact] + public async Task InvalidSourceConfigurationFailsClosedWithoutReadingAnotherProfile() + { + var calls = 0; + using var service = _environment.CreateService(_ => { calls++; return Task.FromResult(Quota("a")); }, + () => { calls++; return Quota("b"); }, clock: () => Now); + service.ConfigureSource(CodexSourceOptions.Default, "Invalid source configuration."); + await service.RefreshAsync(); + Assert.Equal(0, calls); + Assert.Equal(UsageDataSource.Unavailable, service.Current.Source); + } + + [Fact] + public async Task SlowAccountActivityDoesNotBlockQuotasOrPublishForAnotherAccount() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var snapshot = Quota("a"); + using var service = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot, + clock: () => Now, accountUsageReader: _ => pending.Task); + await service.RefreshAsync(); + var activityTask = service.AccountUsageRefreshTask; + Assert.Equal("a", service.Current.AccountKey); + Assert.False(activityTask.IsCompleted); + snapshot = Quota("b"); + await service.RefreshAsync(); + pending.SetResult(new("a", Now, [new(DateOnly.FromDateTime(Now.Date), 123)], AccountUsageStatus.Available)); + await activityTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(AccountUsageStatus.Unavailable, service.CurrentAccountUsage.Status); + Assert.Equal("b", service.Current.AccountKey); + } + + [Fact] + public async Task AccountUsageCanBeDisabledAndUnsupportedReadsAreThrottled() + { + var now = Now; + var calls = 0; + using var service = _environment.CreateService(_ => Task.FromResult(Quota("a")), () => Quota("a"), + clock: () => now, accountUsageReader: _ => + { + calls++; + return Task.FromResult(AccountUsageSnapshot.Unavailable with { Status = AccountUsageStatus.Unsupported }); + }); + service.SetAccountActivityEnabled(false); + await service.RefreshAsync(); + Assert.Equal(0, calls); + service.SetAccountActivityEnabled(true); + await service.RefreshAsync(); + await service.AccountUsageRefreshTask.WaitAsync(TimeSpan.FromSeconds(10)); + now = Now.AddMinutes(6); + await service.RefreshAsync(); + Assert.Equal(1, calls); + now = Now.AddMinutes(31); + await service.RefreshAsync(); + await service.AccountUsageRefreshTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(2, calls); + } + + [Fact] + public async Task DisablingActivityAndSwitchingSourcePublishClearedStateImmediately() + { + using var service = _environment.CreateService(_ => Task.FromResult(Quota("a")), () => Quota("a"), + clock: () => Now, accountUsageReader: _ => Task.FromResult(new AccountUsageSnapshot("a", Now, [], AccountUsageStatus.Available))); + await service.RefreshAsync(); + await service.AccountUsageRefreshTask.WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal(AccountUsageStatus.Available, service.CurrentAccountUsage.Status); + var updates = 0; + service.Updated += (_, _) => updates++; + service.SetAccountActivityEnabled(false); + Assert.Equal(1, updates); + Assert.Equal(AccountUsageStatus.Unavailable, service.CurrentAccountUsage.Status); + service.ConfigureSource(new(HomePath: _environment.PathFor("new-profile"))); + Assert.Equal(2, updates); + Assert.Equal(UsageDataSource.Initializing, service.Current.Source); + Assert.Empty(service.WeeklyHistory); + } + + [Fact] + public async Task SourceSwitchDiscardsTheOldInFlightQuotaResponse() + { + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + using var service = _environment.CreateService(_ => ++calls == 1 ? pending.Task : Task.FromResult(Quota("b")), + () => Quota("fallback"), clock: () => Now); + var first = service.RefreshAsync(); + service.ConfigureSource(new(HomePath: _environment.PathFor("profile"))); + pending.SetResult(Quota("a")); + await first.WaitAsync(TimeSpan.FromSeconds(10)); + await service.RefreshAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Assert.Equal("b", service.Current.AccountKey); + Assert.Equal(80, Assert.Single(service.WeeklyHistory).RemainingPercent); + } +} diff --git a/CodexUsageDock.Tests/TestEnvironment.cs b/CodexUsageDock.Tests/TestEnvironment.cs index 5ab4830..dd0d596 100644 --- a/CodexUsageDock.Tests/TestEnvironment.cs +++ b/CodexUsageDock.Tests/TestEnvironment.cs @@ -21,11 +21,12 @@ internal CodexUsageService CreateService( WeeklyUsageHistoryStore? weeklyHistoryStore = null, AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, Func>? localTokenUsageReader = null, - Func? clock = null) => + Func? clock = null, + Func>? accountUsageReader = null) => new(appServerReader, localSessionReader, weeklyHistoryStore ?? new WeeklyUsageHistoryStore(PathFor("weekly.json")), adaptiveWeeklyUsageStore ?? new AdaptiveWeeklyUsageStore(PathFor("adaptive.json")), - localTokenUsageReader, clock); + localTokenUsageReader, clock, accountUsageReader); internal CodexUsageService CreateService( Func> appServerReader, @@ -33,8 +34,9 @@ internal CodexUsageService CreateService( WeeklyUsageHistoryStore? weeklyHistoryStore = null, AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, Func>? localTokenUsageReader = null, - Func? clock = null) => - CreateService(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader, clock); + Func? clock = null, + Func>? accountUsageReader = null) => + CreateService(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader, clock, accountUsageReader); public void Dispose() { diff --git a/CodexUsageDock.Tests/UsageAlertTests.cs b/CodexUsageDock.Tests/UsageAlertTests.cs new file mode 100644 index 0000000..554e9ea --- /dev/null +++ b/CodexUsageDock.Tests/UsageAlertTests.cs @@ -0,0 +1,363 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageAlertTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); + private static readonly UsageAlertOptions EnabledOptions = new(true); + + [Fact] + public void EnablingUsesTheFirstFreshMeasurementAsBaselineThenAlertsOnCrossing() + { + var evaluator = new UsageAlertEvaluator(); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50))); + + var crossing = Evaluate(evaluator, Presentation(primaryRemaining: 9)); + var alert = Assert.Single(crossing); + Assert.StartsWith("low:", alert.Key, StringComparison.Ordinal); + Assert.Contains("5-hour window", alert.Message, StringComparison.Ordinal); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 9))); + } + + [Fact] + public void LowAlertRearmsOnlyAfterARecoveryAboveThresholdPlusFive() + { + var evaluator = new UsageAlertEvaluator(); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 12))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 15))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 16))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9))); + } + + [Fact] + public void ResetTimestampJitterWithinOneMinuteStaysInTheSameCycle() + { + var evaluator = new UsageAlertEvaluator(); + var reset = Now.AddHours(4); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, primaryReset: reset))); + Assert.Single(Evaluate(evaluator, Presentation( + primaryRemaining: 9, + primaryReset: reset.AddSeconds(5)))); + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: 9, + primaryReset: reset.AddSeconds(10)))); + } + + [Fact] + public void NewCycleAndAccountSwitchStartCleanBaselines() + { + var evaluator = new UsageAlertEvaluator(); + var firstReset = Now.AddHours(4); + var nextReset = firstReset.AddMinutes(2); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, primaryReset: firstReset))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9, primaryReset: firstReset))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 9, primaryReset: nextReset))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, primaryReset: nextReset))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9, primaryReset: nextReset))); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, accountKey: "account-a"))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9, accountKey: "account-a"))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 9, accountKey: "account-b"))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, accountKey: "account-b"))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9, accountKey: "account-b"))); + } + + [Fact] + public void CategorySwitchStartsACleanBaseline() + { + var evaluator = new UsageAlertEvaluator(); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, defaultBucketId: "codex"))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9, defaultBucketId: "codex"))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 9, defaultBucketId: "review"))); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50, defaultBucketId: "review"))); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9, defaultBucketId: "review"))); + } + + [Fact] + public void IneligibleSnapshotsNeverProduceAlerts() + { + var cases = new[] + { + Presentation(primaryRemaining: 9, updatedAt: Now.AddMinutes(-6)), + Presentation(primaryRemaining: 9, updatedAt: Now.AddMinutes(1)), + Presentation(primaryRemaining: 9, source: UsageDataSource.LastConfirmed), + Presentation(primaryRemaining: 9, source: UsageDataSource.Unavailable), + Presentation(primaryRemaining: 9, isLoading: true), + Presentation(primaryRemaining: 9, accountKey: null), + }; + + foreach (var presentation in cases) + { + Assert.Empty(Evaluate(new UsageAlertEvaluator(), presentation)); + } + } + + [Fact] + public void ExpiredOrInvalidWindowsAreIgnored() + { + var expired = Presentation(primaryRemaining: 9, primaryReset: Now.AddMinutes(-1)); + var invalid = Presentation(primaryRemaining: 9) with + { + Usage = Presentation(primaryRemaining: null).Usage with + { + Primary = new RateLimitWindow(double.NaN, 300, Now.AddHours(4)), + }, + }; + + Assert.Empty(Evaluate(new UsageAlertEvaluator(), expired)); + Assert.Empty(Evaluate(new UsageAlertEvaluator(), invalid)); + } + + [Fact] + public void AdditionalCategoriesStaySeparateAndLabelsAreSanitized() + { + var longName = new string('x', 100) + "\nraw account label"; + var highBuckets = new[] + { + new RateLimitBucket("coding", longName, new RateLimitWindow(50, 60, Now.AddHours(1)), null), + new RateLimitBucket("review", "Review", new RateLimitWindow(50, 60, Now.AddHours(1)), null), + }; + var lowBuckets = new[] + { + new RateLimitBucket("coding", longName, new RateLimitWindow(95, 60, Now.AddHours(1)), null), + new RateLimitBucket("review", "Review", new RateLimitWindow(95, 60, Now.AddHours(1)), null), + }; + var evaluator = new UsageAlertEvaluator(); + + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + buckets: highBuckets))); + var alerts = Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + buckets: lowBuckets)); + + Assert.Equal(2, alerts.Count); + Assert.Contains(alerts, alert => alert.Message.Contains("Review", StringComparison.Ordinal)); + Assert.Contains(alerts, alert => alert.Message.Contains(new string('x', 70), StringComparison.Ordinal)); + Assert.DoesNotContain(new string('x', 71), alerts[0].Message, StringComparison.Ordinal); + Assert.DoesNotContain("raw account label", alerts[1].Message, StringComparison.Ordinal); + Assert.DoesNotContain("account-a", string.Join(" ", alerts.Select(alert => alert.Message)), StringComparison.Ordinal); + } + + [Fact] + public void DefaultBucketExtraDurationIsTrackedSeparately() + { + var evaluator = new UsageAlertEvaluator(); + var high = new[] + { + new RateLimitBucket( + "codex", + "Default", + new RateLimitWindow(50, 60, Now.AddHours(1)), + null), + }; + var low = new[] + { + new RateLimitBucket( + "codex", + "Default", + new RateLimitWindow(95, 60, Now.AddHours(1)), + null), + }; + + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: 50, + secondaryRemaining: null, + buckets: high, + defaultBucketId: "codex"))); + var alerts = Evaluate(evaluator, Presentation( + primaryRemaining: 50, + secondaryRemaining: null, + buckets: low, + defaultBucketId: "codex")); + + var alert = Assert.Single(alerts); + Assert.Contains("1-hour window", alert.Message, StringComparison.Ordinal); + } + + [Fact] + public void WindowTrackingIsBounded() + { + var highBuckets = Enumerable.Range(0, 40) + .Select(index => new RateLimitBucket( + $"bucket-{index}", + $"Bucket {index}", + new RateLimitWindow(50, 60, Now.AddHours(1)), + null)) + .ToArray(); + var lowBuckets = highBuckets + .Select(bucket => bucket with { Primary = bucket.Primary! with { UsedPercent = 95 } }) + .ToArray(); + var evaluator = new UsageAlertEvaluator(); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: null, secondaryRemaining: null, buckets: highBuckets))); + var alerts = Evaluate(evaluator, Presentation(primaryRemaining: null, secondaryRemaining: null, buckets: lowBuckets)); + + Assert.Equal(32, alerts.Count); + } + + [Fact] + public void DisablingOrChangingOptionsClearsTheBaseline() + { + var evaluator = new UsageAlertEvaluator(); + var disabled = new UsageAlertOptions(false); + var changed = new UsageAlertOptions(true, LowRemainingPercent: 20); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50), options: EnabledOptions)); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 9), options: disabled)); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 9), options: EnabledOptions)); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 50), options: EnabledOptions)); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 9), options: EnabledOptions)); + + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 19), options: changed)); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 10), options: changed)); + Assert.Empty(Evaluate(evaluator, Presentation(primaryRemaining: 30), options: changed)); + Assert.Single(Evaluate(evaluator, Presentation(primaryRemaining: 19), options: changed)); + } + + [Fact] + public void ResetExpiryWarnsOnEntryWithinTwentyFourHoursAndDeduplicatesByExpiry() + { + var evaluator = new UsageAlertEvaluator(); + var expiry = Now.AddHours(25); + var outside = new RateLimitResetCredits(1, [new RateLimitResetCredit("First title", null, expiry)]); + var inside = new RateLimitResetCredits(1, [new RateLimitResetCredit("Second title", "available", expiry)]); + + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + resetCredits: outside), now: Now)); + var transition = Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + resetCredits: inside, + updatedAt: Now), now: Now.AddHours(2)); + Assert.Single(transition); + Assert.StartsWith("reset-expiry:", transition[0].Key, StringComparison.Ordinal); + Assert.DoesNotContain("First title", transition[0].Message, StringComparison.Ordinal); + Assert.DoesNotContain("Second title", transition[0].Message, StringComparison.Ordinal); + + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + resetCredits: new RateLimitResetCredits(1, [new RateLimitResetCredit("Changed title", "available", expiry)]), + updatedAt: Now), now: Now.AddHours(2))); + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + resetCredits: new RateLimitResetCredits(1, [new RateLimitResetCredit("Used", "used", expiry)]), + updatedAt: Now), now: Now.AddHours(2))); + } + + [Fact] + public void ForecastWarningUsesMatchingFreshHistoryAndBaselinesPredictiveRisk() + { + var evaluator = new UsageAlertEvaluator(); + var reset = Now.AddHours(4); + UsageHistoryEntry[] firstHistory = + [ + new(Now, 40), + ]; + UsageHistoryEntry[] secondHistory = + [ + new(Now.AddMinutes(-10), 100), + new(Now.AddMinutes(1), 35), + ]; + + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: 40, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: firstHistory), now: Now)); + var warning = Evaluate(evaluator, Presentation( + primaryRemaining: 35, + secondaryRemaining: null, + primaryReset: reset, + updatedAt: Now.AddMinutes(1), + primaryHistory: secondHistory), now: Now.AddMinutes(1)); + + var alert = Assert.Single(warning); + Assert.StartsWith("forecast:", alert.Key, StringComparison.Ordinal); + Assert.Contains("forecast may reach", alert.Message, StringComparison.Ordinal); + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: 35, + secondaryRemaining: null, + primaryReset: reset, + updatedAt: Now.AddMinutes(1), + primaryHistory: secondHistory), now: Now.AddMinutes(1))); + + var predictiveBaselineEvaluator = new UsageAlertEvaluator(); + Assert.Empty(Evaluate(predictiveBaselineEvaluator, Presentation( + primaryRemaining: 40, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: secondHistory), now: Now.AddMinutes(1))); + Assert.Empty(Evaluate(predictiveBaselineEvaluator, Presentation( + primaryRemaining: 40, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: secondHistory), now: Now.AddMinutes(1))); + } + + private static IReadOnlyList Evaluate( + UsageAlertEvaluator evaluator, + UsagePresentation presentation, + DateTimeOffset? now = null, + UsageAlertOptions? options = null) => + evaluator.Evaluate(presentation, now ?? Now, RefreshInterval, options ?? EnabledOptions); + + private static UsagePresentation Presentation( + double? primaryRemaining = 80, + double? secondaryRemaining = 80, + int primaryMinutes = 300, + int secondaryMinutes = 10080, + DateTimeOffset? primaryReset = null, + DateTimeOffset? secondaryReset = null, + DateTimeOffset? updatedAt = null, + UsageDataSource source = UsageDataSource.AppServer, + string? accountKey = "account-a", + bool isLoading = false, + string? defaultBucketId = "codex", + IReadOnlyList? buckets = null, + RateLimitResetCredits? resetCredits = null, + IReadOnlyList? primaryHistory = null, + IReadOnlyList? weeklyHistory = null) + { + var primary = primaryRemaining is { } primaryValue + ? new RateLimitWindow(100 - primaryValue, primaryMinutes, primaryReset ?? Now.AddHours(4)) + : null; + var secondary = secondaryRemaining is { } secondaryValue + ? new RateLimitWindow(100 - secondaryValue, secondaryMinutes, secondaryReset ?? Now.AddDays(6)) + : null; + var snapshot = new CodexUsageSnapshot( + primary, + secondary, + "pro", + null, + resetCredits, + updatedAt ?? Now, + source, + null, + Buckets: buckets, + AccountKey: accountKey, + DefaultBucketId: defaultBucketId); + return new UsagePresentation( + snapshot, + primaryHistory ?? Array.Empty(), + weeklyHistory ?? Array.Empty(), + new AdaptiveWeeklyUsageHistory([], null), + LocalTokenUsageSnapshot.Unavailable, + isLoading); + } +} diff --git a/CodexUsageDock.Tests/UsagePreferenceTests.cs b/CodexUsageDock.Tests/UsagePreferenceTests.cs new file mode 100644 index 0000000..214a71c --- /dev/null +++ b/CodexUsageDock.Tests/UsagePreferenceTests.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsagePreferenceTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void NewPreferencesUseSafeDefaults() + { + var settings = _environment.CreateSettings(); + + Assert.False(settings.EnableUsageAlerts); + Assert.False(settings.CompactDock); + Assert.False(settings.SeparateDockItems); + Assert.True(settings.ShowAccountActivity); + Assert.Equal(string.Empty, settings.CodexExecutablePath); + Assert.Equal(string.Empty, settings.CodexHomePath); + } + + [Fact] + public void UsagePreferencesPersistAcrossNewPages() + { + var first = _environment.CreateSettings(); + SubmitSettings(first, new Dictionary + { + ["enableUsageAlerts"] = "true", + ["compactDock"] = "true", + ["separateDockItems"] = "true", + ["showAccountActivity"] = "false", + ["codexExecutablePath"] = "C:/Tools/codex.cmd", + ["codexHomePath"] = "C:/Users/test/.codex", + }); + + Assert.True(first.EnableUsageAlerts); + Assert.True(first.CompactDock); + Assert.True(first.SeparateDockItems); + Assert.False(first.ShowAccountActivity); + Assert.Equal("C:/Tools/codex.cmd", first.CodexExecutablePath); + Assert.Equal("C:/Users/test/.codex", first.CodexHomePath); + + var restarted = _environment.CreateSettings(); + + Assert.True(restarted.EnableUsageAlerts); + Assert.True(restarted.CompactDock); + Assert.True(restarted.SeparateDockItems); + Assert.False(restarted.ShowAccountActivity); + Assert.Equal("C:/Tools/codex.cmd", restarted.CodexExecutablePath); + Assert.Equal("C:/Users/test/.codex", restarted.CodexHomePath); + } + + [Fact] + public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() + { + var validBoundaryPath = new string('a', 1024); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + ["enableUsageAlerts"] = "true", + ["compactDock"] = "true", + ["separateDockItems"] = "not-a-boolean", + ["showAccountActivity"] = "false", + ["codexExecutablePath"] = validBoundaryPath, + ["codexHomePath"] = new string('b', 1025), + })); + + var settings = _environment.CreateSettings(); + + Assert.True(settings.EnableUsageAlerts); + Assert.True(settings.CompactDock); + Assert.False(settings.SeparateDockItems); + Assert.False(settings.ShowAccountActivity); + Assert.Equal(validBoundaryPath, settings.CodexExecutablePath); + Assert.Contains("Invalid source path", settings.CodexHomePath, StringComparison.Ordinal); + + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + ["codexExecutablePath"] = "C:/Codex\u0001/codex.exe", + ["codexHomePath"] = "C:/Users/test/.codex", + })); + + var controlCharacterSettings = _environment.CreateSettings(); + + Assert.Contains("Invalid source path", controlCharacterSettings.CodexExecutablePath, StringComparison.Ordinal); + Assert.Equal("C:/Users/test/.codex", controlCharacterSettings.CodexHomePath); + } + + [Theory] + [InlineData("FiveHour", 47, false, "5h 47%")] + [InlineData("Weekly", 86, false, "Week 86%")] + [InlineData("FiveHour", 47, true, "5h47%")] + [InlineData("Weekly", 86, true, "W86%")] + public void FormatQuotaTitleSupportsFullAndCompactModes( + string kindName, + double remainingPercent, + bool compact, + string expected) + { + var kind = Enum.Parse(kindName); + Assert.Equal(expected, UsageDockItem.FormatQuotaTitle(kind, remainingPercent, compact)); + } + + [Fact] + public async Task NonCompactModeKeepsResetTimeAndFreshnessWarning() + { + var now = DateTimeOffset.Now; + var snapshot = new CodexUsageSnapshot( + new RateLimitWindow(53, 300, now.AddHours(4)), + null, + "pro", + null, + null, + now.AddMinutes(-10), + UsageDataSource.AppServer, + null); + + using var service = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot); + var settings = _environment.CreateSettings(); + using var details = new CodexUsageDockPage(service, settings); + using var item = new UsageDockItem(service, UsageDockItemKind.FiveHour, details, settings); + + await service.RefreshAsync(); + + Assert.Equal("5h 47%", item.Title); + Assert.Contains("Stale", item.Subtitle, StringComparison.Ordinal); + Assert.Contains("reset", item.Subtitle, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CompactModeUsesShortTitleAndOmitsResetTimeButKeepsFreshnessWarning() + { + var now = DateTimeOffset.Now; + var snapshot = new CodexUsageSnapshot( + new RateLimitWindow(53, 300, now.AddHours(4)), + null, + "pro", + null, + null, + now.AddMinutes(-10), + UsageDataSource.AppServer, + null); + + using var service = _environment.CreateService(_ => Task.FromResult(snapshot), () => snapshot); + var settings = _environment.CreateSettings(); + SubmitSettings(settings, new Dictionary { ["compactDock"] = "true" }); + using var details = new CodexUsageDockPage(service, settings); + using var item = new UsageDockItem(service, UsageDockItemKind.FiveHour, details, settings); + + await service.RefreshAsync(); + + Assert.Equal("5h47%", item.Title); + Assert.Contains("Stale", item.Subtitle, StringComparison.Ordinal); + Assert.DoesNotContain("reset", item.Subtitle, StringComparison.OrdinalIgnoreCase); + } + + private static void SubmitSettings(CodexUsageDockSettingsPage page, IReadOnlyDictionary values) + { + var payload = new Dictionary + { + ["showFiveHourLimit"] = "true", + ["showWeeklyLimit"] = "true", + ["showResetsAndCredits"] = "true", + ["showResetTime"] = "true", + ["useAdaptiveWeeklyForecast"] = "true", + ["refreshInterval"] = "1", + }; + + foreach (var pair in values) + { + payload[pair.Key] = pair.Value; + } + + var form = page.GetContent().OfType().Last(); + form.SubmitForm(JsonSerializer.Serialize(payload), "{}"); + } +} diff --git a/CodexUsageDock/AccountUsageData.cs b/CodexUsageDock/AccountUsageData.cs new file mode 100644 index 0000000..fdd9913 --- /dev/null +++ b/CodexUsageDock/AccountUsageData.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum AccountUsageStatus +{ + Available, + Partial, + Unsupported, + Unavailable, +} + +internal sealed record AccountUsageSnapshot( + string? AccountKey, + DateTimeOffset UpdatedAt, + IReadOnlyList Days, + AccountUsageStatus Status, + long? LifetimeTokens = null, + long? PeakDailyTokens = null, + long? LongestRunningTurnSeconds = null, + long? CurrentStreakDays = null, + long? LongestStreakDays = null) +{ + internal static AccountUsageSnapshot Unavailable { get; } = new(null, DateTimeOffset.MinValue, [], AccountUsageStatus.Unavailable); +} + +internal static class AccountUsageParser +{ + internal const int MaximumDays = 366; + private const int MaximumInputDays = 4096; + + internal static AccountUsageSnapshot Parse(JsonElement result, string accountKey, DateTimeOffset now) + { + if (result.ValueKind != JsonValueKind.Object || string.IsNullOrWhiteSpace(accountKey)) + { + return AccountUsageSnapshot.Unavailable with { UpdatedAt = now }; + } + + var partial = false; + var daysAvailable = false; + var days = new SortedDictionary(); + if (result.TryGetProperty("dailyUsageBuckets", out var daily)) + { + if (daily.ValueKind == JsonValueKind.Array) + { + daysAvailable = true; + var inputCount = 0; + foreach (var entry in daily.EnumerateArray()) + { + if (++inputCount > MaximumInputDays) + { + partial = true; + break; + } + + if (entry.ValueKind != JsonValueKind.Object + || !entry.TryGetProperty("startDate", out var dateValue) + || dateValue.ValueKind != JsonValueKind.String + || !DateOnly.TryParseExact(dateValue.GetString(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) + || !entry.TryGetProperty("tokens", out var tokensValue) + || tokensValue.ValueKind != JsonValueKind.Number + || !tokensValue.TryGetInt64(out var tokens) + || tokens < 0 + || !days.TryAdd(date, tokens)) + { + partial = true; + continue; + } + + if (days.Count > MaximumDays) + { + days.Remove(days.First().Key); + partial = true; + } + } + } + else if (daily.ValueKind != JsonValueKind.Null) + { + partial = true; + } + } + + var summary = default(JsonElement); + if (result.TryGetProperty("summary", out var summaryValue)) + { + if (summaryValue.ValueKind == JsonValueKind.Object) + { + summary = summaryValue; + } + else if (summaryValue.ValueKind != JsonValueKind.Null) + { + partial = true; + } + } + + var lifetime = ReadNonnegative(summary, "lifetimeTokens", ref partial); + var peak = ReadNonnegative(summary, "peakDailyTokens", ref partial); + var longest = ReadNonnegative(summary, "longestRunningTurnSec", ref partial); + var currentStreak = ReadNonnegative(summary, "currentStreakDays", ref partial); + var longestStreak = ReadNonnegative(summary, "longestStreakDays", ref partial); + if (!daysAvailable && lifetime is null && peak is null && longest is null && currentStreak is null && longestStreak is null) + { + return AccountUsageSnapshot.Unavailable with { UpdatedAt = now }; + } + + return new AccountUsageSnapshot(accountKey, now, + days.Select(day => new DailyTokenUsage(day.Key, day.Value)).ToArray(), + partial || !daysAvailable ? AccountUsageStatus.Partial : AccountUsageStatus.Available, + lifetime, peak, longest, currentStreak, longestStreak); + } + + private static long? ReadNonnegative(JsonElement summary, string name, ref bool partial) + { + if (summary.ValueKind != JsonValueKind.Object || !summary.TryGetProperty(name, out var value) + || value.ValueKind == JsonValueKind.Null) + { + return null; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number) && number >= 0) + { + return number; + } + + partial = true; + return null; + } +} diff --git a/CodexUsageDock/CodexAppServerReader.cs b/CodexUsageDock/CodexAppServerReader.cs index 7918867..fd2c5d8 100644 --- a/CodexUsageDock/CodexAppServerReader.cs +++ b/CodexUsageDock/CodexAppServerReader.cs @@ -10,10 +10,117 @@ internal static class CodexAppServerReader { private const int MaximumBucketCount = 32; - internal static async Task ReadAsync(CancellationToken cancellationToken = default) + internal static Task ReadAsync(CancellationToken cancellationToken = default) => + ReadAsync(CodexSourceOptions.Default, cancellationToken); + + internal static Task ReadAsync(CodexSourceOptions options, CancellationToken cancellationToken) => + WithAppServerAsync(options, static async (process, token) => + { + await SendAsync(process, "account/rateLimits/read", 2, null, token).ConfigureAwait(false); + await SendAsync( + process, + "account/read", + 3, + static writer => + { + writer.WritePropertyName("params"); + writer.WriteStartObject(); + writer.WriteBoolean("refreshToken", false); + writer.WriteEndObject(); + }, + token).ConfigureAwait(false); + + var responses = await ReadResponsesAsync(process.StandardOutput, token, 2, 3).ConfigureAwait(false); + using var rateResponse = responses[2]; + using var accountResponse = responses[3]; + ThrowIfError(rateResponse.RootElement); + return ParseSnapshot(rateResponse.RootElement.GetProperty("result"), accountResponse.RootElement, DateTimeOffset.Now); + }, cancellationToken); + + internal static async Task ReadAccountUsageAsync(CodexSourceOptions options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return await WithAppServerAsync(options, static (process, token) => + ReadAccountUsageSequenceAsync((method, id, requestToken) => RequestAsync(process, method, id, requestToken), token), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) when (error is IOException or InvalidOperationException or JsonException + or OperationCanceledException or System.ComponentModel.Win32Exception or UnauthorizedAccessException) + { + return AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + } + } + + internal static async Task ReadAccountUsageSequenceAsync( + Func> request, + CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - using var process = StartAppServer(); + using var before = await request("account/rateLimits/read", 2, cancellationToken).ConfigureAwait(false); + var beforeKey = GetResponseAccountKey(before.RootElement); + if (beforeKey is null) + { + return AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + } + + using var usage = await request("account/usage/read", 3, cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(usage.RootElement)) + { + return new AccountUsageSnapshot(beforeKey, DateTimeOffset.Now, [], AccountUsageStatus.Unsupported); + } + + if (HasError(usage.RootElement)) + { + return AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + } + + using var after = await request("account/rateLimits/read", 4, cancellationToken).ConfigureAwait(false); + var afterKey = GetResponseAccountKey(after.RootElement); + if (!string.Equals(beforeKey, afterKey, StringComparison.Ordinal)) + { + return AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + } + + return TryGetObject(usage.RootElement, "result", out var result) + ? AccountUsageParser.Parse(result, beforeKey, DateTimeOffset.Now) + : AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; + } + + private static string? GetResponseAccountKey(JsonElement response) => + !HasError(response) && TryGetObject(response, "result", out var result) ? ParseAccountKey(result) : null; + + internal static bool IsMethodNotFound(JsonElement response) => + TryGetObject(response, "error", out var error) + && error.TryGetProperty("code", out var code) + && code.ValueKind == JsonValueKind.Number + && code.TryGetInt32(out var number) + && number == -32601; + + private static async Task RequestAsync(Process process, string method, int id, CancellationToken cancellationToken) + { + await SendAsync(process, method, id, method == "account/usage/read" ? static writer => + { + writer.WritePropertyName("params"); + writer.WriteStartObject(); + writer.WriteEndObject(); + } : null, cancellationToken).ConfigureAwait(false); + var responses = await ReadResponsesAsync(process.StandardOutput, cancellationToken, id).ConfigureAwait(false); + return responses[id]; + } + + private static async Task WithAppServerAsync( + CodexSourceOptions options, + Func> operation, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + using var process = StartAppServer(options); var standardErrorDrain = DrainAsync(process.StandardError); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(20)); @@ -43,27 +150,7 @@ await SendAsync( ThrowIfError(initializationResponse.RootElement); await SendAsync(process, "initialized", null, null, readCancellationToken).ConfigureAwait(false); - await SendAsync(process, "account/rateLimits/read", 2, null, readCancellationToken).ConfigureAwait(false); - await SendAsync( - process, - "account/read", - 3, - static writer => - { - writer.WritePropertyName("params"); - writer.WriteStartObject(); - writer.WriteBoolean("refreshToken", false); - writer.WriteEndObject(); - }, - readCancellationToken).ConfigureAwait(false); - - var responses = await ReadResponsesAsync(process.StandardOutput, readCancellationToken, 2, 3).ConfigureAwait(false); - using var rateResponse = responses[2]; - using var accountResponse = responses[3]; - - ThrowIfError(rateResponse.RootElement); - var rateResult = rateResponse.RootElement.GetProperty("result"); - return ParseSnapshot(rateResult, accountResponse.RootElement, DateTimeOffset.Now); + return await operation(process, readCancellationToken).ConfigureAwait(false); } finally { @@ -239,8 +326,15 @@ internal static async Task> ReadResponsesAsync(Tex throw new InvalidOperationException("Codex app-server stopped unexpectedly."); } - var document = JsonDocument.Parse(line); - if (document.RootElement.TryGetProperty("id", out var id) + if (line.Length > 1_048_576) + { + throw new InvalidOperationException("Codex app-server returned an oversized response."); + } + + var document = JsonDocument.Parse(line, new JsonDocumentOptions { MaxDepth = 32 }); + if (document.RootElement.ValueKind == JsonValueKind.Object + && document.RootElement.TryGetProperty("id", out var id) + && id.ValueKind == JsonValueKind.Number && id.TryGetInt32(out var value) && pendingIds.Remove(value)) { @@ -355,13 +449,15 @@ internal static async Task> ReadResponsesAsync(Tex return new RateLimitResetCredits(availableCount, parsed); } - private static Process StartAppServer() + private static Process StartAppServer(CodexSourceOptions options) => + Process.Start(CreateStartInfo(options)) ?? throw new InvalidOperationException("Codex app-server could not be started."); + + internal static ProcessStartInfo CreateStartInfo(CodexSourceOptions options) { - var executable = FindCodexExecutable(); + var executable = options.ExecutablePath ?? FindCodexExecutable(); var startInfo = new ProcessStartInfo { FileName = executable, - Arguments = "app-server --stdio", WorkingDirectory = GetSafeWorkingDirectory(executable), UseShellExecute = false, RedirectStandardInput = true, @@ -369,7 +465,13 @@ private static Process StartAppServer() RedirectStandardError = true, CreateNoWindow = true, }; - return Process.Start(startInfo) ?? throw new InvalidOperationException("Codex app-server could not be started."); + startInfo.ArgumentList.Add("app-server"); + startInfo.ArgumentList.Add("--stdio"); + if (options.HomePath is not null) + { + startInfo.Environment["CODEX_HOME"] = options.HomePath; + } + return startInfo; } private static string FindCodexExecutable() @@ -497,12 +599,15 @@ private static async Task ObserveProcessExitAsync(Process process, Task standard private static void ThrowIfError(JsonElement response) { - if (response.TryGetProperty("error", out var error)) + if (HasError(response)) { - throw new InvalidOperationException(error.GetRawText()); + throw new InvalidOperationException("Codex app-server returned an error."); } } + private static bool HasError(JsonElement response) => response.ValueKind == JsonValueKind.Object + && response.TryGetProperty("error", out _); + private static string? ParsePlan(JsonElement accountResponse) { if (!TryGetObject(accountResponse, "result", out var result) diff --git a/CodexUsageDock/CodexSourceOptions.cs b/CodexUsageDock/CodexSourceOptions.cs new file mode 100644 index 0000000..c6516c8 --- /dev/null +++ b/CodexUsageDock/CodexSourceOptions.cs @@ -0,0 +1,47 @@ +namespace CodexUsageDock; + +internal sealed record CodexSourceOptions(string? ExecutablePath = null, string? HomePath = null) +{ + internal static CodexSourceOptions Default { get; } = new(); + + internal static bool TryCreate(string? executablePath, string? homePath, out CodexSourceOptions options, out string? error) + { + options = Default; + error = null; + try + { + var executable = Normalize(executablePath); + var home = Normalize(homePath); + if (executable is not null && (!File.Exists(executable) + || !(Path.GetFileName(executable).Equals("codex.exe", StringComparison.OrdinalIgnoreCase) + || Path.GetFileName(executable).Equals("codex.cmd", StringComparison.OrdinalIgnoreCase)) + || CodexAppServerReader.IsWindowsAppsPath(executable))) + { + error = "Choose an existing standalone codex.exe or codex.cmd outside WindowsApps."; + return false; + } + if (home is not null && !Directory.Exists(home)) + { + error = "The selected Codex home directory is unavailable. Check the path and its access permissions."; + return false; + } + options = new(executable, home); + return true; + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException or IOException or UnauthorizedAccessException) + { + error = "Source paths must be complete, accessible Windows paths without arguments or control characters."; + return false; + } + } + + private static string? Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + if (value.Length > 1024 || value.Any(char.IsControl) || !Path.IsPathFullyQualified(value)) + { + throw new ArgumentException("Invalid source path."); + } + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(value.Trim())); + } +} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 7fb2d06..34d3f37 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -13,6 +13,14 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly ICommandItem[] _commands; private readonly CodexUsageDockPage _details; private readonly CodexUsageDiagnosticsPage _diagnostics; + private readonly CodexAccountActivityPage _accountActivity; + private readonly UsageAlertEvaluator _alerts = new(); + private readonly Action _notify; + private readonly Func _clock; + private bool _lastAlertsEnabled; + private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; + private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; + private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -20,18 +28,27 @@ public CodexUsageDockCommandsProvider() { } - internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockSettingsPage settings) + internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockSettingsPage settings, + Action? notify = null, Func? clock = null) { _usage = usage; _settings = settings; + _settings.Id = "nl.mathijs.codexusage.settings"; + _notify = notify ?? (message => new ToastStatusMessage(message).Show()); + _clock = clock ?? (() => DateTimeOffset.Now); + _lastAlertsEnabled = _settings.EnableUsageAlerts; DisplayName = "Codex Usage"; Id = "nl.mathijs.codexusage"; Icon = new IconInfo("\uE943"); _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); + ApplySourceSettings(); + _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); + _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; + _accountActivity = new CodexAccountActivityPage(_usage); _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -54,6 +71,11 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Title = "Codex Usage diagnostics", Subtitle = "Source, freshness, supported fields, and safe troubleshooting details", }, + new CommandItem(_accountActivity) + { + Title = "Codex account activity", + Subtitle = "Account-wide daily tokens reported by Codex", + }, ]; _settings.Changed += OnSettingsChanged; @@ -68,15 +90,45 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS public override ICommandItem[]? GetDockBands() => _dockBands; + public override ICommandItem? GetCommandItem(string id) + { + if (string.IsNullOrWhiteSpace(id)) return null; + var known = _commands.Concat(_dockBands).FirstOrDefault(item => item.Command.Id == id); + if (known is not null) return known; + return id switch + { + "nl.mathijs.codexusage.dock" => new WrappedDockItem(GetVisibleDockItems(), "nl.mathijs.codexusage.dock", DisplayName), + FiveHourDockId => new WrappedDockItem([_fiveHour], FiveHourDockId, "Codex five-hour usage"), + WeeklyDockId => new WrappedDockItem([_weekly], WeeklyDockId, "Codex weekly usage"), + CreditsDockId => new WrappedDockItem([_resetsAndCredits], CreditsDockId, "Codex resets and credits"), + _ => null, + }; + } + private void OnSettingsChanged(object? sender, EventArgs e) { + if (_lastAlertsEnabled != _settings.EnableUsageAlerts) + { + _alerts.Reset(); + _lastAlertsEnabled = _settings.EnableUsageAlerts; + } _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); + var sourceChanged = ApplySourceSettings(); + _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); RebuildDockBands(); RaiseItemsChanged(); + if (sourceChanged) _ = _usage.RefreshAsync(); + } + + private bool ApplySourceSettings() + { + _ = CodexSourceOptions.TryCreate(_settings.CodexExecutablePath, _settings.CodexHomePath, out var options, out var error); + if (error is not null) _settings.ShowOperationStatus(error); + return _usage.ConfigureSource(options, error); } private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) @@ -101,11 +153,27 @@ private void OnUsageUpdated(object? sender, EventArgs e) RebuildDockBands(); RaiseItemsChanged(); + var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval, + new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts)); + if (alerts.Count > 0) + { + var message = string.Join(" · ", alerts.Take(3).Select(alert => alert.Message)); + if (alerts.Count > 3) message += $" · {alerts.Count - 3} more usage alerts"; + _notify(message); + } } private void RebuildDockBands() { var items = GetVisibleDockItems(); + if (_settings.SeparateDockItems) + { + _dockBands = items.Select(item => new WrappedDockItem([item], + ReferenceEquals(item, _fiveHour) ? FiveHourDockId : ReferenceEquals(item, _weekly) ? WeeklyDockId : CreditsDockId, + ReferenceEquals(item, _fiveHour) ? "Codex five-hour usage" : ReferenceEquals(item, _weekly) ? "Codex weekly usage" : "Codex resets and credits")) + .Cast().ToArray(); + return; + } var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); @@ -143,6 +211,7 @@ public override void Dispose() _resetsAndCredits.Dispose(); _details.Dispose(); _diagnostics.Dispose(); + _accountActivity.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.AccountActivity.cs b/CodexUsageDock/CodexUsageService.AccountActivity.cs new file mode 100644 index 0000000..6e535d1 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.AccountActivity.cs @@ -0,0 +1,94 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private readonly Func>? _accountUsageReader; + private Task? _accountReadTask; + private Task _accountRefreshTask = Task.CompletedTask; + private DateTimeOffset _accountReadAfter; + private bool _accountActivityEnabled = true; + + internal AccountUsageSnapshot CurrentAccountUsage { get; private set; } = AccountUsageSnapshot.Unavailable; + + internal Task AccountUsageRefreshTask + { + get { lock (_refreshStateLock) { return _accountRefreshTask; } } + } + + internal void RequestAccountUsageRefresh() + { + lock (_refreshStateLock) { _accountReadAfter = DateTimeOffset.MinValue; } + } + + internal void SetAccountActivityEnabled(bool enabled) + { + lock (_refreshStateLock) + { + if (_accountActivityEnabled == enabled) return; + _accountActivityEnabled = enabled; + _accountReadAfter = DateTimeOffset.MinValue; + if (!enabled) CurrentAccountUsage = AccountUsageSnapshot.Unavailable; + } + RaiseUpdated(); + } + + private void StartAccountUsageRefresh(CodexUsageSnapshot snapshot) + { + lock (_refreshStateLock) + { + if (_disposed || !ReferenceEquals(Current, snapshot) || !_accountActivityEnabled || (!_usesConfiguredSources && _accountUsageReader is null) + || snapshot.Source != UsageDataSource.AppServer || snapshot.AccountKey is null + || _accountReadTask is { IsCompleted: false } || _clock() < _accountReadAfter) + { + return; + } + + var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetimeCancellation.Token); + cancellation.CancelAfter(TimeSpan.FromSeconds(25)); + var sourceGeneration = _sourceGeneration; + var options = _sourceOptions; + var account = snapshot.AccountKey; + _accountReadAfter = _clock().AddMinutes(5); + var read = _accountReadTask = Task.Run(() => _usesConfiguredSources + ? CodexAppServerReader.ReadAccountUsageAsync(options, cancellation.Token) + : _accountUsageReader!(cancellation.Token)); + _accountRefreshTask = Task.Run(() => ObserveAccountUsageAsync(read, cancellation, sourceGeneration, account)); + } + } + + private async Task ObserveAccountUsageAsync(Task read, CancellationTokenSource cancellation, + long sourceGeneration, string account) + { + try + { + var result = await read.WaitAsync(cancellation.Token).ConfigureAwait(false); + lock (_refreshStateLock) + { + if (_disposed || !_accountActivityEnabled || sourceGeneration != _sourceGeneration + || !string.Equals(Current.AccountKey, account, StringComparison.Ordinal)) return; + if (result.Status is AccountUsageStatus.Available or AccountUsageStatus.Partial + && !string.Equals(result.AccountKey, account, StringComparison.Ordinal)) return; + + CurrentAccountUsage = result; + if (result.Status == AccountUsageStatus.Unsupported) _accountReadAfter = _clock().AddMinutes(30); + } + RaiseUpdated(); + } + catch (OperationCanceledException) + { + } + catch (Exception exception) + { + LocalStorage.TraceFailure("read account activity", exception); + } + finally + { + if (read.IsCompleted) cancellation.Dispose(); + else _ = read.ContinueWith(task => + { + _ = task.Exception; + cancellation.Dispose(); + }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + } +} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index 2432fa0..dd2aa89 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -23,7 +23,11 @@ internal sealed partial class CodexUsageService : IDisposable private readonly Func _clock; private readonly Func> _appServerReader; private readonly Func _localSessionReader; - private readonly Func>? _localTokenUsageReader; + private Func>? _localTokenUsageReader; + private readonly bool _usesConfiguredSources; + private CodexSourceOptions _sourceOptions = CodexSourceOptions.Default; + private long _sourceGeneration; + private string? _sourceConfigurationError; private Task? _refreshTask; private Task? _tokenReadTask; private Task _tokenRefreshTask = Task.CompletedTask; @@ -42,6 +46,7 @@ public CodexUsageService() AdaptiveWeeklyUsageStore.CreateDefault(), localTokenUsageReader: new LocalCodexTokenUsageReader().ReadAsync) { + _usesConfiguredSources = true; } internal CodexUsageService( @@ -50,12 +55,14 @@ internal CodexUsageService( WeeklyUsageHistoryStore weeklyHistoryStore, AdaptiveWeeklyUsageStore adaptiveWeeklyUsageStore, Func>? localTokenUsageReader = null, - Func? clock = null) + Func? clock = null, + Func>? accountUsageReader = null) { _appServerReader = appServerReader; _localSessionReader = localSessionReader; _localTokenUsageReader = localTokenUsageReader; _clock = clock ?? (() => DateTimeOffset.Now); + _accountUsageReader = accountUsageReader; _weeklyHistoryStore = _baseWeeklyHistoryStore = weeklyHistoryStore; _adaptiveWeeklyUsageStore = _baseAdaptiveWeeklyUsageStore = adaptiveWeeklyUsageStore; // Legacy files have no account identity. They must never seed a verified account. @@ -67,8 +74,9 @@ internal CodexUsageService( WeeklyUsageHistoryStore weeklyHistoryStore, AdaptiveWeeklyUsageStore adaptiveWeeklyUsageStore, Func>? localTokenUsageReader = null, - Func? clock = null) - : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader, clock) + Func? clock = null, + Func>? accountUsageReader = null) + : this(appServerReader, _ => localSessionReader(), weeklyHistoryStore, adaptiveWeeklyUsageStore, localTokenUsageReader, clock, accountUsageReader) { } @@ -148,6 +156,36 @@ public AdaptiveWeeklyUsageHistory AdaptiveWeeklyHistory public event EventHandler? Updated; + internal bool ConfigureSource(CodexSourceOptions options, string? error = null) + { + lock (_refreshStateLock) + { + if (_sourceOptions == options && _sourceConfigurationError == error) return false; + _sourceOptions = options; + _sourceConfigurationError = error; + _sourceGeneration++; + _tokenGeneration++; + _lastConfirmed = null; + Current = CodexUsageSnapshot.Loading; + CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; + CurrentAccountUsage = AccountUsageSnapshot.Unavailable; + _accountReadAfter = DateTimeOffset.MinValue; + if (_usesConfiguredSources) + { + _localTokenUsageReader = new LocalCodexTokenUsageReader(options.HomePath).ReadAsync; + } + lock (_historyLock) + { + _memoryHistoryContext = null; + _historyContext = null; + _primaryHistory.Clear(); + _weeklyHistory.Clear(); + } + } + RaiseUpdated(); + return true; + } + public void Start() { lock (_refreshStateLock) @@ -219,6 +257,8 @@ public Task RefreshAsync() { TaskCompletionSource completion; CancellationToken cancellationToken; + CodexSourceOptions options; + long generation; lock (_refreshStateLock) { if (_disposed) @@ -235,10 +275,12 @@ public Task RefreshAsync() completion = new(TaskCreationOptions.RunContinuationsAsynchronously); _refreshTask = completion.Task; cancellationToken = _lifetimeCancellation.Token; + options = _sourceOptions; + generation = _sourceGeneration; } RaiseUpdated(); - _ = ExecuteRefreshAsync(completion, cancellationToken); + _ = ExecuteRefreshAsync(completion, options, generation, cancellationToken); return completion.Task; } @@ -329,13 +371,13 @@ private static bool RecordWindowHistory( private TimeSpan GetAdaptiveMaximumGap() => UsageTrendHistory.MaximumGap(RefreshInterval); - private async Task ExecuteRefreshAsync(TaskCompletionSource completion, CancellationToken cancellationToken) + private async Task ExecuteRefreshAsync(TaskCompletionSource completion, CodexSourceOptions options, long generation, CancellationToken cancellationToken) { CodexUsageSnapshot? tokenSnapshot = null; try { - var snapshot = await ReadSnapshotAsync(cancellationToken).ConfigureAwait(false); - if (!cancellationToken.IsCancellationRequested && TryPublish(snapshot)) + var snapshot = await ReadSnapshotAsync(options, generation, cancellationToken).ConfigureAwait(false); + if (!cancellationToken.IsCancellationRequested && TryPublish(snapshot, generation)) { tokenSnapshot = snapshot; } @@ -346,8 +388,8 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella catch (Exception error) { TraceFailure("unexpected refresh", error); - var unavailable = _lastConfirmed is null ? CreateUnavailableSnapshot() : LastConfirmedSnapshot(_clock()); - _ = TryPublish(unavailable); + var unavailable = FailureSnapshot(_clock()); + _ = TryPublish(unavailable, generation); } finally { @@ -360,20 +402,35 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, Cancella if (tokenSnapshot is not null) { StartTokenRefresh(tokenSnapshot); + StartAccountUsageRefresh(tokenSnapshot); } completion.TrySetResult(); + if (generation != _sourceGeneration && !cancellationToken.IsCancellationRequested) + { + _ = RefreshAsync(); + } } } - private async Task ReadSnapshotAsync(CancellationToken cancellationToken) + private async Task ReadSnapshotAsync(CodexSourceOptions options, long generation, CancellationToken cancellationToken) { var attemptedAt = _clock(); + lock (_refreshStateLock) + { + if (_sourceConfigurationError is not null) + return CreateUnavailableSnapshot() with { LastAttemptAt = attemptedAt }; + } try { - var live = await _appServerReader(cancellationToken).ConfigureAwait(false); - if (live.Source == UsageDataSource.AppServer) + var live = await (_usesConfiguredSources + ? CodexAppServerReader.ReadAsync(options, cancellationToken) + : _appServerReader(cancellationToken)).ConfigureAwait(false); + lock (_refreshStateLock) { - _lastConfirmed = live; + if (live.Source == UsageDataSource.AppServer && generation == _sourceGeneration) + { + _lastConfirmed = live; + } } return live with { LastAttemptAt = attemptedAt }; } @@ -386,14 +443,16 @@ private async Task ReadSnapshotAsync(CancellationToken cance TraceFailure("live usage read", error); try { - var fallback = await Task.Run(() => _localSessionReader(cancellationToken), cancellationToken).ConfigureAwait(false); + var fallback = await Task.Run(() => _usesConfiguredSources + ? LocalCodexSessionReader.ReadLatest(options.HomePath ?? LocalStorage.GetCodexHome(), _clock(), cancellationToken) + : _localSessionReader(cancellationToken), cancellationToken).ConfigureAwait(false); // Session logs do not normally identify the signed-in account. A newer // unverified log must not replace a confirmed, account-scoped measurement. if (_lastConfirmed is { } confirmed && (fallback.UpdatedAt <= confirmed.UpdatedAt || (confirmed.AccountKey is not null && fallback.AccountKey != confirmed.AccountKey))) { - return LastConfirmedSnapshot(attemptedAt); + return LastConfirmedSnapshot(confirmed, attemptedAt); } return fallback with { Error = LiveDataUnavailableMessage, LastAttemptAt = attemptedAt }; } @@ -404,14 +463,22 @@ private async Task ReadSnapshotAsync(CancellationToken cance catch (Exception fallbackError) { TraceFailure("local session fallback", fallbackError); - return _lastConfirmed is null - ? CreateUnavailableSnapshot() with { LastAttemptAt = attemptedAt } - : LastConfirmedSnapshot(attemptedAt); + return FailureSnapshot(attemptedAt); } } } - private CodexUsageSnapshot LastConfirmedSnapshot(DateTimeOffset attemptedAt) => _lastConfirmed! with + private CodexUsageSnapshot FailureSnapshot(DateTimeOffset attemptedAt) + { + lock (_refreshStateLock) + { + return _lastConfirmed is { } confirmed + ? LastConfirmedSnapshot(confirmed, attemptedAt) + : CreateUnavailableSnapshot() with { LastAttemptAt = attemptedAt }; + } + } + + private static CodexUsageSnapshot LastConfirmedSnapshot(CodexUsageSnapshot confirmed, DateTimeOffset attemptedAt) => confirmed with { Source = UsageDataSource.LastConfirmed, LastAttemptAt = attemptedAt, @@ -420,6 +487,7 @@ private CodexUsageSnapshot LastConfirmedSnapshot(DateTimeOffset attemptedAt) => private async Task ReadTokenUsageAsync( CodexUsageSnapshot snapshot, + Func> reader, CancellationToken cancellationToken) { if (snapshot.Secondary is not { WindowMinutes: > 0 } weekly) @@ -432,7 +500,7 @@ private async Task ReadTokenUsageAsync( var windowEnd = now < weekly.ResetsAt ? now : weekly.ResetsAt; try { - return await _localTokenUsageReader!(windowStart, windowEnd, TimeZoneInfo.Local, cancellationToken).ConfigureAwait(false); + return await reader(windowStart, windowEnd, TimeZoneInfo.Local, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -449,7 +517,7 @@ private void StartTokenRefresh(CodexUsageSnapshot snapshot) { lock (_refreshStateLock) { - if (_disposed || _localTokenUsageReader is null || snapshot.Secondary is null + if (_disposed || !ReferenceEquals(Current, snapshot) || _localTokenUsageReader is null || snapshot.Secondary is null || snapshot.Source is UsageDataSource.LastConfirmed or UsageDataSource.Unavailable or UsageDataSource.Initializing || _tokenReadTask is { IsCompleted: false }) { @@ -461,7 +529,8 @@ private void StartTokenRefresh(CodexUsageSnapshot snapshot) // Keep the actual read task, even after a timeout, so a non-cooperative // file read cannot cause overlapping scans of the reader's mutable cache. var generation = _tokenGeneration; - var read = _tokenReadTask = Task.Run(() => ReadTokenUsageAsync(snapshot, cancellation.Token)); + var reader = _localTokenUsageReader; + var read = _tokenReadTask = Task.Run(() => ReadTokenUsageAsync(snapshot, reader, cancellation.Token)); // Notifications must run outside the state lock, even if the read finishes immediately. _tokenRefreshTask = Task.Run(() => ObserveTokenRefreshAsync(read, cancellation, generation)); } @@ -508,11 +577,11 @@ private async Task ObserveTokenRefreshAsync(Task read, } } - private bool TryPublish(CodexUsageSnapshot snapshot) + private bool TryPublish(CodexUsageSnapshot snapshot, long generation) { lock (_refreshStateLock) { - if (_disposed) + if (_disposed || generation != _sourceGeneration) { return false; } @@ -523,6 +592,11 @@ private bool TryPublish(CodexUsageSnapshot snapshot) || snapshot.Source == UsageDataSource.Unavailable) { CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; + if (Current.AccountKey != snapshot.AccountKey) + { + CurrentAccountUsage = AccountUsageSnapshot.Unavailable; + _accountReadAfter = DateTimeOffset.MinValue; + } } Current = snapshot; @@ -590,7 +664,7 @@ public void Dispose() } _disposed = true; - refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask); + refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask); } _timer.Stop(); diff --git a/CodexUsageDock/Pages/CodexAccountActivityPage.cs b/CodexUsageDock/Pages/CodexAccountActivityPage.cs new file mode 100644 index 0000000..7473022 --- /dev/null +++ b/CodexUsageDock/Pages/CodexAccountActivityPage.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class CodexAccountActivityPage : ContentPage, IDisposable +{ + private readonly object _presentationLock = new(); + private readonly CodexUsageService _service; + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexAccountActivityPage(CodexUsageService service) + { + _service = service; + Id = "nl.mathijs.codexusage.account-activity"; + Name = "Open"; + Title = "Codex account activity"; + Icon = new IconInfo("\uE9D9"); + _service.Updated += OnUpdated; + UpdatePresentation(); + } + + public override IContent[] GetContent() + { + lock (_presentationLock) + { + return [_content]; + } + } + + internal static string FormatActivity(AccountUsageSnapshot snapshot) + { + var body = new StringBuilder("# Codex account activity\n\n"); + if (snapshot.Status == AccountUsageStatus.Unsupported) + { + body.Append("This Codex CLI does not provide account activity. An update may add support.\n"); + return body.ToString(); + } + + if (snapshot.Status == AccountUsageStatus.Unavailable) + { + body.Append("Account activity is unavailable. Enable account activity in settings and refresh to request it. The account must remain identified throughout the read.\n"); + return body.ToString(); + } + + body.Append("Token totals reported by the Codex account service. These figures do not measure your remaining quota.\n\n"); + if (snapshot.Status == AccountUsageStatus.Partial) + { + body.Append("**Partial data:** some server information is missing, invalid, duplicated, or outside the retained history.\n\n"); + } + + body.Append("Last read: ").Append(snapshot.UpdatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)).Append(".\n\n"); + AppendSummary(body, "Lifetime tokens", snapshot.LifetimeTokens); + AppendSummary(body, "Peak daily tokens", snapshot.PeakDailyTokens); + AppendSummary(body, "Longest running turn (seconds)", snapshot.LongestRunningTurnSeconds); + AppendSummary(body, "Current streak (days)", snapshot.CurrentStreakDays); + AppendSummary(body, "Longest streak (days)", snapshot.LongestStreakDays); + + body.Append("\n## Daily activity\n\nDates are calendar dates supplied by the server. Their time zone is not specified. Up to 30 recent reported days are shown.\n\n"); + if (snapshot.Days.Count == 0) + { + body.Append("No readable daily rows were returned. Missing days are not treated as zero usage.\n"); + return body.ToString(); + } + + body.Append("| Server date | Tokens |\n| --- | ---: |\n"); + foreach (var day in snapshot.Days.OrderByDescending(day => day.Date).Take(30)) + { + body.Append("| ").Append(day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)) + .Append(" | ").Append(day.TotalTokens.ToString("N0", CultureInfo.InvariantCulture)).Append(" |\n"); + } + + return body.ToString(); + } + + private static void AppendSummary(StringBuilder body, string label, long? value) => + body.Append("- **").Append(label).Append(":** ") + .Append(value?.ToString("N0", CultureInfo.InvariantCulture) ?? "Not reported").Append('\n'); + + private void UpdatePresentation() + { + var body = FormatActivity(_service.CurrentAccountUsage); + lock (_presentationLock) + { + if (_disposed) + { + return; + } + + _content = new MarkdownContent(body); + Commands = + [ + new CommandContextItem(new RefreshUsageCommand(_service, refreshAccountActivity: true)) { Title = "Refresh now" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy activity summary" }, + ]; + } + } + + private void OnUpdated(object? sender, EventArgs e) + { + UpdatePresentation(); + lock (_presentationLock) + { + if (_disposed) + { + return; + } + } + + RaiseItemsChanged(0); + } + + public void Dispose() + { + lock (_presentationLock) + { + if (_disposed) + { + return; + } + + _disposed = true; + _service.Updated -= OnUpdated; + } + + GC.SuppressFinalize(this); + } +} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 7db332a..0b959d5 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -8,12 +8,20 @@ namespace CodexUsageDock; internal sealed partial class CodexUsageDockSettingsPage : ContentPage { + private const string InvalidSourcePath = "Invalid source path: re-enter or clear this field"; private const string ShowFiveHourLimitKey = "showFiveHourLimit"; private const string ShowWeeklyLimitKey = "showWeeklyLimit"; private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; private const string ShowResetTimeKey = "showResetTime"; private const string RefreshIntervalKey = "refreshInterval"; private const string UseAdaptiveWeeklyForecastKey = "useAdaptiveWeeklyForecast"; + private const string EnableUsageAlertsKey = "enableUsageAlerts"; + private const string CompactDockKey = "compactDock"; + private const string SeparateDockItemsKey = "separateDockItems"; + private const string ShowAccountActivityKey = "showAccountActivity"; + private const string CodexExecutablePathKey = "codexExecutablePath"; + private const string CodexHomePathKey = "codexHomePath"; + private const int MaximumPathLength = 1024; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -60,6 +68,40 @@ internal CodexUsageDockSettingsPage(string path) Label = "Use adaptive weekly forecast", Description = "Blend the current pace with up to eight local weekly cycles. Turning this off pauses learning and keeps saved history.", }); + _settings.Add(new ToggleSetting(EnableUsageAlertsKey, false) + { + Label = "Enable usage alerts", + Description = "Show a quiet notification when the usage status changes.", + }); + _settings.Add(new ToggleSetting(CompactDockKey, false) + { + Label = "Compact Dock", + Description = "Use shorter usage labels and hide reset times in the Dock.", + }); + _settings.Add(new ToggleSetting(SeparateDockItemsKey, false) + { + Label = "Separate Dock items", + Description = "Show each usage item as its own Dock entry.", + }); + _settings.Add(new ToggleSetting(ShowAccountActivityKey, true) + { + Label = "Show account activity", + Description = "Read account-wide daily tokens from the Codex service after quotas load. Older CLI versions may not support this.", + }); + _settings.Add(new TextSetting(CodexExecutablePathKey, string.Empty) + { + Label = "Codex executable path", + Description = "Optional explicit path to codex.exe or codex.cmd.", + Placeholder = @"C:\Path\to\codex.exe", + Multiline = false, + }); + _settings.Add(new TextSetting(CodexHomePathKey, string.Empty) + { + Label = "Codex home path", + Description = "Optional Codex home directory. It may be a Windows-accessible WSL directory; this extension does not launch WSL or modify Codex configuration.", + Placeholder = @"C:\Users\you\.codex", + Multiline = false, + }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, [ @@ -107,6 +149,18 @@ internal CodexUsageDockSettingsPage(string path) public bool UseAdaptiveWeeklyForecast => _settings.GetSetting(UseAdaptiveWeeklyForecastKey); + public bool EnableUsageAlerts => _settings.GetSetting(EnableUsageAlertsKey); + + public bool CompactDock => _settings.GetSetting(CompactDockKey); + + public bool SeparateDockItems => _settings.GetSetting(SeparateDockItemsKey); + + public bool ShowAccountActivity => _settings.GetSetting(ShowAccountActivityKey); + + public string CodexExecutablePath => GetPathSetting(CodexExecutablePathKey); + + public string CodexHomePath => GetPathSetting(CodexHomePathKey); + public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); internal string? StatusMessage { get; private set; } @@ -147,6 +201,19 @@ private void Load() var valid = new JsonObject(); foreach (var property in document.RootElement.EnumerateObject()) { + if (property.Name is CodexExecutablePathKey or CodexHomePathKey) + { + valid[property.Name] = property.Value.ValueKind == JsonValueKind.String && IsValidPathSetting(property.Value.GetString()) + ? property.Value.GetString() : InvalidSourcePath; + continue; + } + if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False + && IsBooleanSetting(property.Name)) + { + valid[property.Name] = property.Value.GetBoolean() ? "true" : "false"; + continue; + } + if (property.Value.ValueKind != JsonValueKind.String) { continue; @@ -157,8 +224,7 @@ private void Load() { valid[property.Name] = value; } - else if (property.Name is ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or UseAdaptiveWeeklyForecastKey - && bool.TryParse(value, out var enabled)) + else if (IsBooleanSetting(property.Name) && bool.TryParse(value, out var enabled)) { valid[property.Name] = enabled ? "true" : "false"; } @@ -173,6 +239,35 @@ private void Load() } } + private static bool IsBooleanSetting(string name) => name is + ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or + UseAdaptiveWeeklyForecastKey or EnableUsageAlertsKey or CompactDockKey or SeparateDockItemsKey or + ShowAccountActivityKey; + + private static bool IsValidPathSetting(string? value) + { + if (value is null || value.Length > MaximumPathLength) + { + return false; + } + + foreach (var character in value) + { + if (char.IsControl(character)) + { + return false; + } + } + + return true; + } + + private string GetPathSetting(string key) + { + var value = _settings.GetSetting(key); + return IsValidPathSetting(value) ? value! : InvalidSourcePath; + } + private void OnSettingsChanged(object sender, Settings args) { var saved = LocalStorage.TryWrite(_path, _settings.ToJson()); diff --git a/CodexUsageDock/RefreshUsageCommand.cs b/CodexUsageDock/RefreshUsageCommand.cs index 867c71f..ec7bde1 100644 --- a/CodexUsageDock/RefreshUsageCommand.cs +++ b/CodexUsageDock/RefreshUsageCommand.cs @@ -3,12 +3,13 @@ namespace CodexUsageDock; -internal sealed partial class RefreshUsageCommand(CodexUsageService usage) : InvokableCommand +internal sealed partial class RefreshUsageCommand(CodexUsageService usage, bool refreshAccountActivity = false) : InvokableCommand { public override string Name => "Refresh Codex usage"; public override ICommandResult Invoke() { + if (refreshAccountActivity) usage.RequestAccountUsageRefresh(); _ = usage.RefreshAsync(); return CommandResult.KeepOpen(); } diff --git a/CodexUsageDock/UsageAlerts.cs b/CodexUsageDock/UsageAlerts.cs new file mode 100644 index 0000000..7510613 --- /dev/null +++ b/CodexUsageDock/UsageAlerts.cs @@ -0,0 +1,478 @@ +using System.Globalization; + +namespace CodexUsageDock; + +internal sealed record UsageAlertOptions( + bool Enabled = false, + int LowRemainingPercent = 10, + bool ForecastWarnings = true, + bool ResetExpiryWarnings = true); + +internal sealed record UsageAlert(string Key, string Message); + +internal sealed class UsageAlertEvaluator +{ + private const int MaximumTrackedWindows = 32; + private const int MaximumTrackedResetExpiries = 32; + private static readonly TimeSpan WindowCycleTolerance = TimeSpan.FromMinutes(1); + private static readonly TimeSpan ForecastWarningHorizon = TimeSpan.FromMinutes(60); + private static readonly TimeSpan ResetExpiryWarningHorizon = TimeSpan.FromHours(24); + private readonly object _stateLock = new(); + private UsageAlertOptions? _lastOptions; + private string? _activeAccountKey; + private AccountAlertState? _state; + + internal IReadOnlyList Evaluate( + UsagePresentation presentation, + DateTimeOffset now, + TimeSpan refreshInterval, + UsageAlertOptions options) + { + ArgumentNullException.ThrowIfNull(presentation); + ArgumentNullException.ThrowIfNull(options); + + lock (_stateLock) + { + if (!options.Enabled) + { + ClearState(); + _lastOptions = options; + return []; + } + + if (_lastOptions is null || !_lastOptions.Equals(options)) + { + ClearState(); + _lastOptions = options; + } + + var snapshot = presentation.Usage; + if (!IsEligible(presentation, now, refreshInterval, snapshot)) + { + return []; + } + + var accountKey = snapshot.AccountKey!.Trim(); + if (!string.Equals(_activeAccountKey, accountKey, StringComparison.Ordinal)) + { + ClearState(); + _activeAccountKey = accountKey; + } + + var category = GetDefaultCategory(snapshot); + if (_state is null || !string.Equals(_state.DefaultCategory, category, StringComparison.Ordinal)) + { + _state = new AccountAlertState(category); + } + + var alerts = new List(); + var observations = CollectWindowObservations(snapshot, now); + var currentWindowKeys = new HashSet(StringComparer.Ordinal); + foreach (var observation in observations) + { + var key = GetWindowKey(observation); + currentWindowKeys.Add(key); + var windowState = GetWindowState(observation); + + EvaluateLowRemaining(observation, windowState, options, alerts); + } + + foreach (var key in _state.Windows.Keys.Where(key => !currentWindowKeys.Contains(key)).ToArray()) + { + _state.Windows.Remove(key); + } + + if (options.ForecastWarnings) + { + EvaluateForecastWarning( + observations, + snapshot.Primary, + presentation.PrimaryHistory, + now, + refreshInterval, + alerts); + EvaluateForecastWarning( + observations, + snapshot.Secondary, + presentation.WeeklyHistory, + now, + refreshInterval, + alerts); + } + + if (options.ResetExpiryWarnings) + { + EvaluateResetExpiry(snapshot.ResetCredits, now, alerts); + } + else + { + _state.ResetExpiries.Clear(); + } + + return alerts; + } + } + + internal void Reset() + { + lock (_stateLock) + { + ClearState(); + _lastOptions = null; + } + } + + private static bool IsEligible( + UsagePresentation presentation, + DateTimeOffset now, + TimeSpan refreshInterval, + CodexUsageSnapshot snapshot) => + !presentation.IsLoading + && snapshot.Source == UsageDataSource.AppServer + && !string.IsNullOrWhiteSpace(snapshot.AccountKey) + && UsageFreshness.IsFresh(snapshot.UpdatedAt, now, refreshInterval); + + private static string GetDefaultCategory(CodexUsageSnapshot snapshot) => + string.IsNullOrWhiteSpace(snapshot.DefaultBucketId) ? "default" : snapshot.DefaultBucketId!; + + private static List CollectWindowObservations( + CodexUsageSnapshot snapshot, + DateTimeOffset now) + { + var observations = new List(MaximumTrackedWindows); + var seen = new HashSet(StringComparer.Ordinal); + var defaultCategory = GetDefaultCategory(snapshot); + AddObservation(observations, seen, defaultCategory, "default", "primary", snapshot.Primary, now, isDefault: true, knownDefaultWindow: null); + AddObservation(observations, seen, defaultCategory, "default", "secondary", snapshot.Secondary, now, isDefault: true, knownDefaultWindow: null); + + if (snapshot.Buckets is not { Count: > 0 } buckets) + { + return observations; + } + + for (var index = 0; index < buckets.Count && observations.Count < MaximumTrackedWindows; index++) + { + var bucket = buckets[index]; + if (bucket is null) + { + continue; + } + + var isDefaultBucket = string.Equals(bucket.Id, snapshot.DefaultBucketId, StringComparison.Ordinal); + var category = isDefaultBucket + ? defaultCategory + : string.IsNullOrWhiteSpace(bucket.Id) ? $"bucket-{index + 1}" : bucket.Id; + var label = isDefaultBucket + ? "default" + : UsageText.SanitizeExternal(bucket.Name, 70) + ?? UsageText.SanitizeExternal(bucket.Id, 70) + ?? $"Additional quota {index + 1}"; + AddObservation( + observations, + seen, + category, + label, + "primary", + bucket.Primary, + now, + isDefault: isDefaultBucket, + knownDefaultWindow: snapshot.Primary); + AddObservation( + observations, + seen, + category, + label, + "secondary", + bucket.Secondary, + now, + isDefault: isDefaultBucket, + knownDefaultWindow: snapshot.Secondary); + } + + return observations; + } + + private static void AddObservation( + List observations, + HashSet seen, + string category, + string label, + string role, + RateLimitWindow? window, + DateTimeOffset now, + bool isDefault, + RateLimitWindow? knownDefaultWindow) + { + if (window is null + || observations.Count >= MaximumTrackedWindows + || isDefault && window == knownDefaultWindow + || !UsageFreshness.IsValidWindow(window, now)) + { + return; + } + + var observationKey = $"{category}|{role}|{window.WindowMinutes}|{window.ResetsAt.UtcTicks}|{window.UsedPercent.ToString("R", CultureInfo.InvariantCulture)}"; + if (!seen.Add(observationKey)) + { + return; + } + + var identityKey = $"{category}|{role}|{window.WindowMinutes}"; + observations.Add(new WindowObservation(category, label, role, window, isDefault, identityKey)); + } + + private WindowAlertState GetWindowState(WindowObservation observation) + { + var key = GetWindowKey(observation); + if (_state!.Windows.TryGetValue(key, out var state)) + { + var delta = state.CycleResetAt >= observation.Window.ResetsAt + ? state.CycleResetAt - observation.Window.ResetsAt + : observation.Window.ResetsAt - state.CycleResetAt; + if (delta <= WindowCycleTolerance) + { + state.CycleResetAt = observation.Window.ResetsAt; + return state; + } + } + + state = new WindowAlertState(observation.Window.ResetsAt); + _state.Windows[key] = state; + return state; + } + + private static void EvaluateLowRemaining( + WindowObservation observation, + WindowAlertState state, + UsageAlertOptions options, + List alerts) + { + var remaining = observation.Window.RemainingPercent; + var threshold = Math.Clamp(options.LowRemainingPercent, 0, 100); + if (!state.HasMeasurement) + { + state.HasMeasurement = true; + state.LastRemaining = remaining; + state.Rearmed = remaining > threshold; + return; + } + + var crossedDown = state.Rearmed + && state.LastRemaining > threshold + && remaining <= threshold; + if (crossedDown) + { + alerts.Add(new UsageAlert( + $"low:{GetWindowKey(observation)}:{state.CycleKey}", + $"{FormatWindowName(observation)} is at {FormatPercent(remaining)}% remaining.")); + state.Rearmed = false; + } + + if (remaining > threshold + 5) + { + state.Rearmed = true; + } + + state.LastRemaining = remaining; + } + + private void EvaluateForecastWarning( + IReadOnlyList observations, + RateLimitWindow? targetWindow, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan refreshInterval, + List alerts) + { + if (targetWindow is null) + { + return; + } + + var observation = observations.FirstOrDefault(candidate => + candidate.IsDefault && candidate.Window == targetWindow); + if (observation is null || !_state!.Windows.TryGetValue(GetWindowKey(observation), out var state)) + { + return; + } + + UsageTrendForecast? forecast = null; + try + { + var windowStart = targetWindow.ResetsAt - TimeSpan.FromMinutes(targetWindow.WindowMinutes); + forecast = UsageTrendAnalyzer.Analyze( + history, + windowStart, + targetWindow.ResetsAt, + now, + dataAvailable: true, + UsageFreshness.MaximumAge(refreshInterval)).Forecast; + } + catch (ArgumentException) + { + } + catch (InvalidOperationException) + { + } + catch (OverflowException) + { + } + + var atRisk = forecast is { ReachesLimitBeforeReset: true } candidate + && candidate.EndsAt >= now + && candidate.EndsAt < targetWindow.ResetsAt + && candidate.EndsAt - now <= ForecastWarningHorizon; + if (!state.ForecastInitialized) + { + state.ForecastInitialized = true; + state.ForecastAtRisk = atRisk; + return; + } + + if (atRisk && !state.ForecastAtRisk && !state.ForecastWarningIssued) + { + var minutes = Math.Max(1, (int)Math.Ceiling((forecast!.EndsAt - now).TotalMinutes)); + alerts.Add(new UsageAlert( + $"forecast:{GetWindowKey(observation)}:{state.CycleKey}", + $"{FormatWindowName(observation)} forecast may reach its limit within {minutes.ToString(CultureInfo.InvariantCulture)} minutes.")); + state.ForecastWarningIssued = true; + } + + state.ForecastAtRisk = atRisk; + } + + private void EvaluateResetExpiry( + RateLimitResetCredits? resets, + DateTimeOffset now, + List alerts) + { + if (resets is not { AvailableCount: > 0, Credits: { Count: > 0 } credits }) + { + _state!.ResetExpiries.Clear(); + return; + } + + var eligible = credits + .Where(credit => credit is not null + && credit.ExpiresAt is { } expiry + && expiry > now + && IsAvailableStatus(credit.Status)) + .Select(credit => credit.ExpiresAt!.Value) + .GroupBy(expiry => expiry.UtcTicks) + .OrderBy(group => group.Key) + .Take(MaximumTrackedResetExpiries) + .Select(group => group.First()) + .ToArray(); + var currentKeys = new HashSet(); + foreach (var expiry in eligible) + { + var key = expiry.UtcTicks; + currentKeys.Add(key); + var withinWarningWindow = expiry - now <= ResetExpiryWarningHorizon; + if (!_state!.ResetExpiries.TryGetValue(key, out var state)) + { + _state.ResetExpiries[key] = new ResetExpiryState(withinWarningWindow); + continue; + } + + if (withinWarningWindow && !state.WithinWarningWindow) + { + alerts.Add(new UsageAlert( + $"reset-expiry:{key.ToString(CultureInfo.InvariantCulture)}", + $"A reset credit expires within 24 hours at {expiry.ToUniversalTime():yyyy-MM-dd HH:mm} UTC.")); + } + + state.WithinWarningWindow = withinWarningWindow; + } + + foreach (var key in _state!.ResetExpiries.Keys.Where(key => !currentKeys.Contains(key)).ToArray()) + { + _state.ResetExpiries.Remove(key); + } + } + + private static bool IsAvailableStatus(string? status) => + status is null || string.Equals(status.Trim(), "available", StringComparison.OrdinalIgnoreCase); + + private static string GetWindowKey(WindowObservation observation) => observation.Key; + + private static string FormatWindowName(WindowObservation observation) + { + var duration = FormatWindowDuration(observation.Window.WindowMinutes); + return observation.IsDefault + ? $"{duration} window" + : $"{observation.Label} {observation.Role} window ({duration})"; + } + + private static string FormatWindowDuration(int minutes) + { + if (minutes == (int)TimeSpan.FromHours(5).TotalMinutes) + { + return "5-hour"; + } + + if (minutes == (int)TimeSpan.FromDays(7).TotalMinutes) + { + return "weekly"; + } + + if (minutes > 0 && minutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) + { + return $"{minutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; + } + + if (minutes > 0 && minutes % 60 == 0) + { + return $"{minutes / 60}-hour"; + } + + return $"{minutes}-minute"; + } + + private static string FormatPercent(double remaining) => + remaining.ToString("0", CultureInfo.InvariantCulture); + + private void ClearState() + { + _activeAccountKey = null; + _state = null; + } + + private sealed record WindowObservation( + string Category, + string Label, + string Role, + RateLimitWindow Window, + bool IsDefault, + string Key); + + private sealed class WindowAlertState + { + internal WindowAlertState(DateTimeOffset cycleResetAt) + { + CycleResetAt = cycleResetAt; + CycleKey = cycleResetAt.UtcTicks.ToString(CultureInfo.InvariantCulture); + } + + internal DateTimeOffset CycleResetAt { get; set; } + internal string CycleKey { get; } + internal bool HasMeasurement { get; set; } + internal double LastRemaining { get; set; } + internal bool Rearmed { get; set; } + internal bool ForecastAtRisk { get; set; } + internal bool ForecastInitialized { get; set; } + internal bool ForecastWarningIssued { get; set; } + } + + private sealed class ResetExpiryState(bool withinWarningWindow) + { + internal bool WithinWarningWindow { get; set; } = withinWarningWindow; + } + + private sealed class AccountAlertState(string defaultCategory) + { + internal string DefaultCategory { get; } = defaultCategory; + internal Dictionary Windows { get; } = new(StringComparer.Ordinal); + internal Dictionary ResetExpiries { get; } = []; + } +} diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 36c1a51..9a03271 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -35,7 +35,7 @@ private void UpdateText() var window = _kind == UsageDockItemKind.FiveHour ? snapshot.Primary : snapshot.Secondary; if (snapshot.Source == UsageDataSource.Unavailable) { - (Title, Subtitle) = FormatUnavailable(_kind); + (Title, Subtitle) = FormatUnavailable(_kind, _settings?.CompactDock == true); Icon = new IconInfo("\uE783"); return; } @@ -45,12 +45,13 @@ private void UpdateText() Title = FormatResetsAndCredits(snapshot); Subtitle = CombineStatusAndDetail( FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), - FormatResetExpiry(snapshot.ResetCredits, now)); + _settings?.CompactDock == true ? string.Empty : FormatResetExpiry(snapshot.ResetCredits, now)); Icon = new IconInfo("\uE777"); return; } - var label = _kind == UsageDockItemKind.FiveHour ? "5h" : "Week"; + var compact = _settings?.CompactDock == true; + var label = _kind == UsageDockItemKind.FiveHour ? "5h" : compact ? "W" : "Week"; if (window is null) { var dataWasLoaded = snapshot.Primary is not null || snapshot.Secondary is not null; @@ -72,8 +73,8 @@ private void UpdateText() return; } - Title = $"{label} {window.RemainingPercent:0}%"; - var reset = _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; + Title = FormatQuotaTitle(_kind, window.RemainingPercent, compact); + var reset = compact || _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; Subtitle = CombineStatusAndDetail(FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), reset); Icon = new IconInfo(window.RemainingPercent <= 10 ? "\uE7BA" : "\uE916"); } @@ -103,6 +104,20 @@ internal static string FormatFallbackAge(CodexUsageSnapshot snapshot, DateTimeOf : $"Fallback · {(int)age.TotalDays} days old"; } + internal static string FormatQuotaTitle(UsageDockItemKind kind, double remainingPercent, bool compact) + { + var label = kind switch + { + UsageDockItemKind.FiveHour => "5h", + UsageDockItemKind.Weekly => compact ? "W" : "Week", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "The resets and credits item has no quota percentage."), + }; + + return compact + ? $"{label}{remainingPercent:0}%" + : $"{label} {remainingPercent:0}%"; + } + internal static string FormatSourceFreshness( CodexUsageSnapshot snapshot, DateTimeOffset now, @@ -214,11 +229,11 @@ internal static string FormatResetExpiry(RateLimitResetCredits? resets, DateTime : $"expires in {(int)Math.Ceiling(remaining.TotalDays)} days"; } - internal static (string Title, string Subtitle) FormatUnavailable(UsageDockItemKind kind) => + internal static (string Title, string Subtitle) FormatUnavailable(UsageDockItemKind kind, bool compact = false) => (kind switch { UsageDockItemKind.FiveHour => "5h --", - UsageDockItemKind.Weekly => "Week --", + UsageDockItemKind.Weekly => compact ? "W --" : "Week --", _ => "-- resets", }, "Codex usage unavailable"); diff --git a/PRIVACY.md b/PRIVACY.md index 787feda..31650cd 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -22,6 +22,8 @@ The extension does not create an external user account or remote database. Setti Account-scoped history uses a one-way hash of the account identity supplied by Codex, combined with the default quota category, as an opaque local directory name. Raw account identifiers and email addresses are not stored or shown in diagnostics. Legacy history without account attribution is not imported into a verified account; unverified observations remain in memory and do not train saved forecasts. The last confirmed usage snapshot is retained only in memory during an outage, with its original timestamp. Diagnostics exposes field availability and bounded status messages, not raw service errors, credentials, or personal paths. +Optional source preferences store the user-selected executable and Codex home paths locally in settings. These paths are not included in diagnostic reports or logs. Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. + ## Permissions The Windows `runFullTrust` capability is required to run the packaged Command Palette COM server and to communicate with the locally installed Codex process. It is not used to bypass Windows security controls or access unrelated user data. diff --git a/README.md b/README.md index 9501f54..791e29c 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10. ## Customize the Dock +**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each metric as a separate pinnable band; existing combined-band and individual pin identifiers remain resolvable after changing modes. + +**Enable usage alerts** is off by default. When enabled, fresh, identified account data can notify on a downward crossing of 10% remaining, a new projected limit within one hour, or a reset credit entering its last 24 hours. The first measurement establishes a baseline. Duplicate refreshes do not repeat alerts, small reset-time fluctuations stay in the same cycle, and account/category changes start a new baseline. Multiple simultaneous alerts are combined into one host notification. Delivery depends on the Command Palette host. + Open Command Palette and select **Codex Usage settings** to choose which usage entries appear in the Dock. You can independently show or hide the five-hour limit, weekly limit, and resets and credits, choose whether usage entries show their reset time, set the local data refresh interval to 1, 5, or 15 minutes, and enable or pause the adaptive weekly forecast. Pausing the forecast keeps its learned local history and excludes measurements collected while it is paused; **Delete learned forecast history** asks for confirmation before permanently clearing it. The extension saves these choices in `CodexUsageDock/settings.json` under the current user's Windows local application data directory and restores them before refreshing after a restart. If saving fails, the page explains that the choices apply only to the running session and lets you save again. Deleting learned history confirms success only after the cleared state is saved. History read/write failures also appear in Details. Choices lost by older versions cannot be recovered; set them once again after updating. @@ -58,6 +62,12 @@ The extension saves these choices in `CodexUsageDock/settings.json` under the cu Microsoft Store installs updates automatically. You can also check for updates from **Microsoft Store > Library**. +## Sources and account activity + +Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd` and an optional Codex home directory. Empty fields retain environment-based discovery. An explicit directory can be a Windows-accessible WSL path; the extension reads that directory and passes it to the Windows CLI as `CODEX_HOME`, without starting WSL or changing Codex configuration. Inaccessible or invalid explicit paths stop source reads and show a settings error. A profile change clears the displayed context and discards results from the previous in-flight read. + +**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. + ## Uninstall Remove **Codex Usage Dock** from **Windows Settings > Apps > Installed apps**. diff --git a/SPRINTS.md b/SPRINTS.md index 98bce0d..99ff1cb 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -5,7 +5,7 @@ This series implements the recommended Codex-first roadmap, followed by a small, | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | -| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | Planned | +| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | Implemented; x64/ARM64 compilation passed; GitHub test validation pending | | 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Planned | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | @@ -25,3 +25,9 @@ Gemini/Cursor/Copilot expansion, a standalone tray app, cloud sync, team dashboa ### Sprint 1 verification Native ARM64 tests passed (186/186); application builds have no warnings. Existing test-name analyzer warnings remain unchanged. The x64 .NET 10 test runtime is unavailable locally, so x64 test execution belongs to PR CI. The integration preflight passed manifest, COM identity, generated-output freshness, self-contained runtime, and asset checks. Package registration and AppExtension discovery were unavailable in this test context; Command Palette reload, visual behavior, Store installation, and real-account compatibility were not verified. Tests use isolated synthetic data and do not consume actual reset credits. + +The final sprint 1 head also passed [GitHub Actions](https://github.com/TheBeems/CodexUsageDock/actions/runs/34379840746), including x64 tests, both architecture builds, and Store-package validation. + +### Sprint 2 verification + +The x64 and ARM64 application code compiles without warnings. Local ARM64 test execution was blocked while loading the assembly by Windows Application Control (`0x800711C7`), including a retry with elevated execution. This is an execution gap, not a passing test result. No Windows security policy was changed. The existing GitHub workflow will run the synthetic tests and both architecture/package checks on the pushed feature branch. Live account activity, notifications, individual pinning, and configuration changes still require Command Palette verification after an authorized installation. From 4c2bd15bd5f5eedf9dec77586d7d7fb66536a298 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:23:12 +0200 Subject: [PATCH 04/21] Link sprint 2 changes to pull request 19 --- CHANGELOG.md | 6 +++--- SPRINTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc85c05..e9d3e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- Optional quiet usage alerts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. -- Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. -- Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. +- Optional quiet usage alerts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) +- Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) +- Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/SPRINTS.md b/SPRINTS.md index 99ff1cb..c017faf 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -5,7 +5,7 @@ This series implements the recommended Codex-first roadmap, followed by a small, | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | -| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | Implemented; x64/ARM64 compilation passed; GitHub test validation pending | +| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); x64/ARM64 compilation passed; GitHub test validation pending | | 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Planned | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | From d067295380eb48792bdb1c6e7d037b240ac2280e Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:27:51 +0200 Subject: [PATCH 05/21] Use fresh observations in reset expiry notification regression test --- CHANGELOG.md | 2 +- CodexUsageDock.Tests/AccountUsageTests.cs | 4 +++- CodexUsageDock.Tests/UsageAlertTests.cs | 11 ++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9d3e82..4abe7cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- Optional quiet usage alerts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) +- Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/AccountUsageTests.cs b/CodexUsageDock.Tests/AccountUsageTests.cs index a38bc21..3dce9a8 100644 --- a/CodexUsageDock.Tests/AccountUsageTests.cs +++ b/CodexUsageDock.Tests/AccountUsageTests.cs @@ -240,7 +240,9 @@ public void ConfiguredSourceUsesProcessArgumentsAndAnEnvironmentVariable() var info = CodexAppServerReader.CreateStartInfo(new CodexSourceOptions(executable, home)); Assert.Equal(executable, info.FileName); - Assert.Equal(new[] { "app-server", "--stdio" }, info.ArgumentList); + Assert.Collection(info.ArgumentList, + argument => Assert.Equal("app-server", argument), + argument => Assert.Equal("--stdio", argument)); Assert.Equal(home, info.Environment["CODEX_HOME"]); Assert.Equal(Path.GetDirectoryName(executable), info.WorkingDirectory); Assert.False(info.UseShellExecute); diff --git a/CodexUsageDock.Tests/UsageAlertTests.cs b/CodexUsageDock.Tests/UsageAlertTests.cs index 554e9ea..75443f7 100644 --- a/CodexUsageDock.Tests/UsageAlertTests.cs +++ b/CodexUsageDock.Tests/UsageAlertTests.cs @@ -238,11 +238,16 @@ public void ResetExpiryWarnsOnEntryWithinTwentyFourHoursAndDeduplicatesByExpiry( primaryRemaining: null, secondaryRemaining: null, resetCredits: outside), now: Now)); + Assert.Empty(Evaluate(evaluator, Presentation( + primaryRemaining: null, + secondaryRemaining: null, + resetCredits: inside, + updatedAt: Now), now: Now.AddHours(2))); var transition = Evaluate(evaluator, Presentation( primaryRemaining: null, secondaryRemaining: null, resetCredits: inside, - updatedAt: Now), now: Now.AddHours(2)); + updatedAt: Now.AddHours(2)), now: Now.AddHours(2)); Assert.Single(transition); Assert.StartsWith("reset-expiry:", transition[0].Key, StringComparison.Ordinal); Assert.DoesNotContain("First title", transition[0].Message, StringComparison.Ordinal); @@ -252,12 +257,12 @@ public void ResetExpiryWarnsOnEntryWithinTwentyFourHoursAndDeduplicatesByExpiry( primaryRemaining: null, secondaryRemaining: null, resetCredits: new RateLimitResetCredits(1, [new RateLimitResetCredit("Changed title", "available", expiry)]), - updatedAt: Now), now: Now.AddHours(2))); + updatedAt: Now.AddHours(2)), now: Now.AddHours(2))); Assert.Empty(Evaluate(evaluator, Presentation( primaryRemaining: null, secondaryRemaining: null, resetCredits: new RateLimitResetCredits(1, [new RateLimitResetCredit("Used", "used", expiry)]), - updatedAt: Now), now: Now.AddHours(2))); + updatedAt: Now.AddHours(2)), now: Now.AddHours(2))); } [Fact] From 1ee38a90a249059bfd6ae23974e8c7290b88aed4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:48:35 +0200 Subject: [PATCH 06/21] Add retained history, workday planning, and explicit account actions --- CHANGELOG.md | 3 + .../OptionalActionServiceTests.cs | 376 +++++++++++ .../PlanningHistoryIntegrationTests.cs | 215 ++++++ .../TaskAndResetProtocolTests.cs | 331 ++++++++++ CodexUsageDock.Tests/TestEnvironment.cs | 8 +- .../UsageAggregateStoreTests.cs | 258 ++++++++ CodexUsageDock.Tests/UsagePlanningTests.cs | 326 +++++++++ CodexUsageDock/CodexAppServerReader.cs | 146 +++- .../CodexUsageDockCommandsProvider.cs | 16 + CodexUsageDock/CodexUsageService.Actions.cs | 191 ++++++ .../CodexUsageService.Aggregates.cs | 68 ++ CodexUsageDock/CodexUsageService.cs | 10 +- CodexUsageDock/Pages/CodexActionsPage.cs | 111 ++++ CodexUsageDock/Pages/CodexHistoryPage.cs | 68 ++ CodexUsageDock/Pages/CodexPlanningPage.cs | 77 +++ .../Pages/CodexUsageDockSettingsPage.cs | 36 + CodexUsageDock/ResetAttemptJournal.cs | 36 + CodexUsageDock/ResetCreditData.cs | 49 ++ CodexUsageDock/TaskUsageData.cs | 130 ++++ CodexUsageDock/UsageAggregateStore.cs | 624 ++++++++++++++++++ CodexUsageDock/UsagePlanning.cs | 596 +++++++++++++++++ PRIVACY.md | 6 +- README.md | 10 + SPRINTS.md | 12 +- 24 files changed, 3692 insertions(+), 11 deletions(-) create mode 100644 CodexUsageDock.Tests/OptionalActionServiceTests.cs create mode 100644 CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs create mode 100644 CodexUsageDock.Tests/TaskAndResetProtocolTests.cs create mode 100644 CodexUsageDock.Tests/UsageAggregateStoreTests.cs create mode 100644 CodexUsageDock.Tests/UsagePlanningTests.cs create mode 100644 CodexUsageDock/CodexUsageService.Actions.cs create mode 100644 CodexUsageDock/CodexUsageService.Aggregates.cs create mode 100644 CodexUsageDock/Pages/CodexActionsPage.cs create mode 100644 CodexUsageDock/Pages/CodexHistoryPage.cs create mode 100644 CodexUsageDock/Pages/CodexPlanningPage.cs create mode 100644 CodexUsageDock/ResetAttemptJournal.cs create mode 100644 CodexUsageDock/ResetCreditData.cs create mode 100644 CodexUsageDock/TaskUsageData.cs create mode 100644 CodexUsageDock/UsageAggregateStore.cs create mode 100644 CodexUsageDock/UsagePlanning.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abe7cb..3162838 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ Each entry links to the commit or pull request that introduced the change. ### Added +- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. +- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. +- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) diff --git a/CodexUsageDock.Tests/OptionalActionServiceTests.cs b/CodexUsageDock.Tests/OptionalActionServiceTests.cs new file mode 100644 index 0000000..97786d7 --- /dev/null +++ b/CodexUsageDock.Tests/OptionalActionServiceTests.cs @@ -0,0 +1,376 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class OptionalActionServiceTests : IDisposable +{ + private const string AccountA = "synthetic-account-a"; + private const string AccountB = "synthetic-account-b"; + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + private string JournalPath => _environment.PathFor("optional-reset.json"); + + [Fact] + public async Task AmbiguousResetSurvivesRestartAndRetriesTheSameKeyWithoutAnotherReportedCredit() + { + string? firstKey = null; + using (var first = CreateService(() => Quota(AccountA, 1), reset: (_, _, key, _) => + { + firstKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Ambiguous, Now)); + })) + { + await first.RefreshAsync().WaitAsync(Timeout); + await first.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + Assert.NotNull(firstKey); + Assert.Equal(firstKey, ReadPendingKey(AccountA)); + } + + string? retriedKey = null; + using var restarted = CreateService(() => Quota(AccountA, 0), reset: (_, _, key, _) => + { + retriedKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.AlreadyRedeemed, Now)); + }); + await restarted.RefreshAsync().WaitAsync(Timeout); + await restarted.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(firstKey, retriedKey); + Assert.Null(ReadPendingKey(AccountA)); + Assert.Contains("No second reset", restarted.ResetActionStatus, StringComparison.Ordinal); + } + + [Fact] + public async Task FailedRetryPreflightRetainsAnEarlierAmbiguousKey() + { + var keys = new List(); + var call = 0; + var quota = Quota(AccountA, 1); + using var service = CreateService(() => quota, reset: (_, _, key, _) => + { + keys.Add(key); + var outcome = ++call switch + { + 1 => ResetCreditOutcome.Ambiguous, + 2 => ResetCreditOutcome.Unavailable, + _ => ResetCreditOutcome.AlreadyRedeemed, + }; + return Task.FromResult(new ResetCreditResult(outcome, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + var original = ReadPendingKey(AccountA); + + quota = Quota(AccountA, null); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + Assert.Equal(original, ReadPendingKey(AccountA)); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(3, keys.Count); + Assert.All(keys, key => Assert.Equal(original, key)); + Assert.Null(ReadPendingKey(AccountA)); + } + + [Fact] + public async Task ConcurrentResetRequestsShareOneCallAndOneSavedKey() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + using var service = CreateService(() => Quota(AccountA, 2), reset: (_, _, _, _) => + { + Interlocked.Increment(ref calls); + started.TrySetResult(); + return result.Task; + }); + await service.RefreshAsync().WaitAsync(Timeout); + var first = service.ConsumeEarnedResetAsync(AccountA); + await started.Task.WaitAsync(Timeout); + var second = service.ConsumeEarnedResetAsync(AccountA); + var third = service.ConsumeEarnedResetAsync(AccountA); + Assert.Same(first, second); + Assert.Same(first, third); + Assert.NotNull(ReadPendingKey(AccountA)); + + result.SetResult(new(ResetCreditOutcome.Reset, Now)); + await Task.WhenAll(first, second, third).WaitAsync(Timeout); + Assert.Equal(1, calls); + Assert.Null(ReadPendingKey(AccountA)); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public async Task UnknownOrZeroCreditsDoNotCreateANewResetAttempt(int? available) + { + var calls = 0; + using var service = CreateService(() => Quota(AccountA, available), reset: (_, _, _, _) => + { + calls++; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Null(ReadPendingKey(AccountA)); + Assert.False(File.Exists(LocalStorage.ContextPath(JournalPath, AccountA))); + } + + [Theory] + [InlineData(null)] + [InlineData(0)] + public async Task PendingResetCanBeRetriedWhenNoCreditCountIsAvailable(int? available) + { + var original = Guid.Parse("12345678-abcd-4321-abcd-1234567890ab").ToString("N"); + Assert.True(new ResetAttemptJournal(JournalPath).Save(AccountA, original)); + string? sentKey = null; + using var service = CreateService(() => Quota(AccountA, available), reset: (_, _, key, _) => + { + sentKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.AlreadyRedeemed, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(original, sentKey); + Assert.Null(ReadPendingKey(AccountA)); + } + + [Fact] + public async Task ExistingOpaqueKeyKeepsItsExactCasingDuringRetry() + { + var original = Guid.Parse("abcdef12-abcd-4321-abcd-1234567890ab").ToString("N").ToUpperInvariant(); + Assert.True(new ResetAttemptJournal(JournalPath).Save(AccountA, original)); + string? sentKey = null; + using var service = CreateService(() => Quota(AccountA, 0), reset: (_, _, key, _) => + { + sentKey = key; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Ambiguous, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(original, sentKey); + Assert.Equal(original, ReadPendingKey(AccountA)); + } + + [Fact] + public async Task PendingKeyIsDurablySavedBeforeTheConsumerIsCalled() + { + string? sentKey = null; + string? savedAtCall = null; + var recordReadableAtCall = false; + using var service = CreateService(() => Quota(AccountA, 1), reset: (_, account, key, _) => + { + sentKey = key; + recordReadableAtCall = new ResetAttemptJournal(JournalPath).TryRead(account, out savedAtCall); + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Ambiguous, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.True(recordReadableAtCall); + Assert.NotNull(sentKey); + Assert.Equal(sentKey, savedAtCall); + Assert.Equal(sentKey, ReadPendingKey(AccountA)); + } + + [Theory] + [InlineData("invalid-json")] + [InlineData("[]")] + [InlineData("{\"schemaVersion\":\"1\",\"pendingKey\":null}")] + [InlineData("{\"schemaVersion\":2,\"pendingKey\":null}")] + [InlineData("{\"schemaVersion\":1,\"pendingKey\":\"invalid-key\"}")] + public async Task InvalidJournalBlocksTheActionWithoutOverwritingRecoveryData(string contents) + { + var path = LocalStorage.ContextPath(JournalPath, AccountA); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, contents); + var calls = 0; + using var service = CreateService(() => Quota(AccountA, 1), reset: (_, _, _, _) => + { + calls++; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Equal(contents, File.ReadAllText(path)); + Assert.Contains("No reset request was sent", service.ResetActionStatus, StringComparison.Ordinal); + } + + [Fact] + public async Task JournalWriteFailurePreventsTheConsumerFromRunning() + { + Directory.CreateDirectory(LocalStorage.ContextPath(JournalPath, AccountA)); + var calls = 0; + using var service = CreateService(() => Quota(AccountA, 1), reset: (_, _, _, _) => + { + calls++; + return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); + }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Contains("could not be saved", service.ResetActionStatus, StringComparison.Ordinal); + } + + [Fact] + public async Task LateResetResultDoesNotReplaceTheNewAccountStatus() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var quota = Quota(AccountA, 1); + using var service = CreateService(() => quota, reset: (_, _, _, _) => + { + started.TrySetResult(); + return pending.Task; + }); + await service.RefreshAsync().WaitAsync(Timeout); + var action = service.ConsumeEarnedResetAsync(AccountA); + await started.Task.WaitAsync(Timeout); + quota = Quota(AccountB, 1); + await service.RefreshAsync().WaitAsync(Timeout); + var newAccountStatus = service.ResetActionStatus; + pending.SetResult(new(ResetCreditOutcome.Reset, Now)); + await action.WaitAsync(Timeout); + + Assert.Equal(AccountB, service.Current.AccountKey); + Assert.Equal(newAccountStatus, service.ResetActionStatus); + Assert.Null(ReadPendingKey(AccountA)); + Assert.Null(ReadPendingKey(AccountB)); + } + + [Fact] + public async Task LateTaskResultIsDiscardedAfterAnAccountChange() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var quota = Quota(AccountA, 1); + using var service = CreateService(() => quota, thread: (_, _, _, _) => + { + started.TrySetResult(); + return pending.Task; + }); + await service.RefreshAsync().WaitAsync(Timeout); + var action = service.ReadTaskUsageAsync("task-a"); + await started.Task.WaitAsync(Timeout); + quota = Quota(AccountB, 1); + await service.RefreshAsync().WaitAsync(Timeout); + var newAccountStatus = service.ThreadActionStatus; + pending.SetResult(TaskResult("task-a", AccountA)); + await action.WaitAsync(Timeout); + + Assert.Null(service.CurrentThreadUsage); + Assert.Equal(newAccountStatus, service.ThreadActionStatus); + } + + [Fact] + public async Task LatestRequestedTaskIsQueuedAndAnOlderTaskIsNeverPublishedAsItsResult() + { + var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondResult = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var published = new ConcurrentQueue(); + var calls = new ConcurrentQueue(); + using var service = CreateService(() => Quota(AccountA, 1), thread: (_, id, _, _) => + { + calls.Enqueue(id); + if (id == "task-a") + { + firstStarted.TrySetResult(); + return firstResult.Task; + } + secondStarted.TrySetResult(); + return secondResult.Task; + }); + service.Updated += (_, _) => + { + if (service.CurrentThreadUsage?.ThreadId is { } id) published.Enqueue(id); + }; + await service.RefreshAsync().WaitAsync(Timeout); + var first = service.ReadTaskUsageAsync("task-a"); + await firstStarted.Task.WaitAsync(Timeout); + var second = service.ReadTaskUsageAsync("task-b"); + firstResult.SetResult(TaskResult("task-a", AccountA)); + await secondStarted.Task.WaitAsync(Timeout); + Assert.Null(service.CurrentThreadUsage); + Assert.DoesNotContain("task-a", published); + secondResult.SetResult(TaskResult("task-b", AccountA)); + await Task.WhenAll(first, second).WaitAsync(Timeout); + + Assert.Collection(calls, id => Assert.Equal("task-a", id), id => Assert.Equal("task-b", id)); + Assert.Equal("task-b", service.CurrentThreadUsage!.ThreadId); + Assert.DoesNotContain("task-a", published); + } + + [Theory] + [InlineData(null, "task-a")] + [InlineData(AccountB, "task-a")] + [InlineData(AccountA, "foreign-task")] + public async Task TaskResponsesWithoutMatchingAccountAndTaskAreNotUsed(string? responseAccount, string responseTask) + { + using var service = CreateService(() => Quota(AccountA, 1), thread: (_, _, _, _) => + Task.FromResult(TaskResult(responseTask, responseAccount))); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ReadTaskUsageAsync("task-a").WaitAsync(Timeout); + + Assert.Null(service.CurrentThreadUsage); + Assert.DoesNotContain("estimate reported", service.ThreadActionStatus, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task StaleOrUnidentifiedUsageDoesNotAuthorizeResetOrTaskRequests() + { + var calls = 0; + var quota = Quota(null, 1); + using var service = CreateService(() => quota, + reset: (_, _, _, _) => { calls++; return Task.FromResult(new ResetCreditResult(ResetCreditOutcome.Reset, Now)); }, + thread: (_, id, account, _) => { calls++; return Task.FromResult(TaskResult(id, account)); }); + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + await service.ReadTaskUsageAsync("task-a").WaitAsync(Timeout); + quota = Quota(AccountA, 1) with { UpdatedAt = Now.AddHours(-1) }; + await service.RefreshAsync().WaitAsync(Timeout); + await service.ConsumeEarnedResetAsync(AccountA).WaitAsync(Timeout); + await service.ReadTaskUsageAsync("task-a").WaitAsync(Timeout); + + Assert.Equal(0, calls); + Assert.Null(service.CurrentThreadUsage); + Assert.Null(ReadPendingKey(AccountA)); + } + + private CodexUsageService CreateService(Func quota, + Func>? reset = null, + Func>? thread = null) + { + var service = _environment.CreateService(_ => Task.FromResult(quota()), () => quota(), clock: () => Now); + service.InitializeOptionalFeatures(_environment.PathFor("optional-aggregates.json"), JournalPath, reset, thread); + return service; + } + + private string? ReadPendingKey(string account) + { + Assert.True(new ResetAttemptJournal(JournalPath).TryRead(account, out var key)); + return key; + } + + private static CodexUsageSnapshot Quota(string? account, int? resets) => new( + null, new RateLimitWindow(20, 10080, Now.AddDays(3)), null, null, + resets is { } count ? new RateLimitResetCredits(count, null) : null, + Now, UsageDataSource.AppServer, null, AccountKey: account, DefaultBucketId: "codex"); + + private static ThreadUsageSnapshot TaskResult(string id, string? account) => + new(id, account, Now, ThreadUsageStatus.Available, EstimatedUsageCreditsMicros: 100, Groups: []); +} diff --git a/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs b/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs new file mode 100644 index 0000000..b560819 --- /dev/null +++ b/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class PlanningHistoryIntegrationTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void PlanningPreferencesRoundTripAndExposeSafeDefaults() + { + var first = _environment.CreateSettings(); + + SubmitSettings(first, new Dictionary + { + ["historyRetentionDays"] = "90", + ["workdayEnd"] = "18:30", + ["remainingWorkdays"] = "5", + }); + + Assert.Equal(90, first.HistoryRetentionDays); + Assert.Equal(new TimeOnly(18, 30), first.WorkdayEnd); + Assert.Equal(5, first.RemainingWorkdays); + + var restarted = _environment.CreateSettings(); + Assert.Equal(90, restarted.HistoryRetentionDays); + Assert.Equal(new TimeOnly(18, 30), restarted.WorkdayEnd); + Assert.Equal(5, restarted.RemainingWorkdays); + + File.Delete(_environment.PathFor("settings.json")); + var defaults = _environment.CreateSettings(); + Assert.Equal(0, defaults.HistoryRetentionDays); + Assert.Equal(new TimeOnly(17, 0), defaults.WorkdayEnd); + Assert.Equal(1, defaults.RemainingWorkdays); + } + + [Fact] + public void InvalidPlanningPreferencesUseSafeDefaults() + { + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + ["historyRetentionDays"] = "365", + ["workdayEnd"] = "25:61", + ["remainingWorkdays"] = "0", + })); + + var settings = _environment.CreateSettings(); + + Assert.Equal(0, settings.HistoryRetentionDays); + Assert.Equal(new TimeOnly(17, 0), settings.WorkdayEnd); + Assert.Equal(1, settings.RemainingWorkdays); + } + + [Fact] + public void AggregateRetentionIsOptInAndPausingDoesNotAppend() + { + var now = Now; + using var service = CreateService(() => now); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + var paused = service.GetAggregateHistory(); + Assert.Equal(0, paused.RetentionDays); + Assert.True(paused.Identified); + Assert.Empty(paused.Points); + + service.SetAggregateRetentionDays(30); + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-a", "default", 70, now), now); + var enabled = service.GetAggregateHistory(); + Assert.Equal(30, enabled.RetentionDays); + Assert.Equal(70, Assert.Single(enabled.Points).PrimaryRemainingPercent); + + service.SetAggregateRetentionDays(0); + now = Now.AddMinutes(12); + service.RecordHistory(Snapshot("account-a", "default", 60, now), now); + var pausedAgain = service.GetAggregateHistory(); + Assert.Equal(0, pausedAgain.RetentionDays); + var retained = Assert.Single(pausedAgain.Points); + Assert.Equal(70, retained.PrimaryRemainingPercent); + } + + [Fact] + public void AggregateHistoryIsSeparatedByAccountAndCategory() + { + var now = Now; + using var service = CreateService(() => now); + service.SetAggregateRetentionDays(30); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + Assert.Equal(80, Assert.Single(service.GetAggregateHistory().Points).PrimaryRemainingPercent); + + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-b", "default", 20, now), now); + Assert.Equal(20, Assert.Single(service.GetAggregateHistory().Points).PrimaryRemainingPercent); + + now = Now.AddMinutes(12); + service.RecordHistory(Snapshot("account-a", "review", 50, now), now); + Assert.Equal(50, Assert.Single(service.GetAggregateHistory().Points).PrimaryRemainingPercent); + + // Revisit the original scope with its original measurement. The store + // must reload account-a/default rather than exposing the review or b data. + service.RecordHistory(Snapshot("account-a", "default", 80, Now), Now); + var restored = service.GetAggregateHistory(); + Assert.Equal(80, Assert.Single(restored.Points).PrimaryRemainingPercent); + Assert.True(restored.Identified); + } + + [Fact] + public void ExplicitExportIsScopedAndContainsNoIdentifiers() + { + var now = Now; + using var service = CreateService(() => now); + service.SetAggregateRetentionDays(30); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-b", "default", 20, now), now); + + var result = service.ExportAggregateHistory(csv: false); + const string prefix = "Export saved to "; + Assert.StartsWith(prefix, result, StringComparison.Ordinal); + var exportPath = result[prefix.Length..]; + Assert.True(File.Exists(exportPath)); + + var content = File.ReadAllText(exportPath); + using var document = JsonDocument.Parse(content); + var observations = document.RootElement.GetProperty("observations"); + var observation = Assert.Single(observations.EnumerateArray()); + Assert.Equal(20, observation.GetProperty("primaryRemainingPercent").GetDouble()); + Assert.DoesNotContain("account-a", content, StringComparison.Ordinal); + Assert.DoesNotContain("account-b", content, StringComparison.Ordinal); + Assert.DoesNotContain("default", content, StringComparison.Ordinal); + Assert.DoesNotContain("accountKey", content, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("bucketId", content, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ContextGuardsKeepTheCurrentAccountHistoryAndBlockStaleActions() + { + var now = Now; + using var service = CreateService(() => now); + service.SetAggregateRetentionDays(30); + + service.RecordHistory(Snapshot("account-a", "default", 80, now), now); + var accountA = service.GetAggregateHistory(); + Assert.NotNull(accountA.Context); + + now = Now.AddMinutes(6); + service.RecordHistory(Snapshot("account-b", "default", 20, now), now); + var accountB = service.GetAggregateHistory(); + Assert.NotEqual(accountA.Context, accountB.Context); + Assert.Equal(20, Assert.Single(accountB.Points).PrimaryRemainingPercent); + + Assert.False(service.ClearAggregateHistory(accountA.Context)); + var exportDirectory = Path.Combine( + Path.GetDirectoryName(_environment.PathFor("aggregates.json"))!, + "exports"); + Assert.False(Directory.Exists(exportDirectory)); + + var staleExport = service.ExportAggregateHistory(csv: false, expectedContext: accountA.Context); + Assert.Contains("changed", staleExport, StringComparison.OrdinalIgnoreCase); + Assert.False(Directory.Exists(exportDirectory)); + + var preserved = service.GetAggregateHistory(); + Assert.Equal(accountB.Context, preserved.Context); + Assert.Equal(20, Assert.Single(preserved.Points).PrimaryRemainingPercent); + } + + private CodexUsageService CreateService(Func clock) => _environment.CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), + () => CodexUsageSnapshot.Loading, + clock: clock); + + private static CodexUsageSnapshot Snapshot( + string account, + string category, + double remaining, + DateTimeOffset updatedAt) => new( + new RateLimitWindow(100 - remaining, 300, updatedAt.AddHours(4)), + new RateLimitWindow(100 - remaining, 10080, updatedAt.AddDays(6)), + "pro", + null, + null, + updatedAt, + UsageDataSource.AppServer, + null, + AccountKey: account, + DefaultBucketId: category); + + private static void SubmitSettings( + CodexUsageDockSettingsPage page, + IReadOnlyDictionary values) + { + var payload = new Dictionary + { + ["historyRetentionDays"] = "0", + ["workdayEnd"] = "17:00", + ["remainingWorkdays"] = "1", + }; + + foreach (var pair in values) + { + payload[pair.Key] = pair.Value; + } + + var form = page.GetContent().OfType().Last(); + form.SubmitForm(JsonSerializer.Serialize(payload), "{}"); + } +} diff --git a/CodexUsageDock.Tests/TaskAndResetProtocolTests.cs b/CodexUsageDock.Tests/TaskAndResetProtocolTests.cs new file mode 100644 index 0000000..aa6c029 --- /dev/null +++ b/CodexUsageDock.Tests/TaskAndResetProtocolTests.cs @@ -0,0 +1,331 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class TaskAndResetProtocolTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly string AccountKey = AccountScope("synthetic-account"); + private const string TaskId = "synthetic-thread-123"; + private const string ResetKey = "3d2054c5-d593-401d-84e4-bd8f733341cf"; + private const string AccountResponse = """{"result":{"accountId":"synthetic-account"}}"""; + private const string TaskResponse = """ + {"result":{"threadUsage":{"threadId":"synthetic-thread-123","estimatedUsageCreditsMicros":123456,"estimatedUsageUsdMicros":null,"groups":[]}}} + """; + + [Fact] + public void TaskUsagePreservesEstimatesAndNullableTokenClasses() + { + var snapshot = ParseThread(""" + { + "threadUsage": { + "threadId": "synthetic-thread-123", + "estimatedUsageCreditsMicros": 1234567, + "estimatedUsageUsdMicros": 12000, + "groups": [{ + "model": "synthetic-model", "reasoningEffort": "high", "speed": "fast", + "estimatedUsageCreditsMicros": 1234567, + "netNewInputTokens": 100, "cachedInputTokens": null, + "inputTokens": 150, "outputTokens": 20, "totalTokens": 170 + }] + } + } + """); + + Assert.Equal(ThreadUsageStatus.Available, snapshot.Status); + Assert.Equal(TaskId, snapshot.ThreadId); + Assert.Equal(AccountKey, snapshot.AccountKey); + Assert.Equal(1234567, snapshot.EstimatedUsageCreditsMicros); + Assert.Equal(12000, snapshot.EstimatedUsageUsdMicros); + var group = Assert.Single(snapshot.Groups!); + Assert.Equal("synthetic-model", group.Model); + Assert.Equal("high", group.ReasoningEffort); + Assert.Equal("fast", group.Speed); + Assert.Equal(100, group.NetNewInputTokens); + Assert.Null(group.CachedInputTokens); + Assert.Equal(150, group.InputTokens); + Assert.Equal(20, group.OutputTokens); + Assert.Equal(170, group.TotalTokens); + } + + [Fact] + public void InvalidTaskValuesAreUnknownAndLabelsAreSanitized() + { + var snapshot = ParseThread(""" + {"threadUsage": { + "threadId":"synthetic-thread-123", "estimatedUsageCreditsMicros":-1, "estimatedUsageUsdMicros":9223372036854775808, + "groups":[null,{}, {"model":" Model\n one ","reasoningEffort":[],"speed":"fast\tmode", + "estimatedUsageCreditsMicros":"100","netNewInputTokens":-2,"inputTokens":0,"outputTokens":null}] + }} + """); + + Assert.Equal(ThreadUsageStatus.Partial, snapshot.Status); + Assert.Null(snapshot.EstimatedUsageCreditsMicros); + Assert.Null(snapshot.EstimatedUsageUsdMicros); + var group = Assert.Single(snapshot.Groups!); + Assert.Equal("Model one", group.Model); + Assert.Null(group.ReasoningEffort); + Assert.Equal("fast mode", group.Speed); + Assert.Null(group.EstimatedUsageCreditsMicros); + Assert.Null(group.NetNewInputTokens); + Assert.Equal(0, group.InputTokens); + Assert.Null(group.TotalTokens); + } + + [Fact] + public void TaskGroupsAndLabelsAreBounded() + { + var snapshot = ParseThread(JsonSerializer.Serialize(new + { + threadUsage = new + { + threadId = TaskId, + estimatedUsageCreditsMicros = 0, + groups = Enumerable.Range(0, 70).Select(index => new + { + model = new string('m', 100) + index, + reasoningEffort = new string('e', 50), + speed = new string('s', 50), + totalTokens = index, + }), + }, + })); + + Assert.Equal(ThreadUsageStatus.Partial, snapshot.Status); + Assert.Equal(64, snapshot.Groups!.Count); + Assert.All(snapshot.Groups, group => + { + Assert.Equal(80, group.Model!.Length); + Assert.Equal(32, group.ReasoningEffort!.Length); + Assert.Equal(32, group.Speed!.Length); + }); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"threadUsage\":null}")] + [InlineData("{\"threadUsage\":{\"threadId\":\"foreign-thread\",\"estimatedUsageCreditsMicros\":100,\"groups\":[]}}")] + [InlineData("{\"threadUsage\":{\"threadId\":\"synthetic-thread-123\",\"estimatedUsageCreditsMicros\":null,\"groups\":[]}}")] + public void MissingOrForeignTaskUsageDoesNotProduceAnEstimate(string json) + { + var snapshot = ParseThread(json); + Assert.Equal(ThreadUsageStatus.Unavailable, snapshot.Status); + Assert.Null(snapshot.EstimatedUsageCreditsMicros); + Assert.Null(snapshot.Groups); + } + + [Fact] + public async Task TaskReadSendsTheSelectedIdBetweenMatchingAccountChecks() + { + var calls = new List(); + var result = await CodexAppServerReader.ReadThreadUsageSequenceAsync((method, id, parameters, _) => + { + calls.Add(CodexAppServerReader.CreateRequestJson(method, id, parameters)); + return Task.FromResult(JsonDocument.Parse(method == "account/usage/read" ? TaskResponse : AccountResponse)); + }, TaskId, AccountKey); + + Assert.Equal(3, calls.Count); + using var before = JsonDocument.Parse(calls[0]); + using var usage = JsonDocument.Parse(calls[1]); + using var after = JsonDocument.Parse(calls[2]); + Assert.Equal("account/rateLimits/read", before.RootElement.GetProperty("method").GetString()); + Assert.Equal("account/usage/read", usage.RootElement.GetProperty("method").GetString()); + Assert.Equal(TaskId, usage.RootElement.GetProperty("params").GetProperty("threadId").GetString()); + Assert.Equal("account/rateLimits/read", after.RootElement.GetProperty("method").GetString()); + Assert.Equal(ThreadUsageStatus.Available, result.Status); + Assert.Equal(123456, result.EstimatedUsageCreditsMicros); + } + + [Fact] + public async Task TaskReadRejectsAnAccountChangeBeforeOrAfterTheUsageRequest() + { + var beforeCalls = 0; + var beforeMismatch = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, _, _, _) => + { + beforeCalls++; + return Task.FromResult(JsonDocument.Parse(AccountResponse)); + }, TaskId, AccountScope("other-account")); + Assert.Equal(1, beforeCalls); + Assert.Equal(ThreadUsageStatus.AccountMismatch, beforeMismatch.Status); + + var afterMismatch = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, id, _, _) => + Task.FromResult(JsonDocument.Parse(id switch + { + 2 => AccountResponse, + 3 => TaskResponse, + _ => """{"result":{"accountId":"other-account"}}""", + })), TaskId, AccountKey); + Assert.Equal(ThreadUsageStatus.AccountMismatch, afterMismatch.Status); + Assert.Null(afterMismatch.EstimatedUsageCreditsMicros); + Assert.Null(afterMismatch.AccountKey); + } + + [Fact] + public async Task UnsupportedTaskMethodRemainsDistinctFromUnavailableUsage() + { + var result = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, id, _, _) => + Task.FromResult(JsonDocument.Parse(id == 2 ? AccountResponse + : """{"error":{"code":-32601,"message":"private error data"}}""")), TaskId, AccountKey); + Assert.Equal(ThreadUsageStatus.Unsupported, result.Status); + Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData("thread\nrequest")] + [InlineData("thread with spaces")] + public async Task InvalidTaskIdsDoNotSendRequests(string id) + { + var result = await CodexAppServerReader.ReadThreadUsageSequenceAsync((_, _, _, _) => + throw new InvalidOperationException("Must not send"), id, AccountKey); + Assert.Equal(ThreadUsageStatus.Unavailable, result.Status); + } + + [Theory] + [InlineData("reset", (int)ResetCreditOutcome.Reset)] + [InlineData("alreadyRedeemed", (int)ResetCreditOutcome.AlreadyRedeemed)] + [InlineData("nothingToReset", (int)ResetCreditOutcome.NothingToReset)] + [InlineData("noCredit", (int)ResetCreditOutcome.NoCredit)] + [InlineData("unknown-outcome", (int)ResetCreditOutcome.Ambiguous)] + public void ResetParserPreservesTerminalServerOutcomes(string value, int expected) + { + using var document = JsonDocument.Parse(JsonSerializer.Serialize(new { result = new { outcome = value } })); + var result = ResetCreditParser.Parse(document.RootElement, Now); + Assert.Equal((ResetCreditOutcome)expected, result.Outcome); + Assert.Equal(Now, result.AttemptedAt); + } + + [Theory] + [InlineData("null")] + [InlineData("{}")] + [InlineData("{\"result\":{\"outcome\":null}}")] + [InlineData("{\"error\":{\"code\":-32603,\"message\":\"private details\"}}")] + [InlineData("{\"error\":{\"code\":-32601},\"result\":{\"outcome\":\"reset\"}}")] + public void UncertainResetRepliesRemainAmbiguous(string json) + { + using var document = JsonDocument.Parse(json); + var result = ResetCreditParser.Parse(document.RootElement, Now); + Assert.Equal(ResetCreditOutcome.Ambiguous, result.Outcome); + Assert.DoesNotContain("private", JsonSerializer.Serialize(result), StringComparison.Ordinal); + } + + [Fact] + public async Task ResetChecksTheExpectedAccountBeforeSendingTheCallerKey() + { + var requests = new List(); + var result = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((method, id, parameters, _) => + { + requests.Add(CodexAppServerReader.CreateRequestJson(method, id, parameters)); + return Task.FromResult(JsonDocument.Parse(id == 2 ? AccountResponse : """{"result":{"outcome":"reset"}}""")); + }, AccountKey, ResetKey); + + Assert.Equal(2, requests.Count); + using var before = JsonDocument.Parse(requests[0]); + using var consume = JsonDocument.Parse(requests[1]); + Assert.Equal("account/rateLimits/read", before.RootElement.GetProperty("method").GetString()); + Assert.Equal("account/rateLimitResetCredit/consume", consume.RootElement.GetProperty("method").GetString()); + Assert.Equal(ResetKey, consume.RootElement.GetProperty("params").GetProperty("idempotencyKey").GetString()); + Assert.False(consume.RootElement.GetProperty("params").TryGetProperty("creditId", out _)); + Assert.Equal(ResetCreditOutcome.Reset, result.Outcome); + } + + [Fact] + public async Task ResetAccountMismatchPreventsTheMutation() + { + var calls = 0; + var result = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + { + calls++; + return Task.FromResult(JsonDocument.Parse(AccountResponse)); + }, AccountScope("other-account"), ResetKey); + Assert.Equal(1, calls); + Assert.Equal(ResetCreditOutcome.AccountMismatch, result.Outcome); + + var unverified = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + throw new InvalidOperationException("Must not send"), "unverified", ResetKey); + Assert.Equal(ResetCreditOutcome.AccountMismatch, unverified.Outcome); + } + + [Fact] + public async Task FailedPreflightIsSafeButFailureAfterConsumeRemainsAmbiguousAndKeepsTheKey() + { + var unavailable = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + Task.FromException(new IOException("before consume")), AccountKey, ResetKey); + Assert.Equal(ResetCreditOutcome.Unavailable, unavailable.Outcome); + + var keys = new List(); + var failed = await Attempt(throwAfterSend: true); + Assert.Equal(ResetCreditOutcome.Ambiguous, failed.Outcome); + Assert.Single(keys); + var retried = await Attempt(throwAfterSend: false); + Assert.Equal(ResetCreditOutcome.AlreadyRedeemed, retried.Outcome); + Assert.Equal(new[] { ResetKey, ResetKey }, keys); + + Task Attempt(bool throwAfterSend) => + CodexAppServerReader.ConsumeResetCreditSequenceAsync((method, id, parameters, _) => + { + if (id == 2) return Task.FromResult(JsonDocument.Parse(AccountResponse)); + using var request = JsonDocument.Parse(CodexAppServerReader.CreateRequestJson(method, id, parameters)); + keys.Add(request.RootElement.GetProperty("params").GetProperty("idempotencyKey").GetString()!); + return throwAfterSend + ? Task.FromException(new IOException("after possible send")) + : Task.FromResult(JsonDocument.Parse("""{"result":{"outcome":"alreadyRedeemed"}}""")); + }, AccountKey, ResetKey); + } + + [Fact] + public async Task CancellationAfterConsumeBeginsIsAmbiguous() + { + using var cancellation = new CancellationTokenSource(); + var result = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, id, _, _) => + { + if (id == 2) return Task.FromResult(JsonDocument.Parse(AccountResponse)); + cancellation.Cancel(); + return Task.FromException(new OperationCanceledException(cancellation.Token)); + }, AccountKey, ResetKey, cancellation.Token); + Assert.Equal(ResetCreditOutcome.Ambiguous, result.Outcome); + } + + [Fact] + public async Task UnsupportedResetAndCancellationBeforeConsumeDoNotClaimAmbiguousConsumption() + { + var unsupported = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, id, _, _) => + Task.FromResult(JsonDocument.Parse(id == 2 ? AccountResponse : """{"error":{"code":-32601}}""")), AccountKey, ResetKey); + Assert.Equal(ResetCreditOutcome.Unsupported, unsupported.Outcome); + + using var cancellation = new CancellationTokenSource(); + var calls = 0; + var canceled = await CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + { + calls++; + cancellation.Cancel(); + return Task.FromResult(JsonDocument.Parse(AccountResponse)); + }, AccountKey, ResetKey, cancellation.Token); + Assert.Equal(1, calls); + Assert.Equal(ResetCreditOutcome.Unavailable, canceled.Outcome); + } + + [Theory] + [InlineData("")] + [InlineData("key\nwith-control")] + [InlineData("key with space")] + public async Task InvalidIdempotencyKeysFailBeforeAnyRequest(string key) + { + await Assert.ThrowsAsync(() => + CodexAppServerReader.ConsumeResetCreditSequenceAsync((_, _, _, _) => + throw new InvalidOperationException("Must not send"), AccountKey, key)); + } + + private static ThreadUsageSnapshot ParseThread(string json) + { + using var document = JsonDocument.Parse(json); + return ThreadUsageParser.Parse(document.RootElement, TaskId, AccountKey, Now); + } + + private static string AccountScope(string accountId) + { + using var result = JsonDocument.Parse(JsonSerializer.Serialize(new { rateLimits = new { }, accountId })); + return CodexAppServerReader.ParseSnapshot(result.RootElement, default, Now).AccountKey!; + } +} diff --git a/CodexUsageDock.Tests/TestEnvironment.cs b/CodexUsageDock.Tests/TestEnvironment.cs index dd0d596..9057230 100644 --- a/CodexUsageDock.Tests/TestEnvironment.cs +++ b/CodexUsageDock.Tests/TestEnvironment.cs @@ -22,11 +22,15 @@ internal CodexUsageService CreateService( AdaptiveWeeklyUsageStore? adaptiveWeeklyUsageStore = null, Func>? localTokenUsageReader = null, Func? clock = null, - Func>? accountUsageReader = null) => - new(appServerReader, localSessionReader, + Func>? accountUsageReader = null) + { + var service = new CodexUsageService(appServerReader, localSessionReader, weeklyHistoryStore ?? new WeeklyUsageHistoryStore(PathFor("weekly.json")), adaptiveWeeklyUsageStore ?? new AdaptiveWeeklyUsageStore(PathFor("adaptive.json")), localTokenUsageReader, clock, accountUsageReader); + service.InitializeOptionalFeatures(PathFor("aggregates.json"), PathFor("reset-attempt.json")); + return service; + } internal CodexUsageService CreateService( Func> appServerReader, diff --git a/CodexUsageDock.Tests/UsageAggregateStoreTests.cs b/CodexUsageDock.Tests/UsageAggregateStoreTests.cs new file mode 100644 index 0000000..68145bf --- /dev/null +++ b/CodexUsageDock.Tests/UsageAggregateStoreTests.cs @@ -0,0 +1,258 @@ +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsageAggregateStoreTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void RetentionCanBeChangedAndIsAppliedToReloadedData() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + + Assert.True(store.Record(Point(Now.AddDays(-20)), Now, out var firstError), firstError); + Assert.True(store.Record(Point(Now.AddDays(-6)), Now, out var secondError), secondError); + Assert.Equal(2, store.Snapshot.Count); + + Assert.True(store.SetRetentionDays(7, Now, out var retentionError), retentionError); + Assert.Single(store.Snapshot); + Assert.Equal(Now.AddDays(-6), store.Snapshot[0].RecordedAt); + + var reloaded = new UsageAggregateStore(path, 7, () => Now); + Assert.Equal(7, reloaded.RetentionDays); + Assert.Single(reloaded.Snapshot); + Assert.Equal(Now.AddDays(-6), reloaded.Snapshot[0].RecordedAt); + } + + [Fact] + public void SameBucketAndResetReplacesTheLatestPoint() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + var reset = Now.AddDays(1); + + Assert.True(store.Record(Point(Now.AddMinutes(1), 80, reset), Now.AddMinutes(1))); + Assert.True(store.Record(Point(Now.AddMinutes(4), 70, reset), Now.AddMinutes(4))); + + var replaced = Assert.Single(store.Snapshot); + Assert.Equal(70, replaced.PrimaryRemainingPercent); + Assert.Equal(Now.AddMinutes(4), replaced.RecordedAt); + } + + [Fact] + public void AResetChangeWithinTheSameBucketIsPreserved() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + + Assert.True(store.Record(Point(Now.AddMinutes(1), 80, Now.AddDays(1)), Now.AddMinutes(1))); + Assert.True(store.Record(Point(Now.AddMinutes(4), 70, Now.AddDays(2)), Now.AddMinutes(4))); + + Assert.Equal(2, store.Snapshot.Count); + Assert.Equal( + [80d, 70d], + store.Snapshot.Select(observation => observation.PrimaryRemainingPercent!.Value).ToArray()); + } + + [Fact] + public void LiveFactoryRequiresKnownFreshAppServerData() + { + var reset = Now.AddHours(4); + var snapshot = new CodexUsageSnapshot( + new RateLimitWindow(20, 300, reset), + null, + "pro", + null, + null, + Now.AddMinutes(-1), + UsageDataSource.AppServer, + null, + AccountKey: "already-hashed-account-key", + DefaultBucketId: "default"); + + Assert.True(UsageAggregateStore.TryCreateLivePoint(snapshot, Now, TimeSpan.FromMinutes(1), out var point)); + Assert.NotNull(point); + Assert.Equal(UsageDataSource.AppServer, point!.Source); + Assert.Equal(80, point.PrimaryRemainingPercent); + Assert.Equal(reset, point.PrimaryResetsAt); + Assert.DoesNotContain("already-hashed-account-key", JsonSerializer.Serialize(point), StringComparison.Ordinal); + + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { AccountKey = null }, Now, TimeSpan.FromMinutes(1), out _)); + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { Source = UsageDataSource.LocalSession }, Now, TimeSpan.FromMinutes(1), out _)); + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { UpdatedAt = Now.AddMinutes(-6) }, Now, TimeSpan.FromMinutes(1), out _)); + Assert.False(UsageAggregateStore.TryCreateLivePoint( + snapshot with { UpdatedAt = Now.AddMinutes(1) }, Now, TimeSpan.FromMinutes(1), out _)); + } + + [Fact] + public void InvalidDirectObservationIsRejectedWithGenericError() + { + var store = new UsageAggregateStore(_environment.PathFor("usage-aggregates.json"), 30, () => Now); + var invalid = new UsageAggregatePoint( + Now, + double.NaN, + null, + Now.AddHours(1), + null, + UsageDataSource.AppServer); + + Assert.False(store.Record(invalid, Now, out var error)); + Assert.Equal("The usage observation was invalid and was not saved.", error); + Assert.Empty(store.Snapshot); + } + + [Fact] + public void MalformedNullAndOversizedDocumentsAreBoundedSafely() + { + var path = _environment.PathFor("usage-aggregates.json"); + File.WriteAllText(path, "null"); + + var malformed = new UsageAggregateStore(path, 30, () => Now); + Assert.Empty(malformed.Snapshot); + Assert.Contains("could not be read", malformed.StorageError, StringComparison.Ordinal); + + var points = Enumerable.Range(0, UsageAggregateStore.MaximumEntries + 5) + .Select(index => Point( + Now.AddMinutes(-index * 2), + 20 + index % 70, + Now.AddMinutes(-index * 2).AddDays(1))) + .ToArray(); + var document = new UsageAggregateStoreDocument(UsageAggregateStore.SchemaVersion, 90, points); + File.WriteAllText(path, JsonSerializer.Serialize(document)); + + var capped = new UsageAggregateStore(path, 90, () => Now); + Assert.Equal(UsageAggregateStore.MaximumEntries, capped.Snapshot.Count); + Assert.Equal(Now, capped.Snapshot[^1].RecordedAt); + Assert.Equal(Now.AddMinutes(-2 * (UsageAggregateStore.MaximumEntries - 1)), capped.Snapshot[0].RecordedAt); + } + + [Fact] + public void InvalidEntriesAndNullItemsAreIgnoredDuringReload() + { + var path = _environment.PathFor("usage-aggregates.json"); + var invalidDocument = new + { + SchemaVersion = UsageAggregateStore.SchemaVersion, + RetentionDays = 30, + Observations = new object?[] + { + null, + new + { + RecordedAt = Now, + PrimaryRemainingPercent = 101d, + WeeklyRemainingPercent = (double?)null, + PrimaryResetsAt = Now.AddHours(1), + WeeklyResetsAt = (DateTimeOffset?)null, + Source = UsageDataSource.AppServer, + }, + new + { + RecordedAt = Now.AddMinutes(1), + PrimaryRemainingPercent = 20d, + WeeklyRemainingPercent = (double?)null, + PrimaryResetsAt = Now.AddHours(1), + WeeklyResetsAt = (DateTimeOffset?)null, + Source = UsageDataSource.LocalSession, + }, + }, + }; + File.WriteAllText(path, JsonSerializer.Serialize(invalidDocument)); + + var store = new UsageAggregateStore(path, 30, () => Now); + + Assert.Empty(store.Snapshot); + } + + [Fact] + public void ExportsAreDeterministicInvariantAndPrivacyBounded() + { + var path = _environment.PathFor("usage-aggregates.json"); + var store = new UsageAggregateStore(path, 30, () => Now); + Assert.True(store.Record( + Point(Now.AddMinutes(-1), 87.5, Now.AddHours(4), weeklyRemaining: 12.25, weeklyReset: Now.AddDays(6)), + Now)); + + var json = store.ExportJson(Now); + var repeatedJson = store.ExportJson(Now); + var csv = store.ExportCsv(Now); + + Assert.Equal(json, repeatedJson); + using var parsed = JsonDocument.Parse(json); + Assert.Equal(UsageAggregateStore.SchemaVersion, parsed.RootElement.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("UTC ISO 8601", parsed.RootElement.GetProperty("units").GetProperty("timestamps").GetString()); + Assert.Contains("percent (0-100)", json, StringComparison.Ordinal); + Assert.Contains("87.5", json, StringComparison.Ordinal); + Assert.Contains("12.25", json, StringComparison.Ordinal); + Assert.Contains("2026-09-09T11:59:00.0000000Z", json, StringComparison.Ordinal); + Assert.Contains("schemaVersion=1", csv, StringComparison.Ordinal); + Assert.Contains("recordedAtUtc,primaryRemainingPercent,weeklyRemainingPercent", csv, StringComparison.Ordinal); + Assert.Contains("87.5", csv, StringComparison.Ordinal); + Assert.DoesNotContain("87,5", csv, StringComparison.Ordinal); + Assert.DoesNotContain("\"tokens\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"cost\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"account\"", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("\"session\"", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ContextStoresAreIsolatedAndDoNotPersistContextText() + { + var path = _environment.PathFor("usage-aggregates.json"); + var baseStore = new UsageAggregateStore(path, 30, () => Now); + var accountStore = baseStore.ForContext("account-id|default-category"); + + Assert.True(accountStore.Record(Point(Now.AddMinutes(-1)), Now)); + Assert.Single(accountStore.Snapshot); + Assert.Empty(baseStore.Snapshot); + + foreach (var file in Directory.EnumerateFiles( + Path.GetDirectoryName(path)!, + "*", + SearchOption.AllDirectories)) + { + Assert.DoesNotContain("account-id", file, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("default-category", file, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("account-id", File.ReadAllText(file), StringComparison.Ordinal); + Assert.DoesNotContain("default-category", File.ReadAllText(file), StringComparison.Ordinal); + } + } + + [Fact] + public void ClearOnlyRemovesTheCurrentStore() + { + var path = _environment.PathFor("usage-aggregates.json"); + var baseStore = new UsageAggregateStore(path, 30, () => Now); + var contextStore = baseStore.ForContext("known-account|default"); + + Assert.True(baseStore.Record(Point(Now.AddMinutes(-2)), Now)); + Assert.True(contextStore.Record(Point(Now.AddMinutes(-1)), Now)); + Assert.True(contextStore.Clear(out var error), error); + + Assert.Single(baseStore.Snapshot); + Assert.Empty(contextStore.Snapshot); + Assert.Single(new UsageAggregateStore(path, 30, () => Now).Snapshot); + } + + private static UsageAggregatePoint Point( + DateTimeOffset recordedAt, + double primaryRemaining = 80, + DateTimeOffset? primaryReset = null, + double? weeklyRemaining = null, + DateTimeOffset? weeklyReset = null) => new( + recordedAt, + primaryRemaining, + weeklyRemaining, + primaryReset ?? recordedAt.AddHours(1), + weeklyReset, + UsageDataSource.AppServer); +} diff --git a/CodexUsageDock.Tests/UsagePlanningTests.cs b/CodexUsageDock.Tests/UsagePlanningTests.cs new file mode 100644 index 0000000..d20d5ba --- /dev/null +++ b/CodexUsageDock.Tests/UsagePlanningTests.cs @@ -0,0 +1,326 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class UsagePlanningTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); + + [Fact] + public void FreshPlanReportsRemainingQuotaAndRatesUntilTheEarlierBoundary() + { + var desiredEnd = Now.AddHours(5); + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: 50, + secondaryRemaining: 80, + primaryReset: Now.AddHours(2), + secondaryReset: Now.AddDays(4)), + Now, + RefreshInterval, + desiredEnd, + remainingWorkdays: 3); + + Assert.True(plan.IsAvailable); + Assert.Equal(50, plan.Primary!.RemainingPercent); + Assert.Equal(TimeSpan.FromHours(2), plan.Primary.Horizon); + Assert.Equal(Now.AddHours(2), plan.Primary.HorizonEnd); + Assert.True(plan.Primary.ResetsBeforeDesiredEnd); + Assert.Equal(1, plan.Primary.Workdays); + Assert.Equal(25, plan.Primary.AvailablePointsPerHour, precision: 8); + Assert.Equal(50, plan.Primary.AvailablePointsPerWorkday, precision: 8); + Assert.Contains("resets before the desired end", plan.Primary.HorizonMessage, StringComparison.Ordinal); + + Assert.Equal(80, plan.Weekly!.RemainingPercent); + Assert.Equal(TimeSpan.FromHours(5), plan.Weekly.Horizon); + Assert.False(plan.Weekly.ResetsBeforeDesiredEnd); + Assert.Equal(3, plan.Weekly.Workdays); + Assert.Equal(80 / 3d / 5d, plan.Weekly.AvailablePointsPerHour, precision: 8); + Assert.Contains("not a guarantee", plan.Disclaimer, StringComparison.Ordinal); + Assert.Contains("not a guarantee", plan.Status, StringComparison.Ordinal); + } + + [Fact] + public void WeeklyWorkdayAssumptionAffectsDailyAndHourlyBudget() + { + var desiredEnd = Now.AddHours(5); + var fiveDayPlan = UsagePlanner.Plan( + Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), + Now, + RefreshInterval, + desiredEnd, + remainingWorkdays: 5); + var oneDayPlan = UsagePlanner.Plan( + Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), + Now, + RefreshInterval, + desiredEnd, + remainingWorkdays: 1); + + Assert.Equal(10, fiveDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); + Assert.Equal(2, fiveDayPlan.Weekly.AvailablePointsPerHour, precision: 8); + Assert.Equal(50, oneDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); + Assert.Equal(10, oneDayPlan.Weekly.AvailablePointsPerHour, precision: 8); + } + + [Fact] + public void InvalidEndOrWorkdayCountReturnsAnExplanation() + { + var presentation = Presentation(); + + var now = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now); + var zeroDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 0); + var eightDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 8); + + Assert.False(now.IsAvailable); + Assert.Contains("desired end must be after now", now.Status, StringComparison.Ordinal); + Assert.False(zeroDays.IsAvailable); + Assert.Contains("between 1 and 7", zeroDays.Status, StringComparison.Ordinal); + Assert.False(eightDays.IsAvailable); + Assert.Contains("between 1 and 7", eightDays.Status, StringComparison.Ordinal); + } + + [Fact] + public void StaleFutureUnattributedAndBlockedDataPausePlanning() + { + var desiredEnd = Now.AddHours(2); + var cases = new[] + { + (Presentation(updatedAt: Now.AddMinutes(-6)), "usage data is stale"), + (Presentation(updatedAt: Now.AddMinutes(1)), "timestamp is in the future"), + (Presentation(accountKey: null), "account identity is unavailable"), + (Presentation(ordinaryUsageAllowed: false), "ordinary usage is currently blocked"), + }; + + foreach (var (presentation, expected) in cases) + { + var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, desiredEnd); + Assert.False(plan.IsAvailable); + Assert.Contains(expected, plan.Status, StringComparison.Ordinal); + Assert.Empty(plan.Windows); + } + } + + [Fact] + public void LastConfirmedAndLoadingDataAreNotTreatedAsFreshPlans() + { + var desiredEnd = Now.AddHours(2); + var lastConfirmed = UsagePlanner.Plan( + Presentation(source: UsageDataSource.LastConfirmed), + Now, + RefreshInterval, + desiredEnd); + var loading = UsagePlanner.Plan( + Presentation(isLoading: true), + Now, + RefreshInterval, + desiredEnd); + + Assert.False(lastConfirmed.IsAvailable); + Assert.Contains("last confirmed", lastConfirmed.Status, StringComparison.Ordinal); + Assert.False(loading.IsAvailable); + Assert.Contains("loading", loading.Status, StringComparison.Ordinal); + } + + [Fact] + public void ExpiredPrimaryCanLeaveAValidWeeklyPlanAndExpiredBothPause() + { + var desiredEnd = Now.AddDays(1); + var primaryExpired = UsagePlanner.Plan( + Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddDays(4)), + Now, + RefreshInterval, + desiredEnd); + var bothExpired = UsagePlanner.Plan( + Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddMinutes(-1)), + Now, + RefreshInterval, + desiredEnd); + + Assert.True(primaryExpired.IsAvailable); + Assert.Null(primaryExpired.Primary); + Assert.NotNull(primaryExpired.Weekly); + Assert.False(bothExpired.IsAvailable); + Assert.Contains("no valid", bothExpired.Status, StringComparison.Ordinal); + } + + [Fact] + public void ForecastUsesOnlyAUsableCurrentPaceAndReportsUnavailableEvidenceWhenInsufficient() + { + var reset = Now.AddHours(4); + UsageHistoryEntry[] history = + [ + new(Now.AddMinutes(-10), 100), + new(Now, 50), + ]; + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: 50, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: history), + Now, + RefreshInterval, + Now.AddHours(1)); + + Assert.True(plan.IsAvailable); + Assert.True(plan.Primary!.Forecast.IsAvailable); + Assert.True(plan.Primary.Forecast.ReachesLimitBeforeReset); + Assert.Contains("current pace", plan.Primary.Forecast.Status, StringComparison.Ordinal); + Assert.Equal(2, plan.Primary.Evidence.MeasurementCount); + Assert.Equal(2, plan.Primary.Evidence.SegmentMeasurementCount); + Assert.False(plan.Primary.Backtest.IsAvailable); + + var insufficient = UsagePlanner.Plan( + Presentation( + primaryRemaining: 50, + secondaryRemaining: null, + primaryReset: reset, + primaryHistory: [new(Now, 50)]), + Now, + RefreshInterval, + Now.AddHours(1)); + Assert.False(insufficient.Primary!.Forecast.IsAvailable); + Assert.Contains("another measurement", insufficient.Primary.Forecast.Status, StringComparison.Ordinal); + } + + [Fact] + public void GapsBreakTheContinuousSegmentAndPauseForecast() + { + UsageHistoryEntry[] history = + [ + new(Now.AddMinutes(-40), 100), + new(Now.AddMinutes(-20), 90), + new(Now, 80), + ]; + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: 80, + secondaryRemaining: null, + primaryHistory: history), + Now, + RefreshInterval, + Now.AddHours(1)); + + Assert.Equal(3, plan.Primary!.Evidence.MeasurementCount); + Assert.Equal(1, plan.Primary.Evidence.SegmentMeasurementCount); + Assert.False(plan.Primary.Forecast.IsAvailable); + Assert.Contains("another measurement", plan.Primary.Forecast.Status, StringComparison.Ordinal); + Assert.False(plan.Primary.Backtest.IsAvailable); + } + + [Fact] + public void EvidenceDescribesMeasurementsSegmentsAdaptiveCyclesAndItsLimit() + { + UsageHistoryEntry[] history = + [ + new(Now.AddMinutes(-20), 100), + new(Now.AddMinutes(-10), 90), + new(Now, 80), + ]; + var adaptive = new AdaptiveWeeklyUsageHistory( + [new AdaptiveWeeklyUsageCycle(Now.AddDays(-7), 10080, 60, 10, [])], + new AdaptiveWeeklyUsageCycle(Now.AddDays(7), 10080, 60, 10, [])); + var plan = UsagePlanner.Plan( + Presentation( + primaryRemaining: null, + secondaryRemaining: 80, + weeklyHistory: history, + adaptiveHistory: adaptive), + Now, + RefreshInterval, + Now.AddHours(1)); + + var evidence = plan.Weekly!.Evidence; + Assert.Equal(3, evidence.MeasurementCount); + Assert.Equal(TimeSpan.FromMinutes(20), evidence.MeasurementSpan); + Assert.Equal(3, evidence.SegmentMeasurementCount); + Assert.Equal(TimeSpan.FromMinutes(20), evidence.SegmentSpan); + Assert.Equal(2, evidence.AdaptiveCycleCount); + Assert.Contains("3 measurements", evidence.Summary, StringComparison.Ordinal); + Assert.Contains("continuous segment", evidence.Summary, StringComparison.Ordinal); + Assert.Contains("adaptive weekly cycles: 2", evidence.Summary, StringComparison.Ordinal); + Assert.Contains("not a calibrated reliability probability", evidence.Summary, StringComparison.Ordinal); + } + + [Fact] + public void BacktestHoldsOutTheLatestMeasurementAndDoesNotTrainOnIt() + { + UsageHistoryEntry[] firstHistory = + [ + new(Now.AddMinutes(-20), 100), + new(Now.AddMinutes(-10), 90), + new(Now, 70), + ]; + UsageHistoryEntry[] changedHoldout = + [ + new(Now.AddMinutes(-20), 100), + new(Now.AddMinutes(-10), 90), + new(Now, 60), + ]; + var first = UsagePlanner.Plan( + Presentation(primaryRemaining: 70, secondaryRemaining: null, primaryHistory: firstHistory), + Now, + RefreshInterval, + Now.AddHours(1)).Primary!.Backtest; + var second = UsagePlanner.Plan( + Presentation(primaryRemaining: 60, secondaryRemaining: null, primaryHistory: changedHoldout), + Now, + RefreshInterval, + Now.AddHours(1)).Primary!.Backtest; + + Assert.True(first.IsAvailable); + Assert.Equal(2, first.TrainingSampleCount); + Assert.Equal(Now, first.HeldOutAt); + Assert.Equal(80, first.PredictedRemainingPercent!.Value, precision: 8); + Assert.Equal(70, first.ActualRemainingPercent!.Value, precision: 8); + Assert.Equal(10, first.AbsoluteErrorPercentagePoints!.Value, precision: 8); + Assert.Equal(first.PredictedRemainingPercent, second.PredictedRemainingPercent); + Assert.Equal(20, second.AbsoluteErrorPercentagePoints!.Value, precision: 8); + Assert.Contains("excluded from the preceding-pace calculation", first.Status, StringComparison.Ordinal); + } + + private static UsagePresentation Presentation( + double? primaryRemaining = 80, + double? secondaryRemaining = 80, + int primaryMinutes = 300, + int secondaryMinutes = 10080, + DateTimeOffset? primaryReset = null, + DateTimeOffset? secondaryReset = null, + DateTimeOffset? updatedAt = null, + UsageDataSource source = UsageDataSource.AppServer, + string? accountKey = "account-a", + bool isLoading = false, + bool? ordinaryUsageAllowed = null, + IReadOnlyList? primaryHistory = null, + IReadOnlyList? weeklyHistory = null, + AdaptiveWeeklyUsageHistory? adaptiveHistory = null) + { + var primary = primaryRemaining is { } primaryValue + ? new RateLimitWindow(100 - primaryValue, primaryMinutes, primaryReset ?? Now.AddHours(4)) + : null; + var secondary = secondaryRemaining is { } secondaryValue + ? new RateLimitWindow(100 - secondaryValue, secondaryMinutes, secondaryReset ?? Now.AddDays(6)) + : null; + var snapshot = new CodexUsageSnapshot( + primary, + secondary, + "pro", + null, + null, + updatedAt ?? Now, + source, + null, + AccountKey: accountKey, + OrdinaryUsageAllowed: ordinaryUsageAllowed, + DefaultBucketId: "codex"); + return new UsagePresentation( + snapshot, + primaryHistory ?? Array.Empty(), + weeklyHistory ?? Array.Empty(), + adaptiveHistory ?? new AdaptiveWeeklyUsageHistory([], null), + LocalTokenUsageSnapshot.Unavailable, + isLoading); + } +} diff --git a/CodexUsageDock/CodexAppServerReader.cs b/CodexUsageDock/CodexAppServerReader.cs index fd2c5d8..1359b06 100644 --- a/CodexUsageDock/CodexAppServerReader.cs +++ b/CodexUsageDock/CodexAppServerReader.cs @@ -92,6 +92,140 @@ internal static async Task ReadAccountUsageSequenceAsync( : AccountUsageSnapshot.Unavailable with { UpdatedAt = DateTimeOffset.Now }; } + internal static async Task ReadThreadUsageAsync( + CodexSourceOptions options, string threadId, string expectedAccountKey, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ThreadUsageParser.IsValidThreadId(threadId)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + if (!IsValidAccountKey(expectedAccountKey)) return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + try + { + return await WithAppServerAsync(options, (process, token) => + ReadThreadUsageSequenceAsync((method, id, parameters, requestToken) => + RequestAsync(process, method, id, parameters, requestToken), threadId, expectedAccountKey, token), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) when (error is IOException or InvalidOperationException or JsonException + or OperationCanceledException or System.ComponentModel.Win32Exception or UnauthorizedAccessException) + { + return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + } + } + + internal static async Task ReadThreadUsageSequenceAsync( + Func?, CancellationToken, Task> request, + string threadId, string expectedAccountKey, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!ThreadUsageParser.IsValidThreadId(threadId)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + if (!IsValidAccountKey(expectedAccountKey)) return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + + using var before = await request("account/rateLimits/read", 2, null, cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(before.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unsupported); + if (HasError(before.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + var beforeKey = GetResponseAccountKey(before.RootElement); + if (!string.Equals(expectedAccountKey, beforeKey, StringComparison.OrdinalIgnoreCase)) + return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + + using var usage = await request("account/usage/read", 3, + writer => WriteStringParameter(writer, "threadId", threadId), cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(usage.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unsupported); + if (HasError(usage.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + + using var after = await request("account/rateLimits/read", 4, null, cancellationToken).ConfigureAwait(false); + if (HasError(after.RootElement)) return EmptyThreadUsage(ThreadUsageStatus.Unavailable); + if (!string.Equals(beforeKey, GetResponseAccountKey(after.RootElement), StringComparison.Ordinal)) + return EmptyThreadUsage(ThreadUsageStatus.AccountMismatch); + + return TryGetObject(usage.RootElement, "result", out var result) + ? ThreadUsageParser.Parse(result, threadId, beforeKey!, DateTimeOffset.Now) + : EmptyThreadUsage(ThreadUsageStatus.Unavailable); + } + + internal static async Task ConsumeResetCreditAsync( + CodexSourceOptions options, string expectedAccountKey, string idempotencyKey, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateIdempotencyKey(idempotencyKey); + if (!IsValidAccountKey(expectedAccountKey)) return new(ResetCreditOutcome.AccountMismatch, DateTimeOffset.Now); + + var consumeStarted = false; + ResetCreditResult? completed = null; + try + { + return await WithAppServerAsync(options, async (process, token) => + { + var result = await ConsumeResetCreditSequenceAsync((method, id, parameters, requestToken) => + { + if (method == "account/rateLimitResetCredit/consume") consumeStarted = true; + return RequestAsync(process, method, id, parameters, requestToken); + }, expectedAccountKey, idempotencyKey, token).ConfigureAwait(false); + completed = result; + return result; + }, cancellationToken).ConfigureAwait(false); + } + catch (Exception) + { + // Cleanup can fail after the server has replied. Keep a known result; + // otherwise never describe a possibly dispatched reset as safe to replace. + return completed ?? new(consumeStarted ? ResetCreditOutcome.Ambiguous : ResetCreditOutcome.Unavailable, DateTimeOffset.Now); + } + } + + internal static async Task ConsumeResetCreditSequenceAsync( + Func?, CancellationToken, Task> request, + string expectedAccountKey, string idempotencyKey, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + ValidateIdempotencyKey(idempotencyKey); + var attemptedAt = DateTimeOffset.Now; + if (!IsValidAccountKey(expectedAccountKey)) return new(ResetCreditOutcome.AccountMismatch, attemptedAt); + + var consumeStarted = false; + try + { + using var before = await request("account/rateLimits/read", 2, null, cancellationToken).ConfigureAwait(false); + if (IsMethodNotFound(before.RootElement)) return new(ResetCreditOutcome.Unsupported, attemptedAt); + if (HasError(before.RootElement)) return new(ResetCreditOutcome.Unavailable, attemptedAt); + if (!string.Equals(expectedAccountKey, GetResponseAccountKey(before.RootElement), StringComparison.OrdinalIgnoreCase)) + return new(ResetCreditOutcome.AccountMismatch, attemptedAt); + + cancellationToken.ThrowIfCancellationRequested(); + attemptedAt = DateTimeOffset.Now; + consumeStarted = true; + using var response = await request("account/rateLimitResetCredit/consume", 3, + writer => WriteStringParameter(writer, "idempotencyKey", idempotencyKey), cancellationToken).ConfigureAwait(false); + return ResetCreditParser.Parse(response.RootElement, attemptedAt); + } + catch (Exception) + { + return new(consumeStarted ? ResetCreditOutcome.Ambiguous : ResetCreditOutcome.Unavailable, attemptedAt); + } + } + + private static ThreadUsageSnapshot EmptyThreadUsage(ThreadUsageStatus status) => + ThreadUsageSnapshot.Unavailable with { Status = status, UpdatedAt = DateTimeOffset.Now }; + + private static bool IsValidAccountKey(string? value) => value is { Length: 64 } && value.All(char.IsAsciiHexDigit); + + private static void ValidateIdempotencyKey(string value) + { + if (!ResetCreditParser.IsValidIdempotencyKey(value)) + throw new ArgumentException("A bounded, nonempty idempotency key is required.", nameof(value)); + } + + private static void WriteStringParameter(Utf8JsonWriter writer, string name, string value) + { + writer.WritePropertyName("params"); + writer.WriteStartObject(); + writer.WriteString(name, value); + writer.WriteEndObject(); + } + private static string? GetResponseAccountKey(JsonElement response) => !HasError(response) && TryGetObject(response, "result", out var result) ? ParseAccountKey(result) : null; @@ -102,14 +236,18 @@ internal static bool IsMethodNotFound(JsonElement response) => && code.TryGetInt32(out var number) && number == -32601; - private static async Task RequestAsync(Process process, string method, int id, CancellationToken cancellationToken) - { - await SendAsync(process, method, id, method == "account/usage/read" ? static writer => + private static Task RequestAsync(Process process, string method, int id, CancellationToken cancellationToken) => + RequestAsync(process, method, id, method == "account/usage/read" ? static writer => { writer.WritePropertyName("params"); writer.WriteStartObject(); writer.WriteEndObject(); - } : null, cancellationToken).ConfigureAwait(false); + } : null, cancellationToken); + + private static async Task RequestAsync(Process process, string method, int id, + Action? parameters, CancellationToken cancellationToken) + { + await SendAsync(process, method, id, parameters, cancellationToken).ConfigureAwait(false); var responses = await ReadResponsesAsync(process.StandardOutput, cancellationToken, id).ConfigureAwait(false); return responses[id]; } diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 34d3f37..5fe42e5 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -14,6 +14,9 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexUsageDockPage _details; private readonly CodexUsageDiagnosticsPage _diagnostics; private readonly CodexAccountActivityPage _accountActivity; + private readonly CodexPlanningPage _planner; + private readonly CodexHistoryPage _history; + private readonly CodexActionsPage _actions; private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; private readonly Func _clock; @@ -45,10 +48,14 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); + _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; _accountActivity = new CodexAccountActivityPage(_usage); + _planner = new CodexPlanningPage(_usage, _settings, _clock); + _history = new CodexHistoryPage(_usage); + _actions = new CodexActionsPage(_usage); _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -76,6 +83,9 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Title = "Codex account activity", Subtitle = "Account-wide daily tokens reported by Codex", }, + new CommandItem(_planner) { Title = "Codex workday planner", Subtitle = "Daily quota budget, recent pace, and forecast evidence" }, + new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, + new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, ]; _settings.Changed += OnSettingsChanged; @@ -116,9 +126,12 @@ private void OnSettingsChanged(object? sender, EventArgs e) _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); var sourceChanged = ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); + _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); + _planner.Refresh(); + _history.Refresh(); RebuildDockBands(); RaiseItemsChanged(); if (sourceChanged) _ = _usage.RefreshAsync(); @@ -212,6 +225,9 @@ public override void Dispose() _details.Dispose(); _diagnostics.Dispose(); _accountActivity.Dispose(); + _planner.Dispose(); + _history.Dispose(); + _actions.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.Actions.cs b/CodexUsageDock/CodexUsageService.Actions.cs new file mode 100644 index 0000000..ae6a220 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.Actions.cs @@ -0,0 +1,191 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private ResetAttemptJournal? _resetJournal; + private Func>? _resetCreditConsumer; + private Func>? _threadUsageReader; + private Task? _resetActionTask; + private Task? _threadActionTask; + private PendingTaskRead? _pendingTaskRead; + private long _taskRequestVersion; + private sealed record PendingTaskRead(CodexSourceOptions Options, string ThreadId, string Account, + long SourceGeneration, long RequestVersion); + internal string ResetActionStatus { get; private set; } = "Only an explicit, confirmed action can use an existing earned reset."; + internal ThreadUsageSnapshot? CurrentThreadUsage { get; private set; } + internal string ThreadActionStatus { get; private set; } = "Enter a Codex task ID to request its reported usage estimate."; + + internal (CodexUsageSnapshot Usage, ThreadUsageSnapshot? Task, string TaskStatus, string ResetStatus) GetActionPresentation() + { + lock (_refreshStateLock) { return (Current, CurrentThreadUsage, ThreadActionStatus, ResetActionStatus); } + } + + internal void InitializeOptionalFeatures(string aggregatePath, string resetJournalPath, + Func>? resetConsumer = null, + Func>? threadReader = null) + { + if (_started) throw new InvalidOperationException("Optional storage must be configured before monitoring starts."); + _aggregatePath = Path.GetFullPath(aggregatePath); + _resetJournal = new ResetAttemptJournal(Path.GetFullPath(resetJournalPath)); + _resetCreditConsumer = resetConsumer ?? (_usesConfiguredSources ? CodexAppServerReader.ConsumeResetCreditAsync : null); + _threadUsageReader = threadReader ?? (_usesConfiguredSources ? CodexAppServerReader.ReadThreadUsageAsync : null); + } + + internal Task ConsumeEarnedResetAsync(string expectedAccountKey) + { + Task task; + lock (_refreshStateLock) + { + if (_resetActionTask is { IsCompleted: false }) return _resetActionTask; + if (_disposed || _resetJournal is null || _resetCreditConsumer is null + || !IsFreshAccount(expectedAccountKey)) + { + ResetActionStatus = "Refresh a live, identified Codex account before using a reset."; + task = Task.FromResult(ResetActionStatus); + } + else + { + var options = _sourceOptions; + var generation = _sourceGeneration; + var available = Current.ResetCredits?.AvailableCount; + ResetActionStatus = "Checking the existing reset attempt…"; + task = _resetActionTask = Task.Run(() => RunResetAsync(options, expectedAccountKey, available, generation)); + } + } + RaiseUpdated(); + return task; + } + + private async Task RunResetAsync(CodexSourceOptions options, string account, int? available, long generation) + { + string message; + if (!_resetJournal!.TryRead(account, out var key)) + message = "The pending reset record could not be read safely. No reset request was sent."; + else if (key is null && available is not > 0) + message = "No available earned reset is currently reported. No reset request was sent."; + else + { + key ??= Guid.NewGuid().ToString("N"); + if (!_resetJournal.Save(account, key)) + message = "The reset request ID could not be saved. No reset request was sent."; + else + { + ResetCreditResult result; + try + { + result = await _resetCreditConsumer!(options, account, key, _lifetimeCancellation.Token).ConfigureAwait(false); + } + catch (Exception error) + { + LocalStorage.TraceFailure("request earned reset", error); + result = new(ResetCreditOutcome.Ambiguous, _clock()); + } + message = DescribeResetOutcome(result.Outcome); + // Any unresolved attempt, including a retry whose preflight failed, keeps its original key. + if (result.Outcome is ResetCreditOutcome.Reset or ResetCreditOutcome.AlreadyRedeemed + or ResetCreditOutcome.NothingToReset or ResetCreditOutcome.NoCredit) + { + if (!_resetJournal.Save(account, null)) + message += " The recovery record could not be cleared; a retry will reuse the same request ID."; + } + await RefreshAsync().ConfigureAwait(false); + } + } + lock (_refreshStateLock) + { + if (!_disposed && generation == _sourceGeneration && Current.AccountKey == account) + ResetActionStatus = message; + } + RaiseUpdated(); + return message; + } + + internal static string DescribeResetOutcome(ResetCreditOutcome outcome) => outcome switch + { + ResetCreditOutcome.Reset => "An earned reset was applied. Usage limits have been requested again.", + ResetCreditOutcome.AlreadyRedeemed => "This request had already redeemed its reset. No second reset was used.", + ResetCreditOutcome.NothingToReset => "The service reports nothing to reset. No reset was applied.", + ResetCreditOutcome.NoCredit => "The service reports no eligible earned reset. No reset was applied.", + ResetCreditOutcome.Unsupported => "This Codex CLI does not support earned resets. The request ID is retained for a safe retry.", + ResetCreditOutcome.AccountMismatch => "The Codex account changed before the request. No reset was sent to the changed account.", + ResetCreditOutcome.Unavailable => "The service could not be checked before sending the reset. The request ID is retained.", + _ => "The reset outcome is unknown. Confirm a retry to reuse the same request ID, including after restarting the extension.", + }; + + private bool IsFreshAccount(string expectedAccountKey) => !string.IsNullOrWhiteSpace(expectedAccountKey) + && Current.AccountKey == expectedAccountKey && Current.Source == UsageDataSource.AppServer + && !_isLoading && UsageFreshness.IsFresh(Current.UpdatedAt, _clock(), RefreshInterval); + + internal Task ReadTaskUsageAsync(string threadId) + { + Task task; + lock (_refreshStateLock) + { + if (_disposed || _threadUsageReader is null || Current.AccountKey is not { } account || !IsFreshAccount(account)) + { + ThreadActionStatus = "Refresh a live, identified account before requesting task usage."; + CurrentThreadUsage = null; + task = Task.CompletedTask; + } + else if (!ThreadUsageParser.IsValidThreadId(threadId?.Trim())) + { + ThreadActionStatus = "Enter a task ID of at most 128 letters, digits, hyphens, or underscores."; + CurrentThreadUsage = null; + task = Task.CompletedTask; + } + else + { + _pendingTaskRead = new(_sourceOptions, threadId!.Trim(), account, _sourceGeneration, ++_taskRequestVersion); + ThreadActionStatus = "Reading the latest requested task estimate…"; + CurrentThreadUsage = null; + task = _threadActionTask is { IsCompleted: false } active ? active + : _threadActionTask = Task.Run(RunTaskReadsAsync); + } + } + RaiseUpdated(); + return task; + } + + private async Task RunTaskReadsAsync() + { + while (true) + { + PendingTaskRead request; + lock (_refreshStateLock) + { + if (_disposed || _pendingTaskRead is null) { _threadActionTask = null; return; } + request = _pendingTaskRead; + _pendingTaskRead = null; + } + ThreadUsageSnapshot? result = null; + try { result = await _threadUsageReader!(request.Options, request.ThreadId, request.Account, _lifetimeCancellation.Token).ConfigureAwait(false); } + catch (Exception error) { LocalStorage.TraceFailure("read task usage", error); } + lock (_refreshStateLock) + { + if (_disposed || request.SourceGeneration != _sourceGeneration || Current.AccountKey != request.Account + || request.RequestVersion != _taskRequestVersion) continue; + var usable = result?.AccountKey == request.Account && result.ThreadId == request.ThreadId + && result.Status is ThreadUsageStatus.Available or ThreadUsageStatus.Partial; + CurrentThreadUsage = usable ? result : null; + ThreadActionStatus = result?.Status switch + { + ThreadUsageStatus.Available when usable => "Task usage estimate reported by Codex.", + ThreadUsageStatus.Partial when usable => "Partial task estimate; missing values are not zero.", + ThreadUsageStatus.Unsupported => "This Codex CLI does not support task usage estimates.", + ThreadUsageStatus.AccountMismatch => "The account changed during the read. The result was discarded.", + _ => "Task usage is unavailable. Check the task ID and try again.", + }; + } + RaiseUpdated(); + } + } + + private void ClearActionPresentation() + { + CurrentThreadUsage = null; + _pendingTaskRead = null; + _taskRequestVersion++; + ThreadActionStatus = "Enter a Codex task ID to request its reported usage estimate."; + ResetActionStatus = "Only an explicit, confirmed action can use an existing earned reset."; + } +} diff --git a/CodexUsageDock/CodexUsageService.Aggregates.cs b/CodexUsageDock/CodexUsageService.Aggregates.cs new file mode 100644 index 0000000..22af2f8 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.Aggregates.cs @@ -0,0 +1,68 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private string? _aggregatePath; + private UsageAggregateStore? _aggregateStore; + private int _aggregateRetentionDays; + + internal void SetAggregateRetentionDays(int days) + { + if (days is not (0 or 7 or 30 or 90)) throw new ArgumentOutOfRangeException(nameof(days)); + lock (_historyLock) + { + if (_aggregateRetentionDays == days) return; + _aggregateRetentionDays = days; + OpenAggregateStore(); + if (days != 0) _aggregateStore?.SetRetentionDays(days, _clock()); + } + RaiseUpdated(); + } + + private void OpenAggregateStore() => _aggregateStore = _aggregatePath is not null && _historyContext is not null + ? new UsageAggregateStore(LocalStorage.ContextPath(_aggregatePath, _historyContext), + _aggregateRetentionDays == 0 ? 90 : _aggregateRetentionDays, _clock) + : null; + + private void RecordAggregate(CodexUsageSnapshot snapshot, DateTimeOffset now) + { + if (_aggregateRetentionDays == 0 || _aggregateStore is null) return; + if (UsageAggregateStore.TryCreateLivePoint(snapshot, now, RefreshInterval, out var point)) + _aggregateStore.Record(point!, now); + } + + internal (IReadOnlyList Points, int RetentionDays, string? Error, bool Identified, string? Context) GetAggregateHistory() + { + lock (_historyLock) + { + return (_aggregateStore?.Snapshot ?? [], _aggregateRetentionDays, _aggregateStore?.StorageError, _historyContext is not null, _historyContext); + } + } + + internal bool ClearAggregateHistory(string? expectedContext = null) + { + bool cleared; + lock (_historyLock) + { + if (expectedContext is not null && expectedContext != _historyContext) return false; + cleared = _aggregateStore?.Clear() ?? _historyContext is not null; + } + RaiseUpdated(); + return cleared; + } + + internal string ExportAggregateHistory(bool csv, string? expectedContext = null) + { + lock (_historyLock) + { + if (expectedContext is not null && expectedContext != _historyContext) + return "The account or quota category changed. Open its history and request the export again."; + if (_aggregateStore is null || _aggregatePath is null) + return "Identify the current Codex account before exporting its history."; + var content = csv ? _aggregateStore.ExportCsv(_clock()) : _aggregateStore.ExportJson(_clock()); + var file = $"codex-usage-{_clock():yyyyMMdd-HHmmss}-{Guid.NewGuid():N}.{(csv ? "csv" : "json")}"; + var destination = Path.Combine(Path.GetDirectoryName(_aggregatePath)!, "exports", file); + return LocalStorage.TryWrite(destination, content) ? $"Export saved to {destination}" : "The export could not be saved."; + } + } +} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index dd2aa89..56deedd 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -47,6 +47,7 @@ public CodexUsageService() localTokenUsageReader: new LocalCodexTokenUsageReader().ReadAsync) { _usesConfiguredSources = true; + InitializeOptionalFeatures(LocalStorage.GetPath("aggregates.json"), LocalStorage.GetPath("reset-attempt.json")); } internal CodexUsageService( @@ -169,6 +170,7 @@ internal bool ConfigureSource(CodexSourceOptions options, string? error = null) Current = CodexUsageSnapshot.Loading; CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; CurrentAccountUsage = AccountUsageSnapshot.Unavailable; + ClearActionPresentation(); _accountReadAfter = DateTimeOffset.MinValue; if (_usesConfiguredSources) { @@ -180,6 +182,7 @@ internal bool ConfigureSource(CodexSourceOptions options, string? error = null) _historyContext = null; _primaryHistory.Clear(); _weeklyHistory.Clear(); + _aggregateStore = null; } } RaiseUpdated(); @@ -315,8 +318,11 @@ internal void RecordHistory(CodexUsageSnapshot snapshot, DateTimeOffset? recorde // A returning account may have been observed while learning was paused. // Resume at this measurement rather than replaying another context's gap. _adaptiveWeeklyForecastNeedsBaseline = _adaptiveWeeklyForecastEnabled; + OpenAggregateStore(); } + RecordAggregate(snapshot, now); + RecordWindowHistory(_primaryHistory, snapshot.Primary, snapshot.UpdatedAt, now, now - TimeSpan.FromHours(5)); var weeklyHistoryChanged = RecordWindowHistory(_weeklyHistory, snapshot.Secondary, snapshot.UpdatedAt, now, now - TimeSpan.FromDays(7)); if (weeklyHistoryChanged && context is not null) @@ -594,6 +600,7 @@ private bool TryPublish(CodexUsageSnapshot snapshot, long generation) CurrentTokenUsage = LocalTokenUsageSnapshot.Unavailable; if (Current.AccountKey != snapshot.AccountKey) { + ClearActionPresentation(); CurrentAccountUsage = AccountUsageSnapshot.Unavailable; _accountReadAfter = DateTimeOffset.MinValue; } @@ -664,7 +671,8 @@ public void Dispose() } _disposed = true; - refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask); + refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask, + (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask); } _timer.Stop(); diff --git a/CodexUsageDock/Pages/CodexActionsPage.cs b/CodexUsageDock/Pages/CodexActionsPage.cs new file mode 100644 index 0000000..dc204bc --- /dev/null +++ b/CodexUsageDock/Pages/CodexActionsPage.cs @@ -0,0 +1,111 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; + +namespace CodexUsageDock; + +internal sealed partial class CodexActionsPage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly TaskUsageForm _form; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexActionsPage(CodexUsageService service) + { + _service = service; + _form = new TaskUsageForm(service); + Id = "nl.mathijs.codexusage.actions"; + Name = "Open"; + Title = "Codex task usage and earned resets"; + Icon = new IconInfo("\uE945"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_form, _content]; } } + + private void Refresh() + { + var presentation = _service.GetActionPresentation(); + var body = new StringBuilder("# Task usage\n\n").Append(presentation.TaskStatus).Append("\n\n"); + if (presentation.Task is { } task) body.Append(FormatTask(task)); + body.Append("\n# Earned resets\n\nReported available: ") + .Append(presentation.Usage.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Unknown") + .Append(".\n\n").Append(presentation.ResetStatus) + .Append("\n\nUse the confirmed reset action only when you want to redeem one existing earned reset. ") + .Append("After an unknown outcome, retrying uses the same saved request ID. The backend decides eligibility.\n"); + var expectedAccount = presentation.Usage.AccountKey; + var commands = new List + { + new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + }; + if (expectedAccount is not null) + { + commands.Add(new CommandContextItem(new ConfirmableCommand( + new AnonymousCommand(() => { _ = _service.ConsumeEarnedResetAsync(expectedAccount); }) { Result = CommandResult.KeepOpen() }, + "Use or retry an earned reset?", + "Redeem one existing earned reset for the currently identified Codex account. A retry of an uncertain request reuses its saved ID. No reset is purchased.", + () => true) { Name = "Use or retry an earned reset" }) { Title = "Use or retry an earned reset" }); + } + lock (_gate) + { + if (_disposed) return; + _content = new MarkdownContent(body.ToString()); + Commands = commands.ToArray(); + } + RaiseItemsChanged(0); + } + + internal static string FormatTask(ThreadUsageSnapshot task) + { + if (task.Status is not (ThreadUsageStatus.Available or ThreadUsageStatus.Partial)) return string.Empty; + var body = new StringBuilder("Task: ").Append(UsageText.EscapeMarkdown(task.ThreadId ?? "Unknown")) + .Append(".\n\n**Server estimate, not a bill or quota percentage.** Last read: ") + .Append(task.UpdatedAt.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)).Append(".\n\n") + .Append("Estimated credits: ").Append(Micro(task.EstimatedUsageCreditsMicros)) + .Append("; estimated USD: ").Append(Micro(task.EstimatedUsageUsdMicros)).Append(".\n\n") + .Append("| Model / effort / speed | Input | Cached input | Net new input | Output | Total | Estimated credits |\n") + .Append("| --- | ---: | ---: | ---: | ---: | ---: | ---: |\n"); + foreach (var group in task.Groups ?? []) + body.Append("| ").Append(UsageText.EscapeMarkdown(string.Join(" / ", group.Model ?? "Unknown model", group.ReasoningEffort ?? "Unknown effort", group.Speed ?? "Unknown speed"))) + .Append(" | ").Append(Tokens(group.InputTokens)).Append(" | ").Append(Tokens(group.CachedInputTokens)) + .Append(" | ").Append(Tokens(group.NetNewInputTokens)).Append(" | ").Append(Tokens(group.OutputTokens)) + .Append(" | ").Append(Tokens(group.TotalTokens)).Append(" | ").Append(Micro(group.EstimatedUsageCreditsMicros)).Append(" |\n"); + return body.Append("\nCached and net-new input are components of input; do not add them to input again. Missing values are not zero.\n").ToString(); + } + + private static string Micro(long? value) => value is { } amount ? (amount / 1_000_000m).ToString("0.######", CultureInfo.InvariantCulture) : "Not reported"; + private static string Tokens(long? value) => value?.ToString("N0", CultureInfo.InvariantCulture) ?? "Not reported"; + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} + +internal sealed partial class TaskUsageForm : FormContent +{ + private readonly CodexUsageService _service; + internal TaskUsageForm(CodexUsageService service) + { + _service = service; + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[{"type":"Input.Text","id":"threadId","label":"Codex task ID","placeholder":"Paste the task ID","maxLength":128,"isRequired":true}],"actions":[{"type":"Action.Submit","title":"Read task estimate"}]} + """; + } + + public override CommandResult SubmitForm(string payload) + { + try + { + if (string.IsNullOrEmpty(payload) || payload.Length > 4096) return CommandResult.KeepOpen(); + using var doc = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); + if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("threadId", out var id) + && id.ValueKind == JsonValueKind.String) _ = _service.ReadTaskUsageAsync(id.GetString()!); + } + catch (JsonException) { } + return CommandResult.KeepOpen(); + } +} diff --git a/CodexUsageDock/Pages/CodexHistoryPage.cs b/CodexUsageDock/Pages/CodexHistoryPage.cs new file mode 100644 index 0000000..27029cf --- /dev/null +++ b/CodexUsageDock/Pages/CodexHistoryPage.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; + +namespace CodexUsageDock; + +internal sealed partial class CodexHistoryPage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private string? _operation; + private bool _disposed; + + internal CodexHistoryPage(CodexUsageService service) + { + _service = service; + Id = "nl.mathijs.codexusage.history"; + Name = "Open"; + Title = "Codex usage history"; + Icon = new IconInfo("\uE81C"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + private void Export(bool csv, string context) { _operation = _service.ExportAggregateHistory(csv, context); Refresh(); } + private void Clear(string context) { _operation = _service.ClearAggregateHistory(context) ? "Retained observations deleted." : "Retained observations could not be deleted, or the account/category changed."; Refresh(); } + + internal void Refresh() + { + var history = _service.GetAggregateHistory(); + var body = new StringBuilder("# Usage history\n\n"); + if (_operation is not null) body.Append(UsageText.EscapeMarkdown(_operation)).Append("\n\n"); + if (!history.Identified) body.Append("Waiting for an identified account. Histories are separated by account and quota category.\n\n"); + body.Append(history.RetentionDays == 0 ? "Collection paused. Choose 7, 30, or 90 days in settings to retain observations." + : $"Retention: {history.RetentionDays} days. Up to one observation per five minutes, with separate reset transitions.") + .Append("\n\n").Append(history.Points.Count.ToString(CultureInfo.InvariantCulture)).Append(" retained observations. ") + .Append("Exports contain UTC quota percentages and reset times, without account IDs, conversation content, tokens, or costs.\n\n"); + if (history.Error is not null) body.Append(history.Error).Append("\n\n"); + body.Append("The most recent 30 observations are shown. The exports include all retained rows for this context.\n\n") + .Append("| Observed UTC | Five-hour remaining | Weekly remaining |\n| --- | ---: | ---: |\n"); + foreach (var point in history.Points.TakeLast(30).Reverse()) + body.Append("| ").Append(point.RecordedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)).Append(" | ") + .Append(Percent(point.PrimaryRemainingPercent)).Append(" | ").Append(Percent(point.WeeklyRemainingPercent)).Append(" |\n"); + lock (_gate) + { + if (_disposed) return; + _content = new MarkdownContent(body.ToString()); + Commands = history.Context is not { } context ? [] : + [ + new CommandContextItem(new AnonymousCommand(() => Export(true, context)) { Result = CommandResult.KeepOpen() }) { Title = "Export CSV file" }, + new CommandContextItem(new AnonymousCommand(() => Export(false, context)) { Result = CommandResult.KeepOpen() }) { Title = "Export JSON file" }, + new CommandContextItem(new ConfirmableCommand(new AnonymousCommand(() => Clear(context)) { Result = CommandResult.KeepOpen() }, + "Delete retained usage observations?", "Delete this account and quota category's retained observations. Existing exports and learned forecasts are kept.", () => true) + { Name = "Delete retained observations" }) { Title = "Delete retained observations" }, + ]; + } + RaiseItemsChanged(0); + } + + private static string Percent(double? value) => value?.ToString("0.#", CultureInfo.InvariantCulture) is { } text ? text + "%" : "Not reported"; + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} diff --git a/CodexUsageDock/Pages/CodexPlanningPage.cs b/CodexUsageDock/Pages/CodexPlanningPage.cs new file mode 100644 index 0000000..a340e18 --- /dev/null +++ b/CodexUsageDock/Pages/CodexPlanningPage.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class CodexPlanningPage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly CodexUsageDockSettingsPage _settings; + private readonly Func _clock; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexPlanningPage(CodexUsageService service, CodexUsageDockSettingsPage settings, Func? clock = null) + { + _service = service; + _settings = settings; + _clock = clock ?? (() => DateTimeOffset.Now); + Id = "nl.mathijs.codexusage.planner"; + Name = "Open"; + Title = "Codex workday planner"; + Icon = new IconInfo("\uE787"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + internal void Refresh() + { + var now = _clock(); + var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); + var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) + ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." + : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, + new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); + lock (_gate) + { + if (_disposed) return; + _content = new MarkdownContent(body); + Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + new CommandContextItem(_settings) { Title = "Change planning assumptions" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy plan" }]; + } + RaiseItemsChanged(0); + } + + internal static string FormatPlan(UsagePlanningResult plan) + { + var text = new StringBuilder("# Workday planner\n\n").Append(plan.Status).Append("\n\n"); + text.Append("Target today: ").Append(plan.DesiredEnd.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)) + .Append(". Remaining workdays before weekly reset: ").Append(plan.RequestedWorkdays?.ToString(CultureInfo.InvariantCulture) ?? "1") + .Append(". Change these assumptions in settings.\n\n"); + foreach (var window in plan.Windows) + { + text.Append("## ").Append(window.Label).Append(" allowance\n\n") + .Append(window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% remaining; budget ") + .Append(window.AvailablePointsPerWorkday.ToString("0.#", CultureInfo.InvariantCulture)).Append(" quota percentage points per workday, or ") + .Append(window.AvailablePointsPerHour.ToString("0.#", CultureInfo.InvariantCulture)).Append(" points per hour today.\n\n") + .Append(window.HorizonMessage).Append("\n\n") + .Append("**Recent pace:** ").Append(window.Forecast.Status).Append("\n\n") + .Append("**Evidence:** ").Append(window.Evidence.Summary).Append("\n\n") + .Append("**Last observation check:** ").Append(window.Backtest.Status).Append("\n\n"); + if (window.Backtest.AbsoluteErrorPercentagePoints is { } error) + text.Append("Held-out prediction error: ").Append(error.ToString("0.##", CultureInfo.InvariantCulture)) + .Append(" percentage points. One held-out observation does not establish long-term accuracy.\n\n"); + } + return text.Append("Quota points are not tokens, money, or a guaranteed number of tasks. Both windows apply independently. ") + .Append("This planner uses recent pace; the dashboard may additionally use its optional learned weekly pattern.").ToString(); + } + + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 0b959d5..9dd522a 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -22,6 +22,9 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string CodexExecutablePathKey = "codexExecutablePath"; private const string CodexHomePathKey = "codexHomePath"; private const int MaximumPathLength = 1024; + private const string HistoryRetentionKey = "historyRetentionDays"; + private const string WorkdayEndKey = "workdayEnd"; + private const string RemainingWorkdaysKey = "remainingWorkdays"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -113,6 +116,25 @@ internal CodexUsageDockSettingsPage(string path) Label = "Refresh interval", Description = "How often the extension refreshes local Codex usage data.", }); + _settings.Add(new ChoiceSetSetting(HistoryRetentionKey, + [new("Collection paused", "0"), new("7 days", "7"), new("30 days", "30"), new("90 days", "90")]) + { + Label = "Retain usage observations", + Description = "Optional local quota history for export. Pausing keeps saved data; use History to delete it.", + }); + _settings.Add(new TextSetting(WorkdayEndKey, "17:00") + { + Label = "Workday end (HH:mm)", + Description = "Local time used by the planner for today. After this time, planning pauses until the next day.", + Multiline = false, + }); + _settings.Add(new ChoiceSetSetting(RemainingWorkdaysKey, + [new("1 workday", "1"), new("2 workdays", "2"), new("3 workdays", "3"), new("4 workdays", "4"), + new("5 workdays", "5"), new("6 workdays", "6"), new("7 workdays", "7")]) + { + Label = "Workdays remaining before weekly reset", + Description = "Your planning assumption, including today. The extension does not infer your calendar.", + }); var clearHistory = new ConfirmableCommand( new AnonymousCommand(() => ClearAdaptiveHistoryRequested?.Invoke(this, EventArgs.Empty)) { @@ -163,6 +185,13 @@ internal CodexUsageDockSettingsPage(string path) public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); + internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch + { "7" => 7, "30" => 30, "90" => 90, _ => 0 }; + internal TimeOnly WorkdayEnd => TimeOnly.TryParseExact(_settings.GetSetting(WorkdayEndKey), "HH:mm", + System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var time) ? time : new(17, 0); + internal int RemainingWorkdays => int.TryParse(_settings.GetSetting(RemainingWorkdaysKey), out var days) + && days is >= 1 and <= 7 ? days : 1; + internal string? StatusMessage { get; private set; } public override IContent[] GetContent() => StatusMessage is null @@ -224,6 +253,13 @@ private void Load() { valid[property.Name] = value; } + else if (property.Name == HistoryRetentionKey && value is "0" or "7" or "30" or "90" + || property.Name == RemainingWorkdaysKey && value is "1" or "2" or "3" or "4" or "5" or "6" or "7" + || property.Name == WorkdayEndKey && TimeOnly.TryParseExact(value, "HH:mm", + System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _)) + { + valid[property.Name] = value; + } else if (IsBooleanSetting(property.Name) && bool.TryParse(value, out var enabled)) { valid[property.Name] = enabled ? "true" : "false"; diff --git a/CodexUsageDock/ResetAttemptJournal.cs b/CodexUsageDock/ResetAttemptJournal.cs new file mode 100644 index 0000000..e983c97 --- /dev/null +++ b/CodexUsageDock/ResetAttemptJournal.cs @@ -0,0 +1,36 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CodexUsageDock; + +// Persist before sending a mutation so a lost response or restart cannot create a second redemption. +internal sealed class ResetAttemptJournal(string path) +{ + internal bool TryRead(string accountKey, out string? key) + { + key = null; + try + { + var scoped = LocalStorage.ContextPath(path, accountKey); + if (!File.Exists(scoped)) return true; + if (new FileInfo(scoped).Length > 4096) return false; + using var document = JsonDocument.Parse(File.ReadAllText(scoped), new JsonDocumentOptions { MaxDepth = 4 }); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("schemaVersion", out var version) || !version.TryGetInt32(out var value) || value != 1 + || !root.TryGetProperty("pendingKey", out var pending)) return false; + if (pending.ValueKind == JsonValueKind.Null) return true; + if (pending.ValueKind != JsonValueKind.String || !Guid.TryParseExact(pending.GetString(), "N", out _)) return false; + key = pending.GetString(); + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or InvalidOperationException) + { + LocalStorage.TraceFailure("read pending reset", error); + return false; + } + } + + internal bool Save(string accountKey, string? key) => LocalStorage.TryWrite( + LocalStorage.ContextPath(path, accountKey), new JsonObject { ["schemaVersion"] = 1, ["pendingKey"] = key }.ToJsonString()); +} diff --git a/CodexUsageDock/ResetCreditData.cs b/CodexUsageDock/ResetCreditData.cs new file mode 100644 index 0000000..42cbb24 --- /dev/null +++ b/CodexUsageDock/ResetCreditData.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum ResetCreditOutcome +{ + Reset, + AlreadyRedeemed, + NothingToReset, + NoCredit, + Unsupported, + AccountMismatch, + // The consume request was not started; no credit could have been consumed. + Unavailable, + // A retry must use the original idempotency key. + Ambiguous, +} + +internal sealed record ResetCreditResult(ResetCreditOutcome Outcome, DateTimeOffset AttemptedAt); + +internal static class ResetCreditParser +{ + internal static bool IsValidIdempotencyKey(string? value) => value is { Length: > 0 and <= 128 } + && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); + + internal static ResetCreditResult Parse(JsonElement response, DateTimeOffset attemptedAt) + { + if (response.ValueKind == JsonValueKind.Object && response.TryGetProperty("error", out _) + && response.TryGetProperty("result", out _)) return new(ResetCreditOutcome.Ambiguous, attemptedAt); + if (CodexAppServerReader.IsMethodNotFound(response)) return new(ResetCreditOutcome.Unsupported, attemptedAt); + if (response.ValueKind != JsonValueKind.Object + || response.TryGetProperty("error", out _) + || !response.TryGetProperty("result", out var result) || result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("outcome", out var value) || value.ValueKind != JsonValueKind.String) + { + return new(ResetCreditOutcome.Ambiguous, attemptedAt); + } + + var outcome = value.GetString() switch + { + "reset" => ResetCreditOutcome.Reset, + "alreadyRedeemed" => ResetCreditOutcome.AlreadyRedeemed, + "nothingToReset" => ResetCreditOutcome.NothingToReset, + "noCredit" => ResetCreditOutcome.NoCredit, + _ => ResetCreditOutcome.Ambiguous, + }; + return new(outcome, attemptedAt); + } +} diff --git a/CodexUsageDock/TaskUsageData.cs b/CodexUsageDock/TaskUsageData.cs new file mode 100644 index 0000000..eca9865 --- /dev/null +++ b/CodexUsageDock/TaskUsageData.cs @@ -0,0 +1,130 @@ +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum ThreadUsageStatus +{ + Available, + Partial, + Unsupported, + Unavailable, + AccountMismatch, +} + +internal sealed record ThreadUsageGroup( + string? Model, + string? ReasoningEffort, + string? Speed, + long? EstimatedUsageCreditsMicros, + long? NetNewInputTokens, + long? CachedInputTokens, + long? InputTokens, + long? OutputTokens, + long? TotalTokens); + +internal sealed record ThreadUsageSnapshot( + string? ThreadId, + string? AccountKey, + DateTimeOffset UpdatedAt, + ThreadUsageStatus Status, + long? EstimatedUsageCreditsMicros = null, + long? EstimatedUsageUsdMicros = null, + IReadOnlyList? Groups = null) +{ + internal static ThreadUsageSnapshot Unavailable { get; } = new(null, null, DateTimeOffset.MinValue, ThreadUsageStatus.Unavailable); +} + +internal static class ThreadUsageParser +{ + internal const int MaximumGroups = 64; + + internal static bool IsValidThreadId(string? value) => value is { Length: > 0 and <= 128 } + && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); + + internal static ThreadUsageSnapshot Parse(JsonElement result, string expectedThreadId, string accountKey, DateTimeOffset now) + { + if (!IsValidThreadId(expectedThreadId) || string.IsNullOrWhiteSpace(accountKey) || result.ValueKind != JsonValueKind.Object + || !result.TryGetProperty("threadUsage", out var usage) || usage.ValueKind != JsonValueKind.Object + || !usage.TryGetProperty("threadId", out var thread) || thread.ValueKind != JsonValueKind.String + || !string.Equals(thread.GetString(), expectedThreadId, StringComparison.Ordinal)) + { + return ThreadUsageSnapshot.Unavailable with { UpdatedAt = now }; + } + + var partial = false; + var credits = ReadNonnegative(usage, "estimatedUsageCreditsMicros", ref partial); + var usd = ReadNonnegative(usage, "estimatedUsageUsdMicros", ref partial); + var groups = new List(); + if (usage.TryGetProperty("groups", out var groupValues) && groupValues.ValueKind == JsonValueKind.Array) + { + var count = 0; + foreach (var group in groupValues.EnumerateArray()) + { + if (++count > MaximumGroups) + { + partial = true; + break; + } + if (group.ValueKind != JsonValueKind.Object) + { + partial = true; + continue; + } + + var parsed = new ThreadUsageGroup( + ReadLabel(group, "model", 80, ref partial), + ReadLabel(group, "reasoningEffort", 32, ref partial), + ReadLabel(group, "speed", 32, ref partial), + ReadNonnegative(group, "estimatedUsageCreditsMicros", ref partial), + ReadNonnegative(group, "netNewInputTokens", ref partial), + ReadNonnegative(group, "cachedInputTokens", ref partial), + ReadNonnegative(group, "inputTokens", ref partial), + ReadNonnegative(group, "outputTokens", ref partial), + ReadNonnegative(group, "totalTokens", ref partial)); + if (parsed is { Model: null, ReasoningEffort: null, Speed: null, EstimatedUsageCreditsMicros: null, + NetNewInputTokens: null, CachedInputTokens: null, InputTokens: null, OutputTokens: null, TotalTokens: null }) + { + partial = true; + continue; + } + + groups.Add(parsed); + } + } + else + { + partial = true; + } + + if (credits is null && usd is null && groups.Count == 0) + { + return ThreadUsageSnapshot.Unavailable with { UpdatedAt = now }; + } + + return new(expectedThreadId, accountKey, now, partial ? ThreadUsageStatus.Partial : ThreadUsageStatus.Available, + credits, usd, groups.ToArray()); + } + + private static string? ReadLabel(JsonElement element, string name, int maximumLength, ref bool partial) + { + if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind == JsonValueKind.String) + { + var original = value.GetString(); + var safe = UsageText.SanitizeExternal(original, maximumLength); + if (!string.Equals(original, safe, StringComparison.Ordinal)) partial = true; + return safe; + } + + partial = true; + return null; + } + + private static long? ReadNonnegative(JsonElement element, string name, ref bool partial) + { + if (!element.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null) return null; + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var number) && number >= 0) return number; + partial = true; + return null; + } +} diff --git a/CodexUsageDock/UsageAggregateStore.cs b/CodexUsageDock/UsageAggregateStore.cs new file mode 100644 index 0000000..d385d01 --- /dev/null +++ b/CodexUsageDock/UsageAggregateStore.cs @@ -0,0 +1,624 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexUsageDock; + +internal sealed record UsageAggregatePoint( + DateTimeOffset RecordedAt, + double? PrimaryRemainingPercent, + double? WeeklyRemainingPercent, + DateTimeOffset? PrimaryResetsAt, + DateTimeOffset? WeeklyResetsAt, + UsageDataSource Source); + +internal sealed record UsageAggregateStoreDocument( + int SchemaVersion, + int RetentionDays, + UsageAggregatePoint?[]? Observations); + +internal sealed class UsageAggregateStore +{ + internal const int SchemaVersion = 1; + internal const int MaximumEntries = 27_000; + internal const int DefaultRetentionDays = 30; + internal const int MaximumDocumentBytes = 16 * 1024 * 1024; + + private static readonly TimeSpan ObservationBucket = TimeSpan.FromMinutes(5); + private const string FileName = "usage-aggregates.json"; + private const string LoadErrorMessage = "Saved usage observations could not be read. A new history will be collected."; + private const string SaveErrorMessage = "Usage observations could not be saved. They may be lost after restarting."; + private const string InvalidObservationMessage = "The usage observation was invalid and was not saved."; + private const string InvalidRetentionMessage = "Retention must be 7, 30, or 90 days."; + private const string ClearErrorMessage = "Usage observations could not be cleared."; + private const string ExportCaveat = "Local quota observations only; no tokens, costs, account identifiers, or session content."; + private readonly object _gate = new(); + private readonly string _path; + private readonly Func _clock; + private List _observations; + private int _retentionDays; + private string? _storageError; + + internal UsageAggregateStore( + string path, + int retentionDays = DefaultRetentionDays, + Func? clock = null) + { + _path = Path.GetFullPath(path); + _retentionDays = NormalizeRetentionDays(retentionDays); + _clock = clock ?? (() => DateTimeOffset.UtcNow); + _observations = Load(); + } + + internal static UsageAggregateStore CreateDefault() => new(LocalStorage.GetPath(FileName)); + + internal UsageAggregateStore ForContext(string context) => + new(LocalStorage.ContextPath(_path, context), _retentionDays, _clock); + + internal int RetentionDays + { + get + { + lock (_gate) + { + return _retentionDays; + } + } + } + + internal string? StorageError + { + get + { + lock (_gate) + { + return _storageError; + } + } + } + + internal IReadOnlyList Snapshot + { + get + { + lock (_gate) + { + return _observations.ToArray(); + } + } + } + + internal IReadOnlyList Read(DateTimeOffset now) + { + lock (_gate) + { + if (!TryNormalizeNow(now, out var normalizedNow)) + { + return _observations.ToArray(); + } + + var normalized = Normalize(_observations, normalizedNow, _retentionDays, out var changed); + if (changed) + { + var previous = _observations; + if (TrySave(normalized, out _)) + { + _observations = normalized; + } + else + { + _observations = previous; + } + } + + return _observations.ToArray(); + } + } + + internal bool SetRetentionDays(int retentionDays, DateTimeOffset now) => + SetRetentionDays(retentionDays, now, out _); + + internal bool SetRetentionDays(int retentionDays, DateTimeOffset now, out string? error) + { + error = null; + if (!TryGetSupportedRetention(retentionDays, out var normalizedRetention)) + { + error = InvalidRetentionMessage; + SetStorageError(error); + return false; + } + + if (!TryNormalizeNow(now, out var normalizedNow)) + { + error = InvalidObservationMessage; + SetStorageError(error); + return false; + } + + lock (_gate) + { + var previousRetention = _retentionDays; + var previousObservations = _observations; + _retentionDays = normalizedRetention; + var normalized = Normalize(_observations, normalizedNow, _retentionDays, out _); + if (!TrySave(normalized, out error)) + { + _retentionDays = previousRetention; + _observations = previousObservations; + return false; + } + + _observations = normalized; + return true; + } + } + + internal bool Record(UsageAggregatePoint point) => + Record(point, _clock(), out _); + + internal bool Record(UsageAggregatePoint point, out string? error) => + Record(point, _clock(), out error); + + internal bool Record(UsageAggregatePoint point, DateTimeOffset now) => + Record(point, now, out _); + + internal bool Record(UsageAggregatePoint point, DateTimeOffset now, out string? error) + { + error = null; + if (!TryNormalizeNow(now, out var normalizedNow) + || !TryNormalizePoint(point, normalizedNow, out var normalizedPoint)) + { + error = InvalidObservationMessage; + SetStorageError(error); + return false; + } + + lock (_gate) + { + var previous = _observations; + var updated = new List(previous); + var key = GetObservationKey(normalizedPoint); + var existingIndex = FindLastIndex(updated, key); + if (existingIndex >= 0) + { + if (updated[existingIndex].RecordedAt > normalizedPoint.RecordedAt) + { + return true; + } + + updated[existingIndex] = normalizedPoint; + } + else + { + updated.Add(normalizedPoint); + } + + updated = Normalize(updated, normalizedNow, _retentionDays, out _); + if (!TrySave(updated, out error)) + { + _observations = previous; + return false; + } + + _observations = updated; + return true; + } + } + + internal bool Clear() => Clear(out _); + + internal bool Clear(out string? error) + { + error = null; + lock (_gate) + { + var previous = _observations; + if (!TrySave([], out error)) + { + _observations = previous; + error ??= ClearErrorMessage; + return false; + } + + _observations = []; + return true; + } + } + + internal string ExportJson() => ExportJson(_clock()); + + internal string ExportJson(DateTimeOffset now) + { + var observations = Read(now); + var output = new StringBuilder(Math.Min(1_000_000, observations.Count * 180 + 512)); + output.Append("{\n"); + output.Append(" \"schemaVersion\": 1,\n"); + output.Append(" \"units\": {\n"); + output.Append(" \"timestamps\": \"UTC ISO 8601\",\n"); + output.Append(" \"remainingPercent\": \"percent (0-100)\"\n"); + output.Append(" },\n"); + output.Append(" \"caveat\": ").Append(Quote(ExportCaveat)).Append(",\n"); + output.Append(" \"observations\": ["); + for (var index = 0; index < observations.Count; index++) + { + if (index == 0) + { + output.Append('\n'); + } + else + { + output.Append(",\n"); + } + + AppendJsonObservation(output, observations[index]); + } + + if (observations.Count > 0) + { + output.Append('\n'); + } + + output.Append(" ]\n"); + output.Append('}'); + return output.ToString(); + } + + internal string ExportCsv() => ExportCsv(_clock()); + + internal string ExportCsv(DateTimeOffset now) + { + var observations = Read(now); + var output = new StringBuilder(Math.Min(1_000_000, observations.Count * 160 + 512)); + output.Append("# schemaVersion=1\n"); + output.Append("# units=recordedAtUtc and reset timestamps are UTC ISO 8601; remainingPercent fields are percent from 0 to 100\n"); + output.Append("# caveat=Local quota observations only; no tokens, costs, account identifiers, or session content\n"); + output.Append("recordedAtUtc,primaryRemainingPercent,weeklyRemainingPercent,primaryResetsAtUtc,weeklyResetsAtUtc,source\n"); + foreach (var observation in observations) + { + output.Append(FormatUtc(observation.RecordedAt)).Append(','); + AppendCsvNumber(output, observation.PrimaryRemainingPercent).Append(','); + AppendCsvNumber(output, observation.WeeklyRemainingPercent).Append(','); + AppendCsvTimestamp(output, observation.PrimaryResetsAt).Append(','); + AppendCsvTimestamp(output, observation.WeeklyResetsAt).Append(','); + output.Append(observation.Source.ToString()).Append('\n'); + } + + return output.ToString(); + } + + internal static bool TryCreateLivePoint( + CodexUsageSnapshot? snapshot, + DateTimeOffset now, + TimeSpan refreshInterval, + out UsageAggregatePoint? point) + { + point = null; + if (snapshot is null + || snapshot.Source != UsageDataSource.AppServer + || string.IsNullOrWhiteSpace(snapshot.AccountKey) + || !TryNormalizeNow(now, out var normalizedNow) + || snapshot.UpdatedAt > normalizedNow + || !UsageFreshness.IsFresh(snapshot.UpdatedAt, normalizedNow, refreshInterval)) + { + return false; + } + + var primary = TryGetWindowValues(snapshot.Primary, normalizedNow); + var weekly = TryGetWindowValues(snapshot.Secondary, normalizedNow); + if (primary is null && weekly is null) + { + return false; + } + + point = new UsageAggregatePoint( + snapshot.UpdatedAt.ToUniversalTime(), + primary?.RemainingPercent, + weekly?.RemainingPercent, + primary?.ResetsAt, + weekly?.ResetsAt, + UsageDataSource.AppServer); + return true; + } + + private List Load() + { + try + { + if (!File.Exists(_path)) + { + return []; + } + + var fileInfo = new FileInfo(_path); + if (fileInfo.Length > MaximumDocumentBytes) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var document = JsonSerializer.Deserialize( + File.ReadAllText(_path), + UsageAggregateStoreJsonContext.Default.UsageAggregateStoreDocument); + if (document is null + || document.SchemaVersion != SchemaVersion + || document.Observations is null + || !TryNormalizeNow(_clock(), out var now)) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var normalized = Normalize(document.Observations, now, _retentionDays, out var changed); + if (changed) + { + _ = TrySave(normalized, out _); + } + + return normalized; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or NotSupportedException or InvalidOperationException) + { + LocalStorage.TraceFailure("load usage observations", error); + SetStorageError(LoadErrorMessage); + return []; + } + } + + private bool TrySave(IReadOnlyList observations, out string? error) + { + error = null; + try + { + var document = new UsageAggregateStoreDocument( + SchemaVersion, + _retentionDays, + observations.ToArray()); + var json = JsonSerializer.Serialize( + document, + UsageAggregateStoreJsonContext.Default.UsageAggregateStoreDocument); + if (!LocalStorage.TryWrite(_path, json)) + { + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + + SetStorageError(null); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException or NotSupportedException or InvalidOperationException or ArgumentException) + { + LocalStorage.TraceFailure("save usage observations", exception); + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + } + + private static List Normalize( + IEnumerable observations, + DateTimeOffset now, + int retentionDays, + out bool changed) + { + var original = observations.ToArray(); + var accepted = new List(); + foreach (var observation in original) + { + if (observation is null || !TryNormalizePoint(observation, now, out var normalized)) + { + continue; + } + + if (!IsWithinRetention(normalized.RecordedAt, now, retentionDays)) + { + continue; + } + + accepted.Add(normalized); + } + + accepted.Sort(static (left, right) => left.RecordedAt.CompareTo(right.RecordedAt)); + var deduplicated = new List(Math.Min(accepted.Count, MaximumEntries)); + var indexes = new Dictionary(); + foreach (var observation in accepted) + { + var key = GetObservationKey(observation); + if (indexes.TryGetValue(key, out var index)) + { + deduplicated[index] = observation; + } + else + { + indexes[key] = deduplicated.Count; + deduplicated.Add(observation); + } + } + + if (deduplicated.Count > MaximumEntries) + { + deduplicated = deduplicated.TakeLast(MaximumEntries).ToList(); + } + + changed = accepted.Count != original.Length + || deduplicated.Count != accepted.Count + || !deduplicated.SequenceEqual(original.Where(observation => observation is not null)!.Cast()); + return deduplicated; + } + + private static bool IsWithinRetention(DateTimeOffset recordedAt, DateTimeOffset now, int retentionDays) + { + try + { + var age = now - recordedAt; + return age >= TimeSpan.Zero && age <= TimeSpan.FromDays(retentionDays); + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + private static bool TryNormalizePoint( + UsageAggregatePoint point, + DateTimeOffset now, + out UsageAggregatePoint normalized) + { + normalized = point; + if (point.Source != UsageDataSource.AppServer + || !IsValidTimestamp(point.RecordedAt) + || point.RecordedAt > now + || !IsValidPercent(point.PrimaryRemainingPercent) + || !IsValidPercent(point.WeeklyRemainingPercent)) + { + return false; + } + + if (point.PrimaryRemainingPercent is null && point.WeeklyRemainingPercent is null) + { + return false; + } + + if (!TryNormalizeReset(point.PrimaryResetsAt, point.RecordedAt, out var primaryReset) + || !TryNormalizeReset(point.WeeklyResetsAt, point.RecordedAt, out var weeklyReset)) + { + return false; + } + + normalized = new UsageAggregatePoint( + point.RecordedAt.ToUniversalTime(), + point.PrimaryRemainingPercent, + point.WeeklyRemainingPercent, + point.PrimaryRemainingPercent is null ? null : primaryReset, + point.WeeklyRemainingPercent is null ? null : weeklyReset, + UsageDataSource.AppServer); + return true; + } + + private static bool TryNormalizeReset( + DateTimeOffset? reset, + DateTimeOffset recordedAt, + out DateTimeOffset? normalized) + { + normalized = null; + if (reset is not { } value) + { + return true; + } + + if (!IsValidTimestamp(value) || value < recordedAt) + { + return false; + } + + normalized = value.ToUniversalTime(); + return true; + } + + private static (double RemainingPercent, DateTimeOffset ResetsAt)? TryGetWindowValues( + RateLimitWindow? window, + DateTimeOffset now) + { + if (!UsageFreshness.IsValidWindow(window, now) + || !IsValidTimestamp(window!.ResetsAt)) + { + return null; + } + + return (window!.RemainingPercent, window.ResetsAt.ToUniversalTime()); + } + + private static bool IsValidPercent(double? value) => + value is null || double.IsFinite(value.Value) && value.Value is >= 0 and <= 100; + + private static bool IsValidTimestamp(DateTimeOffset value) => + value > DateTimeOffset.MinValue && value < DateTimeOffset.MaxValue; + + private static bool TryNormalizeNow(DateTimeOffset now, out DateTimeOffset normalized) + { + normalized = now.ToUniversalTime(); + return IsValidTimestamp(normalized); + } + + private static int NormalizeRetentionDays(int retentionDays) => + TryGetSupportedRetention(retentionDays, out var normalized) ? normalized : DefaultRetentionDays; + + private static bool TryGetSupportedRetention(int retentionDays, out int normalized) + { + normalized = retentionDays switch + { + 7 or 30 or 90 => retentionDays, + _ => 0, + }; + return normalized != 0; + } + + private static int FindLastIndex(List observations, ObservationKey key) + { + for (var index = observations.Count - 1; index >= 0; index--) + { + if (GetObservationKey(observations[index]) == key) + { + return index; + } + } + + return -1; + } + + private static ObservationKey GetObservationKey(UsageAggregatePoint observation) => new( + observation.RecordedAt.UtcTicks / ObservationBucket.Ticks, + observation.PrimaryResetsAt?.UtcTicks, + observation.WeeklyResetsAt?.UtcTicks); + + private static void AppendJsonObservation(StringBuilder output, UsageAggregatePoint observation) + { + output.Append(" {\"recordedAtUtc\": ") + .Append(Quote(FormatUtc(observation.RecordedAt))) + .Append(", \"primaryRemainingPercent\": "); + AppendJsonNumber(output, observation.PrimaryRemainingPercent); + output.Append(", \"weeklyRemainingPercent\": "); + AppendJsonNumber(output, observation.WeeklyRemainingPercent); + output.Append(", \"primaryResetsAtUtc\": "); + AppendJsonTimestamp(output, observation.PrimaryResetsAt); + output.Append(", \"weeklyResetsAtUtc\": "); + AppendJsonTimestamp(output, observation.WeeklyResetsAt); + output.Append(", \"source\": ").Append(Quote(observation.Source.ToString())).Append('}'); + } + + private static void AppendJsonNumber(StringBuilder output, double? value) => + output.Append(value is { } number ? number.ToString("R", CultureInfo.InvariantCulture) : "null"); + + private static void AppendJsonTimestamp(StringBuilder output, DateTimeOffset? value) => + output.Append(value is { } timestamp ? Quote(FormatUtc(timestamp)) : "null"); + + private static StringBuilder AppendCsvNumber(StringBuilder output, double? value) => + output.Append(value is { } number ? number.ToString("R", CultureInfo.InvariantCulture) : string.Empty); + + private static StringBuilder AppendCsvTimestamp(StringBuilder output, DateTimeOffset? value) => + output.Append(value is { } timestamp ? FormatUtc(timestamp) : string.Empty); + + private static string FormatUtc(DateTimeOffset value) => + value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'", CultureInfo.InvariantCulture); + + private static string Quote(string value) => + "\"" + JsonEncodedText.Encode(value).ToString() + "\""; + + private void SetStorageError(string? error) + { + lock (_gate) + { + _storageError = error; + } + } + + private readonly record struct ObservationKey(long Bucket, long? PrimaryResetTicks, long? WeeklyResetTicks); +} + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(UsageAggregateStoreDocument))] +[JsonSerializable(typeof(UsageAggregatePoint))] +internal sealed partial class UsageAggregateStoreJsonContext : JsonSerializerContext +{ +} diff --git a/CodexUsageDock/UsagePlanning.cs b/CodexUsageDock/UsagePlanning.cs new file mode 100644 index 0000000..2f5fb97 --- /dev/null +++ b/CodexUsageDock/UsagePlanning.cs @@ -0,0 +1,596 @@ +using System.Globalization; + +namespace CodexUsageDock; + +internal static class UsagePlanner +{ + internal const string Disclaimer = "Planning guidance only; it is not a guarantee."; + + internal static UsagePlanningResult Plan( + UsagePresentation presentation, + DateTimeOffset now, + TimeSpan refreshInterval, + DateTimeOffset desiredEnd, + int? remainingWorkdays = null) + { + ArgumentNullException.ThrowIfNull(presentation); + + if (desiredEnd <= now) + { + return Unavailable(desiredEnd, "Planning unavailable: the desired end must be after now.", remainingWorkdays); + } + + if (remainingWorkdays is < 1 or > 7) + { + return Unavailable( + desiredEnd, + "Planning unavailable: remaining workdays must be between 1 and 7.", + remainingWorkdays); + } + + var snapshot = presentation.Usage; + if (presentation.IsLoading) + { + return Unavailable(desiredEnd, "Planning paused while fresh usage data is loading.", remainingWorkdays); + } + + if (snapshot.Source != UsageDataSource.AppServer) + { + var sourceMessage = snapshot.Source switch + { + UsageDataSource.LastConfirmed => "Planning paused: only last confirmed usage is available; refresh live usage first.", + UsageDataSource.Unavailable => "Planning paused: live usage is unavailable.", + UsageDataSource.LocalSession => "Planning paused: local session usage is not a verified live app-server measurement.", + _ => "Planning paused until fresh live app-server usage is available.", + }; + return Unavailable(desiredEnd, sourceMessage, remainingWorkdays); + } + + if (string.IsNullOrWhiteSpace(snapshot.AccountKey)) + { + return Unavailable(desiredEnd, "Planning paused: the verified account identity is unavailable.", remainingWorkdays); + } + + var freshness = UsageFreshness.Classify(snapshot.UpdatedAt, now, refreshInterval); + if (freshness != UsageFreshnessState.Fresh) + { + var freshnessMessage = freshness switch + { + UsageFreshnessState.Stale => "Planning paused: usage data is stale; refresh before planning.", + UsageFreshnessState.Future => "Planning paused: the usage timestamp is in the future.", + UsageFreshnessState.Unknown => "Planning paused: usage freshness is unknown.", + _ => "Planning paused until fresh usage data is available.", + }; + return Unavailable(desiredEnd, freshnessMessage, remainingWorkdays); + } + + if (snapshot.OrdinaryUsageAllowed == false) + { + return Unavailable(desiredEnd, "Planning paused: ordinary usage is currently blocked.", remainingWorkdays); + } + + try + { + var maximumSampleAge = UsageFreshness.MaximumAge(refreshInterval); + var primary = CreateWindowPlan( + snapshot.Primary, + presentation.PrimaryHistory, + now, + desiredEnd, + remainingWorkdays, + maximumSampleAge, + adaptiveCycleCount: 0, + isPrimary: true); + var weekly = CreateWindowPlan( + snapshot.Secondary, + presentation.WeeklyHistory, + now, + desiredEnd, + remainingWorkdays, + maximumSampleAge, + CountAdaptiveCycles(presentation), + isPrimary: false); + + if (primary is null && weekly is null) + { + return Unavailable( + desiredEnd, + "Planning paused: no valid 5-hour or weekly allowance window is available.", + remainingWorkdays); + } + + var windows = new[] { primary, weekly }.Where(window => window is not null).Cast().ToArray(); + var status = windows.Length == 2 + ? $"Planning available for both allowance windows. {Disclaimer}" + : $"Planning available for one allowance window. {Disclaimer}"; + return new UsagePlanningResult( + true, + status, + desiredEnd, + remainingWorkdays, + primary, + weekly, + Disclaimer); + } + catch (ArgumentOutOfRangeException) + { + return Unavailable(desiredEnd, "Planning paused: the usage window timestamps are out of range.", remainingWorkdays); + } + catch (OverflowException) + { + return Unavailable(desiredEnd, "Planning paused: the usage window duration is out of range.", remainingWorkdays); + } + } + + private static UsagePlanningWindow? CreateWindowPlan( + RateLimitWindow? window, + IReadOnlyList history, + DateTimeOffset now, + DateTimeOffset desiredEnd, + int? requestedWorkdays, + TimeSpan maximumSampleAge, + int adaptiveCycleCount, + bool isPrimary) + { + if (!UsageFreshness.IsValidWindow(window, now)) + { + return null; + } + + var validWindow = window!; + var horizonEnd = validWindow.ResetsAt < desiredEnd ? validWindow.ResetsAt : desiredEnd; + var horizon = horizonEnd - now; + if (horizon <= TimeSpan.Zero) + { + return null; + } + + var workdays = isPrimary + ? 1 + : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now))); + var remaining = validWindow.RemainingPercent; + var pointsPerWorkday = remaining / workdays; + var pointsPerHour = pointsPerWorkday / horizon.TotalHours; + var displayLabel = FormatWindowDuration(validWindow.WindowMinutes); + var resetsBeforeDesiredEnd = validWindow.ResetsAt < desiredEnd; + var horizonMessage = resetsBeforeDesiredEnd + ? $"This {displayLabel} window resets before the desired end at {FormatUtc(validWindow.ResetsAt)}; planning stops at that reset." + : $"Planning runs until {FormatUtc(horizonEnd)}."; + + var forecast = CreateForecast( + validWindow, + history, + now, + maximumSampleAge); + var evidence = CreateEvidence( + validWindow, + history, + now, + maximumSampleAge, + adaptiveCycleCount); + var backtest = CreateBacktest( + validWindow, + history, + now, + maximumSampleAge); + + return new UsagePlanningWindow( + displayLabel, + remaining, + validWindow.WindowMinutes, + validWindow.ResetsAt, + horizonEnd, + workdays, + horizon, + pointsPerWorkday, + pointsPerHour, + resetsBeforeDesiredEnd, + horizonMessage, + forecast, + evidence, + backtest); + } + + private static UsagePlanningForecast CreateForecast( + RateLimitWindow window, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan maximumSampleAge) + { + if (!TryGetWindowStart(window, out var windowStart)) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the window duration is out of range."); + } + + try + { + var analysis = UsageTrendAnalyzer.Analyze( + history ?? Array.Empty(), + windowStart, + window.ResetsAt, + now, + dataAvailable: true, + maximumSampleAge, + adaptiveWeeklyForecastEnabled: false, + adaptiveWeeklyHistory: null); + if (analysis.Forecast is null) + { + return UsagePlanningForecast.Unavailable(analysis.ForecastStatus); + } + + return new UsagePlanningForecast(true, analysis.Forecast, analysis.ForecastStatus); + } + catch (ArgumentException) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); + } + catch (InvalidOperationException) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); + } + catch (OverflowException) + { + return UsagePlanningForecast.Unavailable("Forecast unavailable: the timestamps are out of range."); + } + } + + private static UsagePlanningEvidence CreateEvidence( + RateLimitWindow window, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan maximumSampleAge, + int adaptiveCycleCount) + { + var samples = GetWindowSamples(history, window, now); + var segment = GetLatestSegment(history, window, now, maximumSampleAge); + var measurementSpan = GetSpan(samples); + var segmentSpan = GetSpan(segment); + var summary = $"{samples.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(measurementSpan)}; " + + $"latest continuous segment: {segment.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(segmentSpan)}; " + + $"adaptive weekly cycles: {adaptiveCycleCount.ToString(CultureInfo.InvariantCulture)}. " + + "This is descriptive evidence, not a calibrated reliability probability."; + return new UsagePlanningEvidence( + samples.Length, + measurementSpan, + segment.Length, + segmentSpan, + adaptiveCycleCount, + summary); + } + + private static UsagePlanningBacktest CreateBacktest( + RateLimitWindow window, + IReadOnlyList history, + DateTimeOffset now, + TimeSpan maximumSampleAge) + { + var segment = GetLatestSegment(history, window, now, maximumSampleAge); + if (segment.Length < 3) + { + return UsagePlanningBacktest.Unavailable( + "Backtest unavailable: at least three continuous measurements are required to hold out the latest one."); + } + + var heldOut = segment[^1]; + var training = segment[..^1]; + var first = training[0]; + var last = training[^1]; + var trainingSpan = last.RecordedAt - first.RecordedAt; + var holdoutInterval = heldOut.RecordedAt - last.RecordedAt; + if (training.Length < 2 + || trainingSpan <= TimeSpan.Zero + || holdoutInterval <= TimeSpan.Zero + || holdoutInterval > MaximumGap(maximumSampleAge)) + { + return UsagePlanningBacktest.Unavailable( + "Backtest unavailable: the held-out measurement does not follow a meaningful continuous pace."); + } + + var consumed = first.RemainingPercent - last.RemainingPercent; + var rate = consumed / trainingSpan.TotalMinutes; + if (!double.IsFinite(rate) || rate <= 0) + { + return UsagePlanningBacktest.Unavailable( + "Backtest unavailable: preceding measurements do not show a usable downward pace."); + } + + var predicted = Math.Clamp( + last.RemainingPercent - rate * holdoutInterval.TotalMinutes, + 0, + 100); + var error = Math.Abs(predicted - heldOut.RemainingPercent); + var status = $"Holdout at {FormatUtc(heldOut.RecordedAt)}: predicted {FormatPercent(predicted)}%, " + + $"actual {FormatPercent(heldOut.RemainingPercent)}%, absolute error {FormatPercent(error)} percentage points; " + + "the latest measurement was excluded from the preceding-pace calculation."; + return new UsagePlanningBacktest( + true, + training.Length, + heldOut.RecordedAt, + predicted, + heldOut.RemainingPercent, + error, + status); + } + + private static UsageHistoryEntry[] GetLatestSegment( + IReadOnlyList history, + RateLimitWindow window, + DateTimeOffset now, + TimeSpan maximumSampleAge) + { + if (!TryGetWindowStart(window, out var windowStart)) + { + return []; + } + + try + { + return UsageTrendHistory.LatestSegment( + history ?? Array.Empty(), + windowStart, + window.ResetsAt, + now, + MaximumGap(maximumSampleAge)); + } + catch (ArgumentException) + { + return []; + } + catch (InvalidOperationException) + { + return []; + } + catch (OverflowException) + { + return []; + } + } + + private static UsageHistoryEntry[] GetWindowSamples( + IReadOnlyList history, + RateLimitWindow window, + DateTimeOffset now) + { + if (!TryGetWindowStart(window, out var windowStart) || history is null) + { + return []; + } + + try + { + return history + .Where(sample => sample is not null + && double.IsFinite(sample.RemainingPercent) + && sample.RemainingPercent is >= 0 and <= 100 + && sample.RecordedAt >= windowStart + && sample.RecordedAt <= window.ResetsAt + && sample.RecordedAt <= now) + .OrderBy(sample => sample.RecordedAt) + .GroupBy(sample => sample.RecordedAt) + .Select(group => group.Last()) + .ToArray(); + } + catch (ArgumentException) + { + return []; + } + catch (InvalidOperationException) + { + return []; + } + catch (OverflowException) + { + return []; + } + } + + private static TimeSpan? GetSpan(UsageHistoryEntry[] samples) + { + if (samples.Length < 2) + { + return null; + } + + try + { + return samples[^1].RecordedAt - samples[0].RecordedAt; + } + catch (ArgumentOutOfRangeException) + { + return null; + } + } + + private static int CountAdaptiveCycles(UsagePresentation presentation) + { + var history = presentation.AdaptiveWeeklyHistory; + if (history is null) + { + return 0; + } + + var completed = history.CompletedCycles?.Count(cycle => cycle is not null) ?? 0; + return completed + (history.ActiveCycle is null ? 0 : 1); + } + + private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now) + { + var days = (resetsAt.Date - now.Date).Days; + if (days <= 1) + { + return 1; + } + + return days; + } + + private static TimeSpan MaximumGap(TimeSpan maximumSampleAge) + { + if (maximumSampleAge <= TimeSpan.Zero) + { + return TimeSpan.FromMinutes(15); + } + + return maximumSampleAge.Ticks > TimeSpan.MaxValue.Ticks / 3 + ? TimeSpan.MaxValue + : TimeSpan.FromTicks(maximumSampleAge.Ticks * 3); + } + + private static bool TryGetWindowStart(RateLimitWindow window, out DateTimeOffset start) + { + try + { + start = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); + return true; + } + catch (ArgumentOutOfRangeException) + { + start = default; + return false; + } + catch (OverflowException) + { + start = default; + return false; + } + } + + private static string FormatDuration(TimeSpan? duration) + { + if (duration is not { } value) + { + return "unknown span"; + } + + if (value.TotalMinutes < 1) + { + return $"{Math.Max(1, (int)Math.Round(value.TotalSeconds))} seconds"; + } + + if (value.TotalHours < 1) + { + return $"{Math.Round(value.TotalMinutes, 1).ToString("0.#", CultureInfo.InvariantCulture)} minutes"; + } + + if (value.TotalDays < 1) + { + return $"{Math.Round(value.TotalHours, 1).ToString("0.#", CultureInfo.InvariantCulture)} hours"; + } + + return $"{Math.Round(value.TotalDays, 1).ToString("0.#", CultureInfo.InvariantCulture)} days"; + } + + private static string FormatPercent(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + + private static string FormatWindowDuration(int minutes) + { + if (minutes == (int)TimeSpan.FromHours(5).TotalMinutes) + { + return "5-hour"; + } + + if (minutes == (int)TimeSpan.FromDays(7).TotalMinutes) + { + return "weekly"; + } + + if (minutes > 0 && minutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) + { + return $"{minutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; + } + + if (minutes > 0 && minutes % 60 == 0) + { + return $"{minutes / 60}-hour"; + } + + return $"{minutes}-minute"; + } + + private static string FormatUtc(DateTimeOffset value) => + value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture); + + private static UsagePlanningResult Unavailable( + DateTimeOffset desiredEnd, + string status, + int? remainingWorkdays) => + new(false, status, desiredEnd, remainingWorkdays, null, null, Disclaimer); +} + +internal sealed record UsagePlanningResult( + bool IsAvailable, + string Status, + DateTimeOffset DesiredEnd, + int? RequestedWorkdays, + UsagePlanningWindow? Primary, + UsagePlanningWindow? Weekly, + string Disclaimer) +{ + internal UsagePlanningWindow? FiveHour => Primary; + + internal IReadOnlyList Windows => + new[] { Primary, Weekly }.Where(window => window is not null).Cast().ToArray(); + + internal IReadOnlyList Evidence => + Windows.Select(window => window.Evidence).ToArray(); + + internal IReadOnlyList Backtests => + Windows.Select(window => window.Backtest).ToArray(); +} + +internal sealed record UsagePlanningWindow( + string Label, + double RemainingPercent, + int WindowMinutes, + DateTimeOffset ResetsAt, + DateTimeOffset HorizonEnd, + int Workdays, + TimeSpan Horizon, + double AvailablePointsPerWorkday, + double AvailablePointsPerHour, + bool ResetsBeforeDesiredEnd, + string HorizonMessage, + UsagePlanningForecast Forecast, + UsagePlanningEvidence Evidence, + UsagePlanningBacktest Backtest) +{ + internal double Remaining => RemainingPercent; + internal double QuotaPointsPerWorkday => AvailablePointsPerWorkday; + internal double QuotaPointsPerHour => AvailablePointsPerHour; +} + +internal sealed record UsagePlanningForecast( + bool IsAvailable, + UsageTrendForecast? Projection, + string Status) +{ + internal UsageTrendForecast? Forecast => Projection; + internal DateTimeOffset? EndsAt => Projection?.EndsAt; + internal bool ReachesLimitBeforeReset => Projection?.ReachesLimitBeforeReset ?? false; + + internal static UsagePlanningForecast Unavailable(string status) => new(false, null, status); +} + +internal sealed record UsagePlanningEvidence( + int MeasurementCount, + TimeSpan? MeasurementSpan, + int SegmentMeasurementCount, + TimeSpan? SegmentSpan, + int AdaptiveCycleCount, + string Summary) +{ + internal string Description => Summary; +} + +internal sealed record UsagePlanningBacktest( + bool IsAvailable, + int TrainingSampleCount, + DateTimeOffset? HeldOutAt, + double? PredictedRemainingPercent, + double? ActualRemainingPercent, + double? AbsoluteErrorPercentagePoints, + string Status) +{ + internal string Message => Status; + internal static UsagePlanningBacktest Unavailable(string status) => new(false, 0, null, null, null, null, status); +} diff --git a/PRIVACY.md b/PRIVACY.md index 31650cd..ac7cd4b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -16,7 +16,7 @@ Communication initiated by the extension is limited to the local Codex app-serve ## Data storage -Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains only display, refresh, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. +Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, planning, retention, forecasting, and explicit source preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt from the current weekly window after a restart. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles; measurements collected while paused are not added later. Users can also delete the learned profiles from Codex Usage settings. @@ -24,6 +24,10 @@ Account-scoped history uses a one-way hash of the account identity supplied by C Optional source preferences store the user-selected executable and Codex home paths locally in settings. These paths are not included in diagnostic reports or logs. Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. +Optional retained history stores at most 27,000 aggregate quota observations with UTC timestamps and reset times, separated by hashed account/category context. The selected retention is 7, 30, or 90 days; collection starts only after opting in. Explicit CSV/JSON exports create local files without account IDs or conversation content. Users control deletion of retained observations and exported copies separately. Neither retained observations nor exports contain prompts or task contents. + +An explicitly requested task-usage read passes the user-entered task ID through the local Codex app-server and keeps the resulting aggregate estimates only in memory. A confirmed earned-reset action sends a mutation through that server. Before sending, the extension stores a random request ID in the account's hashed local context. An unresolved ID is retained across restarts so a retry cannot accidentally become a separate redemption. Recovery records contain no authentication credentials or raw account identifiers and are separate from history deletion. + ## Permissions The Windows `runFullTrust` capability is required to run the packaged Command Palette COM server and to communicate with the locally installed Codex process. It is not used to bypass Windows security controls or access unrelated user data. diff --git a/README.md b/README.md index 791e29c..a333e1c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,16 @@ Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd **Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +## History, planning, and optional account actions + +**Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. + +**Codex workday planner** uses today's local **Workday end** and your chosen **Workdays remaining before weekly reset**. It divides remaining weekly allowance across those days and today's remaining hours; the five-hour allowance is budgeted independently. Planning stops at an earlier reset and pauses after today's chosen end. It requires fresh, identified live data. The page explains the measurement span, continuous segment, learned-cycle count, and a held-out latest-observation check where sufficient data exists. This is descriptive evidence, not a calibrated confidence percentage or a guarantee. Its recent-pace estimate is separate from the dashboard's optional adaptive weekly forecast. + +**Codex task usage and earned resets** accepts an explicit task ID for `account/usage/read` on compatible CLI versions. It shows server-estimated credits and optional USD, plus model/effort/speed and available input/cached/output token groups. These estimates are not invoices or conversions of quota percentages. Task reads verify account identity before and after, keep the most recently requested task, and retain results only in memory. + +The same page offers **Use or retry an earned reset**, which always asks for confirmation. It only uses an existing earned reset, never purchases one, and verifies the expected account before the mutation. A request ID is saved locally before sending. Unknown outcomes retain that exact ID across retries and extension restarts; concurrent clicks share the same attempt. An unreadable or unwritable recovery record stops the request. Only an unambiguous server outcome clears the pending record, and limits are refreshed afterward. This feature has synthetic protocol and service tests; no real credit was consumed while developing it. + ## Uninstall Remove **Codex Usage Dock** from **Windows Settings > Apps > Installed apps**. diff --git a/SPRINTS.md b/SPRINTS.md index c017faf..2518930 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -5,8 +5,8 @@ This series implements the recommended Codex-first roadmap, followed by a small, | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | -| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); x64/ARM64 compilation passed; GitHub test validation pending | -| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Planned | +| 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Implemented; 314 native ARM64 tests and both architecture builds passed | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. @@ -30,4 +30,10 @@ The final sprint 1 head also passed [GitHub Actions](https://github.com/TheBeems ### Sprint 2 verification -The x64 and ARM64 application code compiles without warnings. Local ARM64 test execution was blocked while loading the assembly by Windows Application Control (`0x800711C7`), including a retry with elevated execution. This is an execution gap, not a passing test result. No Windows security policy was changed. The existing GitHub workflow will run the synthetic tests and both architecture/package checks on the pushed feature branch. Live account activity, notifications, individual pinning, and configuration changes still require Command Palette verification after an authorized installation. +The x64 and ARM64 application code compiles without warnings. Local ARM64 test execution was blocked while loading the assembly by Windows Application Control (`0x800711C7`), including a retry with elevated execution. No Windows security policy was changed. [GitHub validation](https://github.com/TheBeems/CodexUsageDock/actions/runs/34383095096) passed all 235 tests, both architecture builds, and package checks on head `d067295`. Live account activity, notifications, individual pinning, and configuration changes still require Command Palette verification after an authorized installation. + +### Sprint 3 verification + +Native ARM64 test execution was available again and passed 314 tests. Both application architecture builds passed without warnings. Tests cover retention/export scope, planner assumptions and held-out observations, optional protocol support, task-request ordering, and persistent reset idempotency across ambiguous results and restarts. No real reset was redeemed. Command Palette forms, confirmations, and real-account compatibility still need live verification after an authorized installation. The GitHub workflow validates the pushed branch separately. + +Both integration preflights passed source/generated manifest, identity, asset, and self-contained runtime checks. The registered ARM64 Store package was healthy and discoverable, but its process and registration do not point at the new Debug builds. These expected mismatches leave live verification of the new code open; no registration or installation was changed. From ebe7efe59719f3f75d78e055d6a0ed37121da48d Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:49:11 +0200 Subject: [PATCH 07/21] Link sprint 3 features to pull request 20 --- CHANGELOG.md | 6 +++--- SPRINTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3162838..9a71a60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,9 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. -- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. -- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. +- Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) +- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) +- Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) diff --git a/SPRINTS.md b/SPRINTS.md index 2518930..f8ad822 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -6,7 +6,7 @@ This series implements the recommended Codex-first roadmap, followed by a small, | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | | 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | -| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | Implemented; 314 native ARM64 tests and both architecture builds passed | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests and both architecture builds passed | | 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. From 9b507f67650fb3badf65add3e3148b38c32cd503 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:24:08 +0200 Subject: [PATCH 08/21] Add local source profiles and an optional Claude usage pilot --- CHANGELOG.md | 4 + .../CachedSessionReaderTests.cs | 387 ++++++++++++++++++ .../ClaudeUsageReaderTests.cs | 367 +++++++++++++++++ .../CodexProfileStoreTests.cs | 226 ++++++++++ .../ProviderPilotIntegrationTests.cs | 112 +++++ CodexUsageDock/CachedCodexSessionReader.cs | 330 +++++++++++++++ CodexUsageDock/ClaudeUsageReader.cs | 335 +++++++++++++++ CodexUsageDock/CodexProfileStore.cs | 348 ++++++++++++++++ .../CodexUsageDockCommandsProvider.cs | 69 +++- CodexUsageDock/CodexUsageService.Claude.cs | 69 ++++ CodexUsageDock/CodexUsageService.cs | 21 +- CodexUsageDock/LocalCodexSessionReader.cs | 57 +-- CodexUsageDock/Pages/ClaudeUsagePage.cs | 64 +++ CodexUsageDock/Pages/CodexActionsPage.cs | 42 +- CodexUsageDock/Pages/CodexHistoryPage.cs | 28 +- CodexUsageDock/Pages/CodexPlanningPage.cs | 12 +- CodexUsageDock/Pages/CodexProfilesPage.cs | 356 ++++++++++++++++ .../Pages/CodexUsageDockSettingsPage.cs | 46 ++- CodexUsageDock/Pages/CodexUsageTablePage.cs | 88 ++++ PRIVACY.md | 4 + README.md | 25 ++ SPRINTS.md | 14 +- scripts/capture-claude-usage.ps1 | 313 ++++++++++++++ 23 files changed, 3234 insertions(+), 83 deletions(-) create mode 100644 CodexUsageDock.Tests/CachedSessionReaderTests.cs create mode 100644 CodexUsageDock.Tests/ClaudeUsageReaderTests.cs create mode 100644 CodexUsageDock.Tests/CodexProfileStoreTests.cs create mode 100644 CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs create mode 100644 CodexUsageDock/CachedCodexSessionReader.cs create mode 100644 CodexUsageDock/ClaudeUsageReader.cs create mode 100644 CodexUsageDock/CodexProfileStore.cs create mode 100644 CodexUsageDock/CodexUsageService.Claude.cs create mode 100644 CodexUsageDock/Pages/ClaudeUsagePage.cs create mode 100644 CodexUsageDock/Pages/CodexProfilesPage.cs create mode 100644 CodexUsageDock/Pages/CodexUsageTablePage.cs create mode 100644 scripts/capture-claude-usage.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a71a60..1b63ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Each entry links to the commit or pull request that introduced the change. ### Added +- An opt-in Claude pilot with an independent Dock band and a local statusline capture script that preserves an existing formatter or supplies a standalone quota line. +- Named local/Windows-accessible WSL source profiles and a text alternative for quota, reset, trend, and local token data. - Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) @@ -21,11 +23,13 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Changed +- Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) - Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/CachedSessionReaderTests.cs b/CodexUsageDock.Tests/CachedSessionReaderTests.cs new file mode 100644 index 0000000..3fc8429 --- /dev/null +++ b/CodexUsageDock.Tests/CachedSessionReaderTests.cs @@ -0,0 +1,387 @@ +using System.Text; +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CachedSessionReaderTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + + private string HomePath => _environment.PathFor("cached-codex-home"); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void SelectionUsesEventTimeAcrossActiveAndArchivedFilesAndClearsInactiveWindows() + { + WriteSession("rollout-active.jsonl", QuotaLine(Now.AddHours(-2), 70, 40)); + var archived = WriteSession("rollout-archived.jsonl", + QuotaLine(Now.AddMinutes(-10), null, null) + QuotaLine(Now.AddHours(-3), 80, 60), archived: true); + File.SetLastWriteTimeUtc(archived, Now.AddDays(-10).UtcDateTime); + var reader = CreateReader(); + + var result = reader.ReadLatest(); + + Assert.Equal(Now.AddMinutes(-10), result.UpdatedAt); + Assert.Null(result.Primary); + Assert.Null(result.Secondary); + Assert.Null(result.AccountKey); + Assert.Equal(UsageDataSource.LocalSession, result.Source); + Assert.True(reader.LastScanComplete); + Assert.Equal(2, reader.CachedFileCount); + } + + [Fact] + public void UnchangedCachedFilesReadZeroContentBytesEvenWhenTheirContentCannotBeOpened() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now, 25)); + var reader = CreateReader(); + var first = reader.ReadLatest(); + Assert.Equal(new FileInfo(path).Length, reader.BytesReadLastScan); + using var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); + + var unchanged = reader.ReadLatest(); + + Assert.Equal(first, unchanged); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void AppendingReadsOnlyTheNewBytesAndPreservesTheMeasurementTimestamp() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = QuotaLine(Now.AddMinutes(-5), 45); + File.AppendAllText(path, appended); + + var result = reader.ReadLatest(); + + Assert.Equal(Encoding.UTF8.GetByteCount(appended), reader.BytesReadLastScan); + Assert.Equal(Now.AddMinutes(-5), result.UpdatedAt); + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + reader.ReadLatest(); + Assert.Equal(0, reader.BytesReadLastScan); + } + + [Fact] + public void ACompleteJsonObjectWithoutItsNewlineIsNotPublishedUntilTheLineCompletes() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = QuotaLine(Now, 45).TrimEnd('\n'); + File.AppendAllText(path, appended); + + var unfinished = reader.ReadLatest(); + + Assert.Equal(25, unfinished.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + Assert.Contains("scan incomplete", unfinished.Error, StringComparison.Ordinal); + reader.ReadLatest(); + Assert.Equal(0, reader.BytesReadLastScan); + File.AppendAllText(path, "\n"); + + var completed = reader.ReadLatest(); + + Assert.Equal(1, reader.BytesReadLastScan); + Assert.Equal(45, completed.Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + Assert.Null(completed.Error); + } + + [Fact] + public void Utf8CharactersSplitAcrossAppendsAreParsedOnlyAfterTheRecordCompletes() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = Encoding.UTF8.GetBytes(QuotaLine(Now, 40).Replace("\"pro\"", "\"café\"", StringComparison.Ordinal)); + var split = Array.IndexOf(appended, (byte)0xC3) + 1; + Assert.True(split > 0); + using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write)) stream.Write(appended.AsSpan(0, split)); + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write)) stream.Write(appended.AsSpan(split)); + + var completed = reader.ReadLatest(); + + Assert.Equal("café", completed.PlanType); + Assert.Equal(appended.Length - split, reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void TruncatingAFileInvalidatesItsPreviousMeasurementAndPartialLine() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now, 95) + new string('x', 4096)); + var reader = CreateReader(); + Assert.Equal(95, reader.ReadLatest().Primary!.UsedPercent); + var replacement = QuotaLine(Now.AddHours(-1), 10); + File.WriteAllText(path, replacement); + + var result = reader.ReadLatest(); + + Assert.Equal(10, result.Primary!.UsedPercent); + Assert.Equal(Now.AddHours(-1), result.UpdatedAt); + Assert.Equal(Encoding.UTF8.GetByteCount(replacement), reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void RewritingTheSameLengthWithANewModificationTimeInvalidatesTheCheckpoint() + { + var initial = QuotaLine(Now, 95); + var replacement = QuotaLine(Now, 10); + Assert.Equal(initial.Length, replacement.Length); + var path = WriteSession("rollout-one.jsonl", initial); + var reader = CreateReader(); + reader.ReadLatest(); + File.WriteAllText(path, replacement); + File.SetLastWriteTimeUtc(path, Now.AddMinutes(1).UtcDateTime); + + var result = reader.ReadLatest(); + + Assert.Equal(10, result.Primary!.UsedPercent); + Assert.Equal(Encoding.UTF8.GetByteCount(replacement), reader.BytesReadLastScan); + } + + [Fact] + public void AChangedCreationTimeInvalidatesAReplacedFileEvenWhenItGrew() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now, 95)); + File.SetCreationTimeUtc(path, Now.AddDays(-2).UtcDateTime); + var reader = CreateReader(); + reader.ReadLatest(); + var replacement = QuotaLine(Now.AddHours(-1), 10) + "{\"message\":\"replacement\"}\n"; + File.WriteAllText(path, replacement); + File.SetCreationTimeUtc(path, Now.AddDays(-1).UtcDateTime); + + var result = reader.ReadLatest(); + + Assert.Equal(10, result.Primary!.UsedPercent); + Assert.Equal(Now.AddHours(-1), result.UpdatedAt); + Assert.Equal(Encoding.UTF8.GetByteCount(replacement), reader.BytesReadLastScan); + } + + [Fact] + public void DeletingTheLatestFileFallsBackToAnotherCachedMeasurementWithoutRereadingIt() + { + WriteSession("rollout-older.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var newest = WriteSession("rollout-newest.jsonl", QuotaLine(Now, 45)); + var reader = CreateReader(); + Assert.Equal(45, reader.ReadLatest().Primary!.UsedPercent); + File.Delete(newest); + + var result = reader.ReadLatest(); + + Assert.Equal(25, result.Primary!.UsedPercent); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.Equal(1, reader.CachedFileCount); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void UnreadableFilesAreRetriedAndMalformedOrFutureRecordsDoNotReplaceValidUsage() + { + WriteSession("rollout-readable.jsonl", QuotaLine(Now.AddHours(-1), 25) + + "[\"rate_limits\"]\n{\"rate_limits\":invalid}\n" + QuotaLine(Now.AddHours(1), 99)); + var locked = WriteSession("rollout-locked.jsonl", QuotaLine(Now, 45)); + var reader = CreateReader(); + using (var held = new FileStream(locked, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var result = reader.ReadLatest(); + Assert.Equal(25, result.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + Assert.Contains("scan incomplete", result.Error, StringComparison.Ordinal); + } + + var recovered = reader.ReadLatest(); + + Assert.Equal(45, recovered.Primary!.UsedPercent); + Assert.Equal(new FileInfo(locked).Length, reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void InitialBudgetExhaustionReturnsObservedUsageAndContinuesFromItsCheckpoint() + { + const int budget = 512; + var path = WriteSession("rollout-large.jsonl", QuotaLine(Now.AddHours(-1), 25) + + new string('x', 4096) + "\n" + QuotaLine(Now, 45)); + var reader = CreateReader(maxBytesPerScan: budget, maxLineBytes: 1024, fileReadQuantum: 256); + + var result = reader.ReadLatest(); + long totalBytes = reader.BytesReadLastScan; + + Assert.Equal(25, result.Primary!.UsedPercent); + Assert.Equal(budget, reader.BytesReadLastScan); + Assert.False(reader.LastScanComplete); + Assert.Contains("scan incomplete", result.Error, StringComparison.Ordinal); + for (var scan = 0; scan < 20 && !reader.LastScanComplete; scan++) + { + result = reader.ReadLatest(); + Assert.InRange(reader.BytesReadLastScan, 0, budget); + totalBytes += reader.BytesReadLastScan; + } + Assert.True(reader.LastScanComplete); + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.Equal(new FileInfo(path).Length, totalBytes); + Assert.Null(result.Error); + } + + [Fact] + public void ANewFileReceivesAReadTurnWhileAnOlderLargeFileIsStillPending() + { + WriteSession("rollout-large.jsonl", QuotaLine(Now.AddHours(-1), 25) + new string('x', 50_000) + "\n"); + var reader = CreateReader(maxBytesPerScan: 1024, fileReadQuantum: 512); + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + WriteSession("rollout-newest.jsonl", QuotaLine(Now, 45)); + + var result = reader.ReadLatest(); + + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + Assert.InRange(reader.BytesReadLastScan, 0, 1024); + } + + [Fact] + public void CacheOverflowRotatesFilesAndKeepsTheNewestObservedEventWhenItsCheckpointIsEvicted() + { + for (var index = 0; index < 5; index++) + WriteSession($"rollout-{index}.jsonl", QuotaLine(Now.AddMinutes(index - 5), 20 + index)); + var reader = CreateReader(maxCachedFiles: 2); + CodexUsageSnapshot? result = null; + for (var scan = 0; scan < 8; scan++) + { + result = reader.ReadLatest(); + Assert.InRange(reader.CachedFileCount, 1, 2); + Assert.False(reader.LastScanComplete); + } + + Assert.Equal(24, result!.Primary!.UsedPercent); + Assert.Contains("scan incomplete", result.Error, StringComparison.Ordinal); + Assert.Null(result.AccountKey); + } + + [Fact] + public void OverflowDoesNotEvictEveryPartialRecordBeforeAnyRecordCanFinish() + { + for (var index = 0; index < 3; index++) + WriteSession($"rollout-{index}.jsonl", QuotaLine(Now.AddMinutes(index - 3), 20 + index)); + var reader = CreateReader(maxBytesPerScan: 128, maxCachedFiles: 2, fileReadQuantum: 128); + CodexUsageSnapshot? result = null; + for (var scan = 0; scan < 30; scan++) + { + try { result = reader.ReadLatest(); } + catch (InvalidOperationException) { /* The first complete measurement may require multiple bounded reads. */ } + Assert.InRange(reader.CachedFileCount, 1, 2); + Assert.InRange(reader.BytesReadLastScan, 0, 128); + } + + Assert.NotNull(result); + Assert.Equal(22, result.Primary!.UsedPercent); + } + + [Fact] + public void AnOversizedUnfinishedLineIsDiscardedAndTheNextCompleteRecordCanStillBeRead() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(maxLineBytes: 1024); + reader.ReadLatest(); + File.AppendAllText(path, new string('x', 50_000)); + var unfinished = reader.ReadLatest(); + Assert.Equal(25, unfinished.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + File.AppendAllText(path, "\n" + QuotaLine(Now, 45)); + + var result = reader.ReadLatest(); + + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void PreCancelledReadDoesNotTouchContentOrLoseAnExistingCheckpoint() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = QuotaLine(Now, 45); + File.AppendAllText(path, appended); + + Assert.Throws(() => reader.ReadLatest(new CancellationToken(canceled: true))); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.False(reader.LastScanComplete); + + var result = reader.ReadLatest(); + + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.Equal(Encoding.UTF8.GetByteCount(appended), reader.BytesReadLastScan); + } + + [Fact] + public void CancellationDuringDiscoveryStopsBeforeContentReadsAndCanBeRetried() + { + WriteSession("rollout-one.jsonl", QuotaLine(Now, 25)); + using var cancellation = new CancellationTokenSource(); + var cancelAtClock = true; + var reader = new CachedCodexSessionReader(HomePath, clock: () => + { + if (cancelAtClock) cancellation.Cancel(); + return Now; + }); + + Assert.Throws(() => reader.ReadLatest(cancellation.Token)); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.Equal(0, reader.CachedFileCount); + cancelAtClock = false; + + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void MissingSourcesRemainUnavailableWithoutCreatingAnyDirectories() + { + var reader = CreateReader(); + + Assert.Throws(() => reader.ReadLatest()); + + Assert.False(Directory.Exists(HomePath)); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.Equal(0, reader.CachedFileCount); + } + + private CachedCodexSessionReader CreateReader(long maxBytesPerScan = 8 * 1024 * 1024, + int maxCachedFiles = 512, int maxLineBytes = 128 * 1024, int fileReadQuantum = 64 * 1024) => + new(HomePath, maxBytesPerScan, maxCachedFiles, maxLineBytes, fileReadQuantum, () => Now); + + private string WriteSession(string name, string content, bool archived = false) + { + var directory = Path.Combine(HomePath, archived ? "archived_sessions" : "sessions", "2026", "09", "09"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, name); + File.WriteAllText(path, content); + File.SetLastWriteTimeUtc(path, Now.UtcDateTime); + return path; + } + + private static string QuotaLine(DateTimeOffset recordedAt, int? primary, int? secondary = 40) => + JsonSerializer.Serialize(new + { + timestamp = recordedAt, + payload = new + { + rate_limits = new + { + primary = primary is { } shortUsed ? new { used_percent = shortUsed, window_minutes = 300, resets_at = Now.AddHours(4).ToUnixTimeSeconds() } : null, + secondary = secondary is { } weeklyUsed ? new { used_percent = weeklyUsed, window_minutes = 10080, resets_at = Now.AddDays(3).ToUnixTimeSeconds() } : null, + plan_type = "pro", + }, + }, + }) + "\n"; +} diff --git a/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs b/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs new file mode 100644 index 0000000..32d1dbc --- /dev/null +++ b/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs @@ -0,0 +1,367 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class ClaudeUsageReaderTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); + + [Fact] + public void ReaderParsesDocumentedWindowsAndIgnoresUnrelatedFields() + { + var snapshot = ReadJson( + CaptureJson( + Now, + WindowJson(23.5, Now.AddHours(1)), + WindowJson(41.25, Now.AddDays(3)), + extra: "\"private\":{\"account\":\"secret\",\"model\":\"private-model\"}")); + + Assert.Equal(ClaudeUsageReadStatus.Available, snapshot.Status); + Assert.True(snapshot.IsAvailable); + Assert.Equal(23.5, snapshot.Primary!.UsedPercent); + Assert.Equal(300, snapshot.Primary!.WindowMinutes); + Assert.Equal(76.5, snapshot.Primary!.RemainingPercent); + Assert.Equal(Now.AddHours(1), snapshot.Primary!.ResetsAt); + Assert.Equal(41.25, snapshot.Weekly!.UsedPercent); + Assert.Equal(10080, snapshot.Weekly!.WindowMinutes); + Assert.Equal(Now, snapshot.ObservedAt); + } + + [Fact] + public void ReaderRequiresTheBridgeSchemaAndDoesNotAcceptUnspecifiedAliases() + { + var aliases = """ + { + "schemaVersion": 1, + "provider": "claude", + "observedAtUTC": "2026-09-09T12:00:00.0000000Z", + "fiveHour": { "usedPercentage": 20, "resetsAt": 1788958800 }, + "sevenDay": { "usedPercentage": 30, "resetsAt": 1789214400 } + } + """; + + var snapshot = ReadJson(aliases); + + Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); + Assert.Null(snapshot.Primary); + Assert.Null(snapshot.Weekly); + } + + [Fact] + public void ReaderKeepsWindowsIndependentWhenOneIsMissingOrInvalid() + { + var missingWeekly = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddHours(1)), null)); + var invalidPrimary = ReadJson(CaptureJson(Now, WindowJson(-1, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + + Assert.Equal(ClaudeUsageReadStatus.Partial, missingWeekly.Status); + Assert.NotNull(missingWeekly.Primary); + Assert.Null(missingWeekly.Weekly); + Assert.Equal(ClaudeUsageReadStatus.Partial, invalidPrimary.Status); + Assert.Null(invalidPrimary.Primary); + Assert.NotNull(invalidPrimary.Weekly); + } + + [Fact] + public void ReaderMarksFutureAndStaleObservationsWithoutCallingThemAvailable() + { + var future = ReadJson(CaptureJson(Now.AddSeconds(1), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + var stale = ReadJson(CaptureJson(Now.AddMinutes(-6), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + + Assert.Equal(ClaudeUsageReadStatus.Future, future.Status); + Assert.False(future.IsAvailable); + Assert.NotNull(future.Primary); + Assert.Equal(ClaudeUsageReadStatus.Stale, stale.Status); + Assert.False(stale.IsAvailable); + Assert.NotNull(stale.Weekly); + } + + [Fact] + public void ReaderRejectsZoneLessObservationTimes() + { + var zoneLess = """ + { + "schemaVersion": 1, + "provider": "claude", + "observedAtUTC": "2026-09-09T12:00:00", + "rate_limits": { + "five_hour": { "used_percentage": 20, "resets_at": 1788958800 }, + "seven_day": { "used_percentage": 30, "resets_at": 1789214400 } + } + } + """; + + Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson(zoneLess).Status); + } + + [Fact] + public void ReaderRejectsExpiredAndOutOfRangeWindowValues() + { + var expiredPrimary = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddDays(3)))); + var expiredBoth = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddSeconds(-1)))); + var outOfRange = ReadJson(CaptureJson(Now, WindowJson(101, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + + Assert.Equal(ClaudeUsageReadStatus.Partial, expiredPrimary.Status); + Assert.Null(expiredPrimary.Primary); + Assert.NotNull(expiredPrimary.Weekly); + Assert.Equal(ClaudeUsageReadStatus.Unavailable, expiredBoth.Status); + Assert.Equal(ClaudeUsageReadStatus.Partial, outOfRange.Status); + Assert.Null(outOfRange.Primary); + } + + [Fact] + public void ReaderRejectsMissingMalformedAndOversizedFilesSafely() + { + Assert.Equal( + ClaudeUsageReadStatus.Unavailable, + ClaudeUsageReader.Read( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.json"), + Now, + RefreshInterval).Status); + Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson("not-json").Status); + + var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); + try + { + File.WriteAllBytes(path, new byte[ClaudeUsageReader.MaximumFileBytes + 1]); + var snapshot = ClaudeUsageReader.Read(path, Now, RefreshInterval); + Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ReaderRequiresAQualifiedCapturePath() + { + var snapshot = ClaudeUsageReader.Read("relative-claude-usage.json", Now, RefreshInterval); + + Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); + Assert.Contains("fully qualified", snapshot.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ScriptPassesThroughStatuslineAndWritesOnlyAggregateWindows() + { + var input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}},\"workspace\":{\"path\":\"private\"}}"; + var result = RunCaptureScript(input); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + Assert.DoesNotContain("do-not-copy", result.SnapshotJson, StringComparison.Ordinal); + using var document = JsonDocument.Parse(result.SnapshotJson); + var root = document.RootElement; + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("claude", root.GetProperty("provider").GetString()); + Assert.EndsWith("+00:00", root.GetProperty("observedAtUTC").GetString()!, StringComparison.Ordinal); + var limits = root.GetProperty("rate_limits"); + Assert.Equal(23.5, limits.GetProperty("five_hour").GetProperty("used_percentage").GetDouble()); + Assert.Equal(41, limits.GetProperty("seven_day").GetProperty("used_percentage").GetDouble()); + Assert.DoesNotContain("model", result.SnapshotJson, StringComparison.Ordinal); + Assert.DoesNotContain("workspace", result.SnapshotJson, StringComparison.Ordinal); + } + + [Fact] + public void ScriptStandaloneModeSuppressesRawStatuslineMetadata() + { + const string input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}}}"; + var result = RunCaptureScript(input, standalone: true); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Claude 5h 76.5% / week 59%", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("private-model", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("do-not-copy", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public void ScriptDrainsAndPassesThroughInputWhenDestinationIsRelative() + { + const string input = "{\"rate_limits\":{},\"private\":\"unchanged\"}"; + var result = RunCaptureScript(input, "relative-claude-capture.json"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ScriptWritesUnavailableSnapshotForMissingOrInvalidFields() + { + var input = "{\"rate_limits\":{\"five_hour\":{\"used_percentage\":\"75\",\"resets_at\":0},\"seven_day\":{\"used_percentage\":101,\"resets_at\":0}},\"secret\":\"keep-out\"}"; + var result = RunCaptureScript(input); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + using var document = JsonDocument.Parse(result.SnapshotJson); + var root = document.RootElement; + Assert.Equal("unavailable", root.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("five_hour").ValueKind); + Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("seven_day").ValueKind); + Assert.DoesNotContain("keep-out", result.SnapshotJson, StringComparison.Ordinal); + } + + [Fact] + public void ScriptBoundsCapturedInputButStillPassesThroughOversizedStatuslineData() + { + var input = new string('x', 256 * 1024 + 1); + var result = RunCaptureScript(input); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + using var document = JsonDocument.Parse(result.SnapshotJson); + Assert.Equal("unavailable", document.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public void ScriptRequiresAnAbsoluteDestinationAndReportsWriteFailuresGenerically() + { + var script = FindScript(); + var destinationDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destinationDirectory); + try + { + var result = RunCaptureScript("{}", destinationDirectory); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("{}", result.StandardError, StringComparison.Ordinal); + Assert.NotEmpty(script); + } + finally + { + Directory.Delete(destinationDirectory, recursive: true); + } + } + + private static ClaudeUsageSnapshot ReadJson( + string json, + DateTimeOffset? now = null, + TimeSpan? refreshInterval = null) + { + var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); + try + { + File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return ClaudeUsageReader.Read(path, now ?? Now, refreshInterval ?? RefreshInterval); + } + finally + { + File.Delete(path); + } + } + + private static string CaptureJson( + DateTimeOffset observedAt, + string? primary, + string? weekly, + string? extra = null) + { + var primaryText = primary ?? "null"; + var weeklyText = weekly ?? "null"; + var suffix = string.IsNullOrWhiteSpace(extra) ? string.Empty : $",{extra}"; + return "{\"schemaVersion\":1,\"provider\":\"claude\",\"observedAtUTC\":\"" + + observedAt.ToString("O", CultureInfo.InvariantCulture) + + "\",\"rate_limits\":{\"five_hour\":" + + primaryText + + ",\"seven_day\":" + + weeklyText + + "}" + + suffix + + "}"; + } + + private static string WindowJson(double usedPercent, DateTimeOffset resetsAt) => + "{\"used_percentage\":" + + usedPercent.ToString(CultureInfo.InvariantCulture) + + ",\"resets_at\":" + + Unix(resetsAt) + + "}"; + + private static long Unix(DateTimeOffset timestamp) => timestamp.ToUnixTimeSeconds(); + + private static ScriptResult RunCaptureScript(string input, string? outputPath = null, bool standalone = false) + { + var destination = outputPath ?? Path.Combine(Path.GetTempPath(), $"claude-capture-{Guid.NewGuid():N}.json"); + try + { + var startInfo = new ProcessStartInfo + { + FileName = FindPowerShell(), + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(FindScript()); + startInfo.ArgumentList.Add("-OutputPath"); + startInfo.ArgumentList.Add(destination); + if (standalone) + { + startInfo.ArgumentList.Add("-Standalone"); + } + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("PowerShell did not start."); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + process.StandardInput.Write(input); + process.StandardInput.Close(); + if (!process.WaitForExit(30_000)) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException("The capture fixture did not finish."); + } + + var stdout = stdoutTask.GetAwaiter().GetResult(); + var stderr = stderrTask.GetAwaiter().GetResult(); + var snapshot = File.Exists(destination) ? File.ReadAllText(destination, Encoding.UTF8) : string.Empty; + return new ScriptResult(process.ExitCode, stdout, stderr, snapshot); + } + finally + { + if (File.Exists(destination)) + { + File.Delete(destination); + } + } + } + + private static string FindScript() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "scripts", "capture-claude-usage.ps1"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("The Claude capture fixture was not found."); + } + + private static string FindPowerShell() + { + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var candidate = Path.Combine(windows, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + return File.Exists(candidate) ? candidate : "pwsh"; + } + + private sealed record ScriptResult(int ExitCode, string StandardOutput, string StandardError, string SnapshotJson); +} diff --git a/CodexUsageDock.Tests/CodexProfileStoreTests.cs b/CodexUsageDock.Tests/CodexProfileStoreTests.cs new file mode 100644 index 0000000..ddbf137 --- /dev/null +++ b/CodexUsageDock.Tests/CodexProfileStoreTests.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CodexProfileStoreTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void UpsertPersistsAndReplacesNamesCaseInsensitively() + { + var executable = _environment.PathFor("codex.exe"); + var home = _environment.PathFor("codex-home"); + File.WriteAllText(executable, string.Empty); + Directory.CreateDirectory(home); + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + + Assert.True(store.TryUpsert("Work", executable, home, out var first, out var firstError), firstError); + Assert.NotNull(first); + Assert.Equal(32, first!.Id.ToString("N").Length); + Assert.Equal(executable, first.SourceOptions.ExecutablePath); + Assert.Equal(home, first.SourceOptions.HomePath); + + Assert.True(store.TryUpsert("work", null, null, out var replacement, out var replacementError), replacementError); + Assert.NotNull(replacement); + Assert.Equal(first.Id, replacement!.Id); + Assert.Equal("work", replacement.DisplayName); + Assert.Null(replacement.SourceOptions.ExecutablePath); + Assert.Null(replacement.SourceOptions.HomePath); + + var reloaded = new CodexProfileStore(_environment.PathFor("profiles.json")); + var saved = Assert.Single(reloaded.Profiles); + Assert.Equal(replacement.Id, saved.Id); + Assert.Equal("work", saved.DisplayName); + Assert.Null(saved.SourceOptions.ExecutablePath); + Assert.Null(saved.SourceOptions.HomePath); + } + + [Fact] + public void InvalidNamesAndPathsAreRejectedAndTheProfileCountIsBounded() + { + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + + Assert.False(store.TryUpsert(string.Empty, null, null, out _, out var emptyNameError)); + Assert.Contains("1 to 40", emptyNameError, StringComparison.Ordinal); + Assert.False(store.TryUpsert(new string('x', 41), null, null, out _, out _)); + Assert.False(store.TryUpsert("bad\u0001name", null, null, out _, out _)); + + var relativeExecutable = Path.Combine("relative", "codex.exe"); + Assert.False(store.TryUpsert("Relative", relativeExecutable, null, out _, out var relativeError)); + Assert.DoesNotContain(relativeExecutable, relativeError, StringComparison.Ordinal); + + var missingHome = Path.Combine(_environment.PathFor("missing"), "home"); + Assert.False(store.TryUpsert("Missing home", null, missingHome, out _, out var missingHomeError)); + Assert.DoesNotContain(missingHome, missingHomeError, StringComparison.Ordinal); + + for (var index = 0; index < CodexProfileStore.MaximumProfiles; index++) + { + Assert.True(store.TryUpsert($"Profile {index}", null, null, out _, out var error), error); + } + + Assert.False(store.TryUpsert("Profile 8", null, null, out _, out var limitError)); + Assert.Contains("eight", limitError, StringComparison.OrdinalIgnoreCase); + Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Count); + Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Select(profile => profile.Id).Distinct().Count()); + } + + [Fact] + public void ReloadKeepsOfflinePathsButPersistsOnlyProfileFields() + { + var unavailableExecutable = Path.Combine(_environment.PathFor("offline"), "codex.exe"); + var unavailableHome = _environment.PathFor("offline-home"); + var path = _environment.PathFor("profiles.json"); + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = CodexProfileStore.SchemaVersion, + profiles = new[] + { + new + { + id = Guid.NewGuid().ToString("N"), + displayName = "Offline WSL", + executablePath = unavailableExecutable, + homePath = unavailableHome, + accountEmail = "private@example.com", + }, + }, + })); + + var store = new CodexProfileStore(path); + var loaded = Assert.Single(store.Profiles); + Assert.Equal(unavailableExecutable, loaded.SourceOptions.ExecutablePath); + Assert.Equal(unavailableHome, loaded.SourceOptions.HomePath); + + Assert.True(store.TryUpsert("Offline WSL", null, null, out _, out var error), error); + var saved = File.ReadAllText(path); + Assert.DoesNotContain("private@example.com", saved, StringComparison.Ordinal); + Assert.DoesNotContain("accountEmail", saved, StringComparison.Ordinal); + } + + [Fact] + public void MalformedAndOversizedProfileDocumentsFailSafelyAndLoadAtMostEight() + { + var path = _environment.PathFor("profiles.json"); + File.WriteAllText(path, "null"); + + var malformed = new CodexProfileStore(path); + Assert.Empty(malformed.Profiles); + Assert.Contains("could not be read", malformed.StorageError, StringComparison.Ordinal); + Assert.DoesNotContain("null", malformed.StorageError, StringComparison.OrdinalIgnoreCase); + + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = CodexProfileStore.SchemaVersion, + profiles = Enumerable.Range(0, CodexProfileStore.MaximumProfiles + 4) + .Select(index => new + { + id = Guid.NewGuid().ToString("N"), + displayName = $"Profile {index}", + executablePath = (string?)null, + homePath = (string?)null, + }) + .ToArray(), + })); + + var bounded = new CodexProfileStore(path); + Assert.Equal(CodexProfileStore.MaximumProfiles, bounded.Profiles.Count); + } + + [Fact] + public void RemoveUsesStableIdentityAndPersistsTheDeletion() + { + var path = _environment.PathFor("profiles.json"); + var store = new CodexProfileStore(path); + Assert.True(store.TryUpsert("Work", null, null, out var profile, out var error), error); + Assert.NotNull(profile); + Assert.True(store.TryGet(profile!.Id, out var found)); + Assert.Equal(profile, found); + + Assert.False(store.TryRemove(Guid.Empty, out var invalidIdError)); + Assert.Contains("identifier", invalidIdError, StringComparison.OrdinalIgnoreCase); + Assert.True(store.TryRemove(profile.Id, out var removeError), removeError); + Assert.Empty(store.Profiles); + Assert.Empty(new CodexProfileStore(path).Profiles); + } + + [Fact] + public void ProfilesPageOffersFormUseAndConfirmedRemoval() + { + var executable = _environment.PathFor("codex.exe"); + var home = _environment.PathFor("codex-home"); + File.WriteAllText(executable, string.Empty); + Directory.CreateDirectory(home); + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + Assert.True(store.TryUpsert("Work", executable, home, out _, out var error), error); + + using var page = new CodexProfilesPage(store); + var items = page.GetItems(); + Assert.Equal(2, items.Length); + var add = Assert.Single(items, item => item.Title == "Add or replace profile"); + var formPage = Assert.IsType(add.Command); + var form = Assert.IsAssignableFrom(Assert.Single(formPage.GetContent().OfType())); + Assert.Contains("\"id\":\"name\"", form.TemplateJson, StringComparison.Ordinal); + Assert.Contains("\"id\":\"executablePath\"", form.TemplateJson, StringComparison.Ordinal); + Assert.Contains("\"id\":\"homePath\"", form.TemplateJson, StringComparison.Ordinal); + + var selected = new List(); + page.ProfileSelected += (_, args) => selected.Add(args); + var profileItem = Assert.Single(items, item => item.Title == "Work"); + var use = Assert.IsAssignableFrom(profileItem.Command); + use.Invoke(page); + var selection = Assert.Single(selected); + Assert.Equal("Work", selection.Name); + Assert.Equal(executable, selection.Options.ExecutablePath); + Assert.Equal(home, selection.Options.HomePath); + + var deleteContext = Assert.IsType(Assert.Single(profileItem.MoreCommands)); + var confirmation = Assert.IsType(deleteContext.Command); + Assert.Equal("Delete profile", deleteContext.Title); + confirmation.Command.Invoke(page); + Assert.Empty(store.Profiles); + Assert.Single(page.GetItems()); + } + + [Fact] + public void NewProfileFormUpsertsAProfileWithoutApplyingIt() + { + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + using var page = new NewProfileFormPage(store); + var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); + var result = form.SubmitForm(JsonSerializer.Serialize(new + { + name = "Work", + executablePath = string.Empty, + homePath = string.Empty, + }), "{}"); + + Assert.NotNull(result); + var profile = Assert.Single(store.Profiles); + Assert.Equal("Work", profile.DisplayName); + Assert.Null(profile.SourceOptions.ExecutablePath); + Assert.Null(profile.SourceOptions.HomePath); + } + + [Theory] + [InlineData("{\"name\":\"Work\",\"executablePath\":42}")] + [InlineData("{\"name\":\"Work\",\"homePath\":false}")] + [InlineData("{\"name\":\"Work\",\"homePath\":{}}")] + public void NewProfileFormRejectsNonStringPathValues(string payload) + { + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + using var page = new NewProfileFormPage(store); + var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); + + var result = form.SubmitForm(payload, "{}"); + + Assert.NotNull(result); + Assert.Empty(store.Profiles); + } +} diff --git a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs new file mode 100644 index 0000000..82e3aa0 --- /dev/null +++ b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class ProviderPilotIntegrationTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + public void Dispose() => _environment.Dispose(); + + [Fact] + public void ApplyingAProfilePersistsPathsAndLabelBeforeTheNextStart() + { + var executable = _environment.PathFor("codex.exe"); + File.WriteAllText(executable, string.Empty); + var source = new CodexSourceOptions(executable, Path.GetDirectoryName(executable)); + var settings = _environment.CreateSettings(); + var changes = 0; + settings.Changed += (_, _) => changes++; + settings.ApplySourceProfile("Local work", source); + var restored = _environment.CreateSettings(); + Assert.Equal(source.ExecutablePath, restored.CodexExecutablePath); + Assert.Equal(source.HomePath, restored.CodexHomePath); + Assert.Equal("Local work", restored.SourceLabel); + Assert.Equal(1, changes); + } + + [Fact] + public async Task ClaudeCaptureCompletesIndependentlyOfABlockedCodexReadAndDisablingClearsIt() + { + var capture = WriteCapture(); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var service = _environment.CreateService(_ => pending.Task, () => CodexUsageSnapshot.Loading, clock: () => Now); + var codexRead = service.RefreshAsync(); + service.ConfigureClaude(true, capture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(codexRead.IsCompleted); + Assert.Equal(ClaudeUsageReadStatus.Available, service.GetClaudeUsage().Status); + Assert.Equal(75, service.GetClaudeUsage().Primary!.RemainingPercent); + var changed = JsonNode.Parse(File.ReadAllText(capture))!.AsObject(); + changed["rate_limits"]!["five_hour"]!["used_percentage"] = 50; + File.WriteAllText(capture, changed.ToJsonString()); + Assert.Same(codexRead, service.RefreshAsync()); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(50, service.GetClaudeUsage().Primary!.RemainingPercent); + Assert.False(codexRead.IsCompleted); + service.ConfigureClaude(false, capture); + Assert.Null(service.GetClaudeUsage().Primary); + Assert.Contains("disabled", service.GetClaudeUsage().Message, StringComparison.Ordinal); + pending.SetResult(CodexUsageSnapshot.Loading); + await codexRead; + } + + [Fact] + public async Task ClaudeDockHasItsOwnRestorableBandAndDoesNotAlterCodexQuotas() + { + var capture = WriteCapture(); + File.WriteAllText(_environment.PathFor("settings.json"), new JsonObject + { ["enableClaude"] = "true", ["claudeBridgePath"] = capture }.ToJsonString()); + var quota = new CodexUsageSnapshot(new(10, 300, Now.AddHours(4)), null, null, null, null, + Now, UsageDataSource.AppServer, null, AccountKey: "a"); + using var service = _environment.CreateService(_ => Task.FromResult(quota), () => quota, clock: () => Now); + using var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings(), _ => { }, () => Now); + await service.RefreshAsync(); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(90, service.Current.Primary!.RemainingPercent); + Assert.Equal(2, provider.GetDockBands()!.Length); + var band = provider.GetCommandItem("nl.mathijs.codexusage.dock.claude")!; + var items = Assert.IsAssignableFrom(band.Command).GetItems(); + Assert.Equal(2, items.Length); + Assert.Equal("Claude 5h 75%", items[0].Title); + Assert.Equal("Claude week 60%", items[1].Title); + Assert.Contains(provider.TopLevelCommands(), item => item.Command.Id == "nl.mathijs.codexusage.table"); + } + + [Fact] + public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() + { + var quota = new CodexUsageSnapshot(new(100, 300, Now.AddHours(-1)), null, null, null, new(0, null), + Now.AddHours(-2), UsageDataSource.LastConfirmed, null, + [new("extra", "Extra | quota", new(0, 60, Now.AddHours(1)), null)], DefaultBucketId: "codex"); + var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var body = CodexUsageTablePage.Format(view, Now, TimeSpan.FromMinutes(1)); + Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); + Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); + Assert.Contains("Not reported", body, StringComparison.Ordinal); + Assert.Contains("0%", body, StringComparison.Ordinal); + Assert.Contains("100%", body, StringComparison.Ordinal); + Assert.Contains("Extra \\| quota", body, StringComparison.Ordinal); + Assert.DoesNotContain("![", body, StringComparison.Ordinal); + } + + private string WriteCapture() + { + var path = _environment.PathFor("claude.json"); + File.WriteAllText(path, new JsonObject + { + ["schemaVersion"] = 1, + ["provider"] = "claude", + ["observedAtUTC"] = Now.ToString("O", CultureInfo.InvariantCulture), + ["rate_limits"] = new JsonObject + { + ["five_hour"] = new JsonObject { ["used_percentage"] = 25, ["resets_at"] = Now.AddHours(4).ToUnixTimeSeconds() }, + ["seven_day"] = new JsonObject { ["used_percentage"] = 40, ["resets_at"] = Now.AddDays(4).ToUnixTimeSeconds() }, + }, + }.ToJsonString()); + return path; + } +} diff --git a/CodexUsageDock/CachedCodexSessionReader.cs b/CodexUsageDock/CachedCodexSessionReader.cs new file mode 100644 index 0000000..5dfada4 --- /dev/null +++ b/CodexUsageDock/CachedCodexSessionReader.cs @@ -0,0 +1,330 @@ +using System.Text.Json; + +namespace CodexUsageDock; + +internal sealed class CachedCodexSessionReader +{ + private readonly string _homePath; + private readonly long _maxBytesPerScan; + private readonly int _maxCachedFiles; + private readonly int _maxLineBytes; + private readonly int _fileReadQuantum; + private readonly Func _clock; + private readonly object _sync = new(); + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly LinkedList _pending = new(); + private FileObservation? _latest; + private string? _discoveryAfter; + private long _scan; + private long _visit; + + internal CachedCodexSessionReader( + string? homePath = null, + long maxBytesPerScan = 8 * 1024 * 1024, + int maxCachedFiles = 512, + int maxLineBytes = 128 * 1024, + int fileReadQuantum = 64 * 1024, + Func? clock = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytesPerScan); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCachedFiles); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLineBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(fileReadQuantum); + _homePath = Path.GetFullPath(homePath ?? LocalStorage.GetCodexHome()); + _maxBytesPerScan = maxBytesPerScan; + _maxCachedFiles = maxCachedFiles; + _maxLineBytes = maxLineBytes; + _fileReadQuantum = fileReadQuantum; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + internal long BytesReadLastScan { get; private set; } + internal int CachedFileCount => _files.Count; + internal bool LastScanComplete { get; private set; } + + internal CodexUsageSnapshot ReadLatest(CancellationToken cancellationToken = default) + { + lock (_sync) + { + BytesReadLastScan = 0; + LastScanComplete = false; + cancellationToken.ThrowIfCancellationRequested(); + _scan++; + var now = _clock(); + var inventoryComplete = DiscoverFiles(cancellationToken); + var buffer = new byte[Math.Min(16 * 1024, _fileReadQuantum)]; + var readFailed = false; + while (_pending.First is { } next && BytesReadLastScan < _maxBytesPerScan) + { + cancellationToken.ThrowIfCancellationRequested(); + var file = next.Value; + _pending.RemoveFirst(); + file.QueueNode = null; + file.LastVisit = ++_visit; + if (!ReadFile(file, buffer, now, cancellationToken)) + { + // Retry failed files on the next refresh, rather than spinning without consuming the budget. + readFailed = true; + continue; + } + QueueIfNeeded(file); + } + + foreach (var file in _files.Values) + { + RememberLatest(file); + } + cancellationToken.ThrowIfCancellationRequested(); + LastScanComplete = inventoryComplete && !readFailed && _pending.Count == 0 + && _files.Values.All(file => file.PartialLength == 0 && !file.DiscardingLine); + var latest = _latest?.Snapshot ?? throw new InvalidOperationException("No usable Codex usage measurement was found."); + return latest with { Error = LastScanComplete ? null : "Local session scan incomplete." }; + } + } + + private bool DiscoverFiles(CancellationToken cancellationToken) + { + var comparer = new DiscoveryOrder(_discoveryAfter); + var candidates = new SortedSet(comparer); + var complete = true; + var found = 0; + var latestSeen = false; + foreach (var directory in new[] { Path.Combine(_homePath, "sessions"), Path.Combine(_homePath, "archived_sessions") }) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.Exists(directory)) continue; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = false, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + try + { + foreach (var path in Directory.EnumerateFiles(directory, "rollout-*.jsonl", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + found++; + if (_files.TryGetValue(path, out var file)) file.SeenAt = _scan; + if (StringComparer.OrdinalIgnoreCase.Equals(_latest?.Stamp.Path, path)) latestSeen = true; + FileStamp stamp; + try { stamp = ReadStamp(path); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("inspect fallback session", error); + complete = false; + continue; + } + if (_latest is { } latest && StringComparer.OrdinalIgnoreCase.Equals(latest.Stamp.Path, path)) + _latest = NeedsReset(latest.Stamp, stamp, latest.Offset) ? null : latest with { Stamp = stamp }; + if (file is not null) + { + Observe(file, stamp); + QueueIfNeeded(file); + } + else + { + candidates.Add(stamp); + if (candidates.Count > _maxCachedFiles) candidates.Remove(candidates.Max!); + } + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("enumerate fallback sessions", error); + complete = false; + } + } + + if (complete) + { + foreach (var file in _files.Values.Where(file => file.SeenAt != _scan).ToArray()) Remove(file); + if (!latestSeen) _latest = null; + } + var evictedPending = false; + foreach (var stamp in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_files.Count == _maxCachedFiles) + { + var eligible = _files.Values.Where(file => file.AddedAt != _scan); + var evicted = eligible.Where(file => file.QueueNode is null).MinBy(file => file.LastVisit); + if (evicted is null && !evictedPending) + { + evicted = eligible.Where(file => file.LastVisit > 0 && file.PartialLength == 0).MinBy(file => file.LastVisit); + evictedPending = evicted is not null; + } + if (evicted is null) break; + RememberLatest(evicted); + Remove(evicted); + } + var added = new FileState(stamp, _scan); + _files.Add(stamp.Path, added); + QueueIfNeeded(added); + _discoveryAfter = stamp.Path; + } + // An overflow can require rereading evicted checkpoints; never label that bounded view a complete inventory. + return complete && found <= _maxCachedFiles; + } + + private bool ReadFile(FileState file, byte[] buffer, DateTimeOffset now, CancellationToken cancellationToken) + { + try + { + Observe(file, ReadStamp(file.Stamp.Path)); + using var stream = new FileStream(file.Stamp.Path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, bufferSize: 1); + if (stream.Length < file.Stamp.Length) + { + Reset(file); + file.Stamp = file.Stamp with { Length = stream.Length }; + } + stream.Position = file.Offset; + var remaining = Math.Min(Math.Min(_fileReadQuantum, _maxBytesPerScan - BytesReadLastScan), file.Stamp.Length - file.Offset); + while (remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining)); + if (read == 0) return false; + ProcessBytes(file, buffer.AsSpan(0, read), now); + file.Offset += read; + BytesReadLastScan += read; + remaining -= read; + RememberLatest(file); + } + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("read fallback session", error); + return false; + } + } + + private void ProcessBytes(FileState file, ReadOnlySpan bytes, DateTimeOffset now) + { + while (!bytes.IsEmpty) + { + var newline = bytes.IndexOf((byte)'\n'); + var count = newline < 0 ? bytes.Length : newline; + if (!file.DiscardingLine) + { + if (count > _maxLineBytes - file.PartialLength) + { + file.ClearPartial(); + file.DiscardingLine = true; + } + else + { + var needed = file.PartialLength + count; + if (needed > file.Partial.Length) + Array.Resize(ref file.Partial, Math.Min(_maxLineBytes, Math.Max(needed, 1024))); + bytes[..count].CopyTo(file.Partial.AsSpan(file.PartialLength)); + file.PartialLength = needed; + } + } + if (newline < 0) break; + if (!file.DiscardingLine) ParseLine(file, now); + file.ClearPartial(); + file.DiscardingLine = false; + bytes = bytes[(newline + 1)..]; + } + } + + private static void ParseLine(FileState file, DateTimeOffset now) + { + var line = file.Partial.AsMemory(0, file.PartialLength); + if (line.Span.IndexOf("\"rate_limits\""u8) < 0) return; + if (line.Span.StartsWith("\uFEFF"u8)) line = line[3..]; + try + { + using var document = JsonDocument.Parse(line, new JsonDocumentOptions { MaxDepth = 32 }); + var snapshot = LocalCodexSessionReader.ParseSnapshot(document.RootElement, now); + if (snapshot is not null && (file.Latest is null || snapshot.UpdatedAt >= file.Latest.UpdatedAt)) + file.Latest = snapshot; + } + catch (JsonException) + { + // Malformed complete records are ignored; an unfinished record remains bounded until its newline arrives. + } + } + + private void Observe(FileState file, FileStamp stamp) + { + if (NeedsReset(file.Stamp, stamp, file.Offset)) Reset(file); + file.Stamp = stamp; + } + + private static bool NeedsReset(FileStamp previous, FileStamp current, long offset) => previous.CreatedAt != current.CreatedAt + || current.Length < previous.Length || current.Length < offset + || (current.Length == previous.Length && current.WrittenAt != previous.WrittenAt); + + private void Reset(FileState file) + { + if (StringComparer.OrdinalIgnoreCase.Equals(_latest?.Stamp.Path, file.Stamp.Path)) _latest = null; + file.Offset = 0; + file.Latest = null; + file.ClearPartial(); + file.DiscardingLine = false; + } + + private void RememberLatest(FileState file) + { + if (file.Latest is { } snapshot && (_latest is null || snapshot.UpdatedAt > _latest.Snapshot.UpdatedAt)) + _latest = new(file.Stamp, file.Offset, snapshot); + } + + private void QueueIfNeeded(FileState file) + { + if (file.QueueNode is null && file.Offset < file.Stamp.Length) file.QueueNode = _pending.AddLast(file); + } + + private void Remove(FileState file) + { + if (file.QueueNode is { } node) _pending.Remove(node); + file.ClearPartial(); + _files.Remove(file.Stamp.Path); + } + + private static FileStamp ReadStamp(string path) + { + var info = new FileInfo(path); + return new(path, info.Length, info.LastWriteTimeUtc, info.CreationTimeUtc); + } + + private sealed record FileStamp(string Path, long Length, DateTime WrittenAt, DateTime CreatedAt); + private sealed record FileObservation(FileStamp Stamp, long Offset, CodexUsageSnapshot Snapshot); + + private sealed class FileState(FileStamp stamp, long scan) + { + internal FileStamp Stamp = stamp; + internal long Offset; + internal long SeenAt = scan; + internal long AddedAt = scan; + internal long LastVisit; + internal byte[] Partial = []; + internal int PartialLength; + internal bool DiscardingLine; + internal CodexUsageSnapshot? Latest; + internal LinkedListNode? QueueNode; + + internal void ClearPartial() + { + Partial.AsSpan(0, PartialLength).Clear(); + PartialLength = 0; + } + } + + private sealed class DiscoveryOrder(string? after) : IComparer + { + public int Compare(FileStamp? left, FileStamp? right) + { + if (ReferenceEquals(left, right)) return 0; + if (left is null) return -1; + if (right is null) return 1; + var leftAfter = after is null || StringComparer.OrdinalIgnoreCase.Compare(left.Path, after) > 0; + var rightAfter = after is null || StringComparer.OrdinalIgnoreCase.Compare(right.Path, after) > 0; + return leftAfter == rightAfter ? StringComparer.OrdinalIgnoreCase.Compare(left.Path, right.Path) : leftAfter ? -1 : 1; + } + } +} diff --git a/CodexUsageDock/ClaudeUsageReader.cs b/CodexUsageDock/ClaudeUsageReader.cs new file mode 100644 index 0000000..bd6d452 --- /dev/null +++ b/CodexUsageDock/ClaudeUsageReader.cs @@ -0,0 +1,335 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum ClaudeUsageReadStatus +{ + Available, + Partial, + Unavailable, + Stale, + Future, +} + +internal sealed record ClaudeUsageWindow( + double UsedPercent, + int WindowMinutes, + DateTimeOffset ResetsAt) +{ + internal double RemainingPercent => Math.Clamp(100 - UsedPercent, 0, 100); +} + +internal sealed record ClaudeUsageSnapshot( + ClaudeUsageWindow? Primary, + ClaudeUsageWindow? Weekly, + DateTimeOffset ObservedAt, + ClaudeUsageReadStatus Status, + string Message) +{ + internal bool IsAvailable => Status is ClaudeUsageReadStatus.Available or ClaudeUsageReadStatus.Partial; + + internal static ClaudeUsageSnapshot Unavailable(string message) => + new(null, null, DateTimeOffset.MinValue, ClaudeUsageReadStatus.Unavailable, message); +} + +internal static class ClaudeUsageReader +{ + internal const int MaximumFileBytes = 64 * 1024; + private const int PrimaryWindowMinutes = 5 * 60; + private const int WeeklyWindowMinutes = 7 * 24 * 60; + + internal static ClaudeUsageSnapshot Read( + string path, + DateTimeOffset now, + TimeSpan refreshInterval, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(path) || !IsFullyQualifiedPath(path)) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture path must be a fully qualified file path."); + } + + byte[] bytes; + try + { + bytes = ReadBounded(path, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException or PathTooLongException or InvalidDataException) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture could not be read."); + } + + JsonDocument document; + try + { + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + var json = encoding.GetString(bytes); + document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 }); + } + catch (Exception error) when (error is JsonException or DecoderFallbackException or ArgumentException) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture is not valid JSON."); + } + + using (document) + { + return Parse(document.RootElement, now, refreshInterval); + } + } + + internal static ClaudeUsageSnapshot Read(string path, DateTimeOffset now) => + Read(path, now, UsageFreshness.MinimumAge); + + internal static ClaudeUsageSnapshot Read(string path) => + Read(path, DateTimeOffset.UtcNow, UsageFreshness.MinimumAge); + + private static ClaudeUsageSnapshot Parse( + JsonElement root, + DateTimeOffset now, + TimeSpan refreshInterval) + { + if (root.ValueKind != JsonValueKind.Object + || !TryGetSchemaVersion(root, out var schemaVersion) + || schemaVersion != 1 + || !TryGetString(root, "provider", out var provider) + || !string.Equals(provider, "claude", StringComparison.OrdinalIgnoreCase) + || !TryGetObservedAt(root, out var observedAt)) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture has an unsupported schema or provider."); + } + + if (!TryGetObject(root, "rate_limits", out var rateLimits)) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture has no rate-limit data."); + } + + var primary = ParseWindow(rateLimits, "five_hour", PrimaryWindowMinutes, now); + var weekly = ParseWindow(rateLimits, "seven_day", WeeklyWindowMinutes, now); + var freshness = UsageFreshness.Classify(observedAt, now, refreshInterval); + if (freshness == UsageFreshnessState.Future) + { + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Future, + "Claude usage capture timestamp is in the future."); + } + + if (freshness == UsageFreshnessState.Stale) + { + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Stale, + "Claude usage capture is stale."); + } + + if (freshness != UsageFreshnessState.Fresh) + { + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Unavailable, + "Claude usage capture freshness is unavailable."); + } + + var validCount = (primary.Window is null ? 0 : 1) + (weekly.Window is null ? 0 : 1); + var status = validCount switch + { + 2 => ClaudeUsageReadStatus.Available, + 1 => ClaudeUsageReadStatus.Partial, + _ => ClaudeUsageReadStatus.Unavailable, + }; + var message = validCount switch + { + 2 => "Claude rate-limit windows are available.", + 1 => "One Claude rate-limit window is unavailable; windows remain independent.", + _ => "No valid Claude rate-limit windows were provided.", + }; + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, status, message); + } + + private static WindowParseResult ParseWindow( + JsonElement parent, + string propertyName, + int windowMinutes, + DateTimeOffset now) + { + if (!parent.TryGetProperty(propertyName, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return new WindowParseResult(null); + } + + if (value.ValueKind != JsonValueKind.Object + || !TryGetNumber(value, "used_percentage", out var usedPercent) + || !TryGetReset(value, "resets_at", out var resetsAt)) + { + return new WindowParseResult(null); + } + + var rateWindow = new RateLimitWindow(usedPercent, windowMinutes, resetsAt); + return UsageFreshness.IsValidWindow(rateWindow, now) + ? new WindowParseResult(new ClaudeUsageWindow(usedPercent, windowMinutes, resetsAt.ToUniversalTime())) + : new WindowParseResult(null); + } + + private static bool TryGetSchemaVersion(JsonElement root, out int version) + { + version = 0; + return root.TryGetProperty("schemaVersion", out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out version); + } + + private static bool TryGetString(JsonElement parent, string propertyName, out string value) + { + value = string.Empty; + return parent.TryGetProperty(propertyName, out var element) + && element.ValueKind == JsonValueKind.String + && (value = element.GetString() ?? string.Empty).Length > 0; + } + + private static bool TryGetObservedAt(JsonElement root, out DateTimeOffset observedAt) + { + observedAt = default; + if (!TryGetString(root, "observedAtUTC", out var value) + || !DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out observedAt) + || !HasExplicitOffset(value)) + { + observedAt = default; + return false; + } + + observedAt = observedAt.ToUniversalTime(); + return true; + } + + private static bool TryGetObject(JsonElement parent, string propertyName, out JsonElement value) + { + value = default; + return parent.ValueKind == JsonValueKind.Object + && parent.TryGetProperty(propertyName, out value) + && value.ValueKind == JsonValueKind.Object; + } + + private static bool TryGetNumber( + JsonElement parent, + string propertyName, + out double number) + { + number = 0; + if (parent.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetDouble(out number) + && double.IsFinite(number) + && number is >= 0 and <= 100) + { + return true; + } + + number = 0; + return false; + } + + private static bool TryGetReset( + JsonElement parent, + string propertyName, + out DateTimeOffset resetsAt) + { + resetsAt = default; + if (parent.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt64(out var seconds)) + { + try + { + resetsAt = DateTimeOffset.FromUnixTimeSeconds(seconds); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + return false; + } + + private static byte[] ReadBounded(string path, CancellationToken cancellationToken) + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 8192, + options: FileOptions.SequentialScan); + if (stream.Length > MaximumFileBytes) + { + throw new InvalidDataException("Claude usage capture is oversized."); + } + + using var memory = new MemoryStream((int)stream.Length); + var buffer = new byte[8192]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) + { + break; + } + + if (memory.Length + read > MaximumFileBytes) + { + throw new InvalidDataException("Claude usage capture is oversized."); + } + + memory.Write(buffer, 0, read); + } + + return memory.ToArray(); + } + + private static bool IsFullyQualifiedPath(string path) + { + try + { + return Path.IsPathFullyQualified(path); + } + catch (Exception error) when (error is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + private static bool HasExplicitOffset(string value) + { + var text = value.Trim(); + if (text.EndsWith("Z", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var timeSeparator = text.IndexOf('T'); + if (timeSeparator < 0) + { + timeSeparator = text.IndexOf(' '); + } + + if (timeSeparator < 0 || timeSeparator == text.Length - 1) + { + return false; + } + + var time = text[(timeSeparator + 1)..]; + return time.Contains('+', StringComparison.Ordinal) + || time.LastIndexOf('-') > 0; + } + + private readonly record struct WindowParseResult(ClaudeUsageWindow? Window); +} diff --git a/CodexUsageDock/CodexProfileStore.cs b/CodexUsageDock/CodexProfileStore.cs new file mode 100644 index 0000000..7fcd52b --- /dev/null +++ b/CodexUsageDock/CodexProfileStore.cs @@ -0,0 +1,348 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexUsageDock; + +internal sealed record CodexProfile( + Guid Id, + string DisplayName, + CodexSourceOptions SourceOptions); + +internal sealed class CodexProfileStore +{ + internal const int SchemaVersion = 1; + internal const int MaximumProfiles = 8; + internal const int MaximumDisplayNameLength = 40; + internal const int MaximumPathLength = 1024; + internal const int MaximumDocumentBytes = 512 * 1024; + + private const string LoadErrorMessage = "Saved Codex profiles could not be read. No profiles were loaded."; + private const string SaveErrorMessage = "The Codex profile could not be saved. Try again."; + private const string InvalidNameMessage = "Profile names must contain 1 to 40 characters without control characters."; + private const string InvalidSourceMessage = "The Codex executable or home path is invalid or unavailable."; + private const string MaximumProfilesMessage = "You can save up to eight Codex profiles."; + private const string ProfileNotFoundMessage = "The Codex profile was not found."; + private const string InvalidProfileIdMessage = "The Codex profile identifier is invalid."; + private readonly object _gate = new(); + private readonly string _path; + private List _profiles; + private string? _storageError; + + internal CodexProfileStore(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = Path.GetFullPath(path); + _profiles = Load(); + } + + internal static CodexProfileStore CreateDefault() => + new(LocalStorage.GetPath("profiles.json")); + + internal IReadOnlyList Profiles + { + get + { + lock (_gate) + { + return _profiles.ToArray(); + } + } + } + + internal string? StorageError + { + get + { + lock (_gate) + { + return _storageError; + } + } + } + + internal bool TryGet(Guid id, out CodexProfile? profile) + { + lock (_gate) + { + profile = id != Guid.Empty + ? _profiles.FirstOrDefault(candidate => candidate.Id == id) + : null; + return profile is not null; + } + } + + internal bool TryUpsert( + string? displayName, + string? executablePath, + string? homePath, + out CodexProfile? profile, + out string? error) + { + profile = null; + error = null; + if (!TryNormalizeDisplayName(displayName, out var normalizedName)) + { + error = InvalidNameMessage; + return false; + } + + if (!CodexSourceOptions.TryCreate(executablePath, homePath, out var options, out _)) + { + error = InvalidSourceMessage; + return false; + } + + lock (_gate) + { + var existingIndex = _profiles.FindIndex(candidate => + string.Equals(candidate.DisplayName, normalizedName, StringComparison.OrdinalIgnoreCase)); + if (existingIndex < 0 && _profiles.Count >= MaximumProfiles) + { + error = MaximumProfilesMessage; + return false; + } + + var candidate = new CodexProfile( + existingIndex >= 0 ? _profiles[existingIndex].Id : Guid.NewGuid(), + normalizedName, + options); + var updated = _profiles.ToList(); + if (existingIndex >= 0) + { + updated[existingIndex] = candidate; + } + else + { + updated.Add(candidate); + } + + if (!TrySave(updated, out error)) + { + return false; + } + + _profiles = updated; + profile = candidate; + return true; + } + } + + internal bool TryRemove(Guid id, out string? error) + { + error = null; + if (id == Guid.Empty) + { + error = InvalidProfileIdMessage; + return false; + } + + lock (_gate) + { + var existingIndex = _profiles.FindIndex(candidate => candidate.Id == id); + if (existingIndex < 0) + { + error = ProfileNotFoundMessage; + return false; + } + + var updated = _profiles.ToList(); + updated.RemoveAt(existingIndex); + if (!TrySave(updated, out error)) + { + return false; + } + + _profiles = updated; + return true; + } + } + + private List Load() + { + try + { + if (!File.Exists(_path)) + { + return []; + } + + var fileInfo = new FileInfo(_path); + if (fileInfo.Length > MaximumDocumentBytes) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var document = JsonSerializer.Deserialize( + File.ReadAllText(_path), + CodexProfileStoreJsonContext.Default.CodexProfileDocument); + if (document is null || document.SchemaVersion != SchemaVersion || document.Profiles is null) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var profiles = new List(Math.Min(document.Profiles.Length, MaximumProfiles)); + var ids = new HashSet(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in document.Profiles) + { + if (entry is null + || !Guid.TryParseExact(entry.Id, "N", out var id) + || id == Guid.Empty + || !TryNormalizeDisplayName(entry.DisplayName, out var displayName) + || !TryNormalizePathShape(entry.ExecutablePath, executable: true, out var executablePath) + || !TryNormalizePathShape(entry.HomePath, executable: false, out var homePath) + || !ids.Add(id) + || !names.Add(displayName)) + { + continue; + } + + profiles.Add(new CodexProfile(id, displayName, new(executablePath, homePath))); + if (profiles.Count == MaximumProfiles) + { + break; + } + } + + return profiles; + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or InvalidOperationException + or ArgumentException) + { + LocalStorage.TraceFailure("load Codex profiles", exception); + SetStorageError(LoadErrorMessage); + return []; + } + } + + private bool TrySave(IReadOnlyList profiles, out string? error) + { + error = null; + try + { + var document = new CodexProfileDocument( + SchemaVersion, + profiles.Select(profile => new CodexProfileEntry( + profile.Id.ToString("N"), + profile.DisplayName, + profile.SourceOptions.ExecutablePath, + profile.SourceOptions.HomePath)).ToArray()); + var content = JsonSerializer.Serialize( + document, + CodexProfileStoreJsonContext.Default.CodexProfileDocument); + if (!LocalStorage.TryWrite(_path, content)) + { + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + + SetStorageError(null); + return true; + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or InvalidOperationException + or ArgumentException) + { + LocalStorage.TraceFailure("save Codex profiles", exception); + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + } + + private static bool TryNormalizeDisplayName(string? value, out string normalized) + { + normalized = string.Empty; + if (value is null || value.Any(char.IsControl)) + { + return false; + } + + normalized = value.Trim(); + return normalized.Length is >= 1 and <= MaximumDisplayNameLength; + } + + // Loading deliberately checks only path shape. A temporarily unavailable + // WSL or network directory remains selectable until use-time validation. + private static bool TryNormalizePathShape(string? value, bool executable, out string? normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + var trimmed = value.Trim(); + if (trimmed.Length > MaximumPathLength + || trimmed.Any(char.IsControl) + || !Path.IsPathFullyQualified(trimmed)) + { + return false; + } + + try + { + normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trimmed)); + if (normalized.Length == 0) + { + return false; + } + + if (executable) + { + var fileName = Path.GetFileName(normalized); + if (!(fileName.Equals("codex.exe", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("codex.cmd", StringComparison.OrdinalIgnoreCase)) + || CodexAppServerReader.IsWindowsAppsPath(normalized)) + { + normalized = null; + return false; + } + } + + return true; + } + catch (Exception exception) when (exception is ArgumentException + or NotSupportedException + or PathTooLongException) + { + normalized = null; + return false; + } + } + + private void SetStorageError(string? error) + { + lock (_gate) + { + _storageError = error; + } + } +} + +internal sealed record CodexProfileDocument( + [property: JsonPropertyName("schemaVersion")] int SchemaVersion, + [property: JsonPropertyName("profiles")] CodexProfileEntry?[]? Profiles); + +internal sealed record CodexProfileEntry( + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("displayName")] string? DisplayName, + [property: JsonPropertyName("executablePath")] string? ExecutablePath, + [property: JsonPropertyName("homePath")] string? HomePath); + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(CodexProfileDocument))] +[JsonSerializable(typeof(CodexProfileEntry))] +internal sealed partial class CodexProfileStoreJsonContext : JsonSerializerContext +{ +} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 5fe42e5..2688702 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -17,6 +17,12 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexPlanningPage _planner; private readonly CodexHistoryPage _history; private readonly CodexActionsPage _actions; + private readonly CodexUsageTablePage _textUsage; + private readonly CodexProfilesPage _profiles; + private readonly ClaudeUsagePage _claude; + private readonly ListItem _claudeFiveHour; + private readonly ListItem _claudeWeekly; + private readonly object _claudePresentationLock = new(); private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; private readonly Func _clock; @@ -24,6 +30,7 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; + private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -49,6 +56,7 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); + _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; @@ -56,6 +64,13 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _planner = new CodexPlanningPage(_usage, _settings, _clock); _history = new CodexHistoryPage(_usage); _actions = new CodexActionsPage(_usage); + _textUsage = new CodexUsageTablePage(_usage, _clock); + _profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath)); + _profiles.ProfileSelected += OnProfileSelected; + _claude = new ClaudeUsagePage(_usage); + _claudeFiveHour = new ListItem(_claude); + _claudeWeekly = new ListItem(_claude); + _details.Commands = [.. _details.Commands, new CommandContextItem(_textUsage) { Title = "Read usage in text" }]; _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -86,11 +101,16 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS new CommandItem(_planner) { Title = "Codex workday planner", Subtitle = "Daily quota budget, recent pace, and forecast evidence" }, new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, + new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quota tables and measured values without charts or color cues" }, + new CommandItem(_profiles) { Title = "Codex source profiles", Subtitle = "Save and select named local or Windows-accessible WSL sources" }, + new CommandItem(_claude) { Title = "Claude usage pilot", Subtitle = "Optional local statusline capture; independent Claude quotas" }, ]; _settings.Changed += OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested += OnClearAdaptiveHistoryRequested; _usage.Updated += OnUsageUpdated; + _usage.ClaudeUpdated += OnClaudeUpdated; + RefreshClaudeItems(); RebuildDockBands(); _usage.Start(); @@ -111,6 +131,7 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS FiveHourDockId => new WrappedDockItem([_fiveHour], FiveHourDockId, "Codex five-hour usage"), WeeklyDockId => new WrappedDockItem([_weekly], WeeklyDockId, "Codex weekly usage"), CreditsDockId => new WrappedDockItem([_resetsAndCredits], CreditsDockId, "Codex resets and credits"), + ClaudeDockId => new WrappedDockItem(_settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : [], ClaudeDockId, "Claude usage"), _ => null, }; } @@ -127,6 +148,7 @@ private void OnSettingsChanged(object? sender, EventArgs e) var sourceChanged = ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); + _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); @@ -144,6 +166,32 @@ private bool ApplySourceSettings() return _usage.ConfigureSource(options, error); } + private void OnProfileSelected(object? sender, CodexProfileSelectedEventArgs args) => _settings.ApplySourceProfile(args.Name, args.Options); + + private void OnClaudeUpdated(object? sender, EventArgs args) + { + RefreshClaudeItems(); + RebuildDockBands(); + RaiseItemsChanged(); + } + + private void RefreshClaudeItems() + { + lock (_claudePresentationLock) + { + var snapshot = _usage.GetClaudeUsage(); + var now = _clock(); + var fresh = snapshot.IsAvailable && UsageFreshness.IsFresh(snapshot.ObservedAt, now, _usage.RefreshInterval); + _claudeFiveHour.Title = "Claude 5h " + FormatClaudeRemaining(snapshot.Primary, fresh, now); + _claudeWeekly.Title = "Claude week " + FormatClaudeRemaining(snapshot.Weekly, fresh, now); + _claudeFiveHour.Subtitle = snapshot.Message; + _claudeWeekly.Subtitle = snapshot.Message; + } + } + + private static string FormatClaudeRemaining(ClaudeUsageWindow? window, bool fresh, DateTimeOffset now) => fresh && window is not null && window.ResetsAt > now + ? window.RemainingPercent.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + "%" : "--"; + private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) { var cleared = _usage.ClearAdaptiveWeeklyHistory(); @@ -179,18 +227,22 @@ private void OnUsageUpdated(object? sender, EventArgs e) private void RebuildDockBands() { var items = GetVisibleDockItems(); + ICommandItem[] bands; if (_settings.SeparateDockItems) { - _dockBands = items.Select(item => new WrappedDockItem([item], + bands = items.Select(item => new WrappedDockItem([item], ReferenceEquals(item, _fiveHour) ? FiveHourDockId : ReferenceEquals(item, _weekly) ? WeeklyDockId : CreditsDockId, ReferenceEquals(item, _fiveHour) ? "Codex five-hour usage" : ReferenceEquals(item, _weekly) ? "Codex weekly usage" : "Codex resets and credits")) .Cast().ToArray(); - return; } - var dockBand = items.Length == 0 - ? null - : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); - _dockBands = dockBand is null ? [] : [dockBand]; + else + { + var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); + bands = dockBand is null ? [] : [dockBand]; + } + if (_settings.EnableClaude) + bands = [.. bands, new WrappedDockItem([_claudeFiveHour, _claudeWeekly], ClaudeDockId, "Claude usage")]; + _dockBands = bands; } private IListItem[] GetVisibleDockItems() @@ -219,6 +271,8 @@ public override void Dispose() _settings.Changed -= OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested -= OnClearAdaptiveHistoryRequested; _usage.Updated -= OnUsageUpdated; + _usage.ClaudeUpdated -= OnClaudeUpdated; + _profiles.ProfileSelected -= OnProfileSelected; _fiveHour.Dispose(); _weekly.Dispose(); _resetsAndCredits.Dispose(); @@ -228,6 +282,9 @@ public override void Dispose() _planner.Dispose(); _history.Dispose(); _actions.Dispose(); + _textUsage.Dispose(); + _profiles.Dispose(); + _claude.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.Claude.cs b/CodexUsageDock/CodexUsageService.Claude.cs new file mode 100644 index 0000000..19bf9f5 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.Claude.cs @@ -0,0 +1,69 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private bool _claudeEnabled; + private string _claudePath = string.Empty; + private long _claudeGeneration; + private Task? _claudeReadTask; + private ClaudeUsageSnapshot _claudeUsage = ClaudeUsageSnapshot.Unavailable("The Claude pilot is disabled."); + internal event EventHandler? ClaudeUpdated; + + internal ClaudeUsageSnapshot GetClaudeUsage() { lock (_refreshStateLock) { return _claudeUsage; } } + internal Task ClaudeRefreshTask { get { lock (_refreshStateLock) { return _claudeReadTask ?? Task.CompletedTask; } } } + + internal void ConfigureClaude(bool enabled, string path) + { + lock (_refreshStateLock) + { + if (_disposed || _claudeEnabled == enabled && _claudePath == path) return; + _claudeEnabled = enabled; + _claudePath = path; + _claudeGeneration++; + _claudeUsage = ClaudeUsageSnapshot.Unavailable(enabled ? "Waiting for a local Claude usage capture." : "The Claude pilot is disabled."); + } + RaiseClaudeUpdated(); + StartClaudeRefresh(); + } + + private void StartClaudeRefresh() + { + lock (_refreshStateLock) + { + if (_disposed || !_claudeEnabled || _claudeReadTask is { IsCompleted: false }) return; + var path = _claudePath; + var generation = _claudeGeneration; + var interval = RefreshInterval; + var cancellationToken = _lifetimeCancellation.Token; + _claudeReadTask = Task.Run(() => + { + ClaudeUsageSnapshot result; + try { result = ClaudeUsageReader.Read(path, _clock(), interval, cancellationToken); } + catch (Exception error) + { + if (error is not OperationCanceledException) LocalStorage.TraceFailure("read Claude capture", error); + result = ClaudeUsageSnapshot.Unavailable("The Claude usage capture could not be read."); + } + lock (_refreshStateLock) + { + if (!_disposed && generation == _claudeGeneration && _claudeEnabled) _claudeUsage = result; + } + RaiseClaudeUpdated(); + bool restart; + lock (_refreshStateLock) + { + _claudeReadTask = null; + restart = !_disposed && generation != _claudeGeneration && _claudeEnabled; + } + if (restart) StartClaudeRefresh(); + }); + } + } + + private void RaiseClaudeUpdated() + { + lock (_refreshStateLock) { if (_disposed) return; } + try { ClaudeUpdated?.Invoke(this, EventArgs.Empty); } + catch (Exception error) { LocalStorage.TraceFailure("update Claude presentation", error); } + } +} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index 56deedd..f9f5f32 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -25,6 +25,7 @@ internal sealed partial class CodexUsageService : IDisposable private readonly Func _localSessionReader; private Func>? _localTokenUsageReader; private readonly bool _usesConfiguredSources; + private CachedCodexSessionReader? _configuredSessionReader; private CodexSourceOptions _sourceOptions = CodexSourceOptions.Default; private long _sourceGeneration; private string? _sourceConfigurationError; @@ -47,6 +48,7 @@ public CodexUsageService() localTokenUsageReader: new LocalCodexTokenUsageReader().ReadAsync) { _usesConfiguredSources = true; + _configuredSessionReader = new CachedCodexSessionReader(); InitializeOptionalFeatures(LocalStorage.GetPath("aggregates.json"), LocalStorage.GetPath("reset-attempt.json")); } @@ -175,6 +177,7 @@ internal bool ConfigureSource(CodexSourceOptions options, string? error = null) if (_usesConfiguredSources) { _localTokenUsageReader = new LocalCodexTokenUsageReader(options.HomePath).ReadAsync; + _configuredSessionReader = new CachedCodexSessionReader(options.HomePath); } lock (_historyLock) { @@ -258,6 +261,7 @@ internal string? HistoryStorageError public Task RefreshAsync() { + StartClaudeRefresh(); TaskCompletionSource completion; CancellationToken cancellationToken; CodexSourceOptions options; @@ -421,10 +425,12 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, CodexSou private async Task ReadSnapshotAsync(CodexSourceOptions options, long generation, CancellationToken cancellationToken) { var attemptedAt = _clock(); + CachedCodexSessionReader? localCache; lock (_refreshStateLock) { - if (_sourceConfigurationError is not null) + if (_sourceConfigurationError is not null || generation != _sourceGeneration) return CreateUnavailableSnapshot() with { LastAttemptAt = attemptedAt }; + localCache = _configuredSessionReader; } try { @@ -450,7 +456,7 @@ private async Task ReadSnapshotAsync(CodexSourceOptions opti try { var fallback = await Task.Run(() => _usesConfiguredSources - ? LocalCodexSessionReader.ReadLatest(options.HomePath ?? LocalStorage.GetCodexHome(), _clock(), cancellationToken) + ? localCache!.ReadLatest(cancellationToken) : _localSessionReader(cancellationToken), cancellationToken).ConfigureAwait(false); // Session logs do not normally identify the signed-in account. A newer // unverified log must not replace a confirmed, account-scoped measurement. @@ -460,7 +466,13 @@ private async Task ReadSnapshotAsync(CodexSourceOptions opti { return LastConfirmedSnapshot(confirmed, attemptedAt); } - return fallback with { Error = LiveDataUnavailableMessage, LastAttemptAt = attemptedAt }; + return fallback with + { + Error = localCache is { LastScanComplete: false } + ? LiveDataUnavailableMessage + " The local session scan is incomplete; more data will be checked on the next refresh." + : LiveDataUnavailableMessage, + LastAttemptAt = attemptedAt, + }; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -672,7 +684,8 @@ public void Dispose() _disposed = true; refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask, - (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask); + (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask, + _claudeReadTask ?? Task.CompletedTask); } _timer.Stop(); diff --git a/CodexUsageDock/LocalCodexSessionReader.cs b/CodexUsageDock/LocalCodexSessionReader.cs index e80c882..14f8b84 100644 --- a/CodexUsageDock/LocalCodexSessionReader.cs +++ b/CodexUsageDock/LocalCodexSessionReader.cs @@ -62,32 +62,10 @@ internal static CodexUsageSnapshot ReadLatest(string codexHome, DateTimeOffset n try { using var document = JsonDocument.Parse(line); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty("timestamp", out var timestamp) - || timestamp.ValueKind != JsonValueKind.String - || !DateTimeOffset.TryParse(timestamp.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var recordedAt) - || recordedAt > now - || !root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object - || !payload.TryGetProperty("rate_limits", out var limits) || limits.ValueKind != JsonValueKind.Object - || !TryReadWindow(limits, "primary", out var primary) - || !TryReadWindow(limits, "secondary", out var secondary) - || (!limits.TryGetProperty("primary", out _) && !limits.TryGetProperty("secondary", out _))) + var snapshot = ParseSnapshot(document.RootElement, now); + if (snapshot is not null && (latest is null || snapshot.UpdatedAt >= latest.UpdatedAt)) { - continue; - } - - var windows = RateLimitWindowParser.Classify(primary, secondary); - if ((primary is not null || secondary is not null) && windows.FiveHour is null && windows.Weekly is null) - { - continue; - } - - var plan = limits.TryGetProperty("plan_type", out var planType) && planType.ValueKind == JsonValueKind.String - ? UsageText.SanitizeExternal(planType.GetString(), 32) : null; - if (latest is null || recordedAt >= latest.UpdatedAt) - { - latest = new CodexUsageSnapshot(windows.FiveHour, windows.Weekly, plan, null, null, recordedAt, UsageDataSource.LocalSession, null); + latest = snapshot; } } catch (JsonException) @@ -104,9 +82,36 @@ internal static CodexUsageSnapshot ReadLatest(string codexHome, DateTimeOffset n return latest; } + internal static CodexUsageSnapshot? ParseSnapshot(JsonElement root, DateTimeOffset now) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("timestamp", out var timestamp) + || timestamp.ValueKind != JsonValueKind.String + || !DateTimeOffset.TryParse(timestamp.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var recordedAt) + || recordedAt > now + || !root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object + || !payload.TryGetProperty("rate_limits", out var limits) || limits.ValueKind != JsonValueKind.Object + || !TryReadWindow(limits, "primary", out var primary) + || !TryReadWindow(limits, "secondary", out var secondary) + || (!limits.TryGetProperty("primary", out _) && !limits.TryGetProperty("secondary", out _))) + { + return null; + } + + var windows = RateLimitWindowParser.Classify(primary, secondary); + if ((primary is not null || secondary is not null) && windows.FiveHour is null && windows.Weekly is null) + { + return null; + } + + var plan = limits.TryGetProperty("plan_type", out var planType) && planType.ValueKind == JsonValueKind.String + ? UsageText.SanitizeExternal(planType.GetString(), 32) : null; + return new CodexUsageSnapshot(windows.FiveHour, windows.Weekly, plan, null, null, recordedAt, UsageDataSource.LocalSession, null); + } + private static bool TryReadWindow(JsonElement limits, string name, out RateLimitWindow? window) { window = RateLimitWindowParser.TryParse(limits, name, "used_percent", "window_minutes", "resets_at"); return window is not null || !limits.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null; } -} \ No newline at end of file +} diff --git a/CodexUsageDock/Pages/ClaudeUsagePage.cs b/CodexUsageDock/Pages/ClaudeUsagePage.cs new file mode 100644 index 0000000..c7b5b81 --- /dev/null +++ b/CodexUsageDock/Pages/ClaudeUsagePage.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class ClaudeUsagePage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + internal ClaudeUsagePage(CodexUsageService service) + { + _service = service; + Id = "nl.mathijs.codexusage.claude"; + Name = "Open"; + Title = "Claude usage pilot"; + Icon = new IconInfo("\uE943"); + service.ClaudeUpdated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + internal static string Format(ClaudeUsageSnapshot snapshot) + { + var body = new StringBuilder("# Claude usage pilot\n\n").Append(snapshot.Message).Append("\n\n") + .Append("These independent Claude quotas come from your explicitly selected local statusline capture. ") + .Append("The bridge does not verify the Claude account and its percentages are never added to Codex usage.\n\n"); + if (snapshot.ObservedAt != DateTimeOffset.MinValue) + body.Append("Observed UTC: ").Append(snapshot.ObservedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) + .Append(". Status: ").Append(snapshot.Status).Append(".\n\n"); + body.Append("| Claude window | Remaining at observation | Reset UTC |\n| --- | ---: | --- |\n"); + AppendWindow(body, "Five-hour", snapshot.Primary); + AppendWindow(body, "Seven-day", snapshot.Weekly); + return body.Append("\nSetup: use the optional capture script described in the repository README, select its output file in settings, ") + .Append("then enable the pilot. The extension does not change Claude configuration or an existing statusline. ") + .Append("Missing or expired windows remain unavailable until Claude emits a new capture.").ToString(); + } + + private static void AppendWindow(StringBuilder body, string name, ClaudeUsageWindow? window) + { + body.Append("| ").Append(name).Append(" | ") + .Append(window is null ? "Not reported" : window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%") + .Append(" | ").Append(window?.ResetsAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "Not reported").Append(" |\n"); + } + + private void Refresh() + { + lock (_gate) + { + if (_disposed) return; + var body = Format(_service.GetClaudeUsage()); + _content = new MarkdownContent(body); + Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh captures" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy Claude usage" }]; + } + RaiseItemsChanged(0); + } + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.ClaudeUpdated -= OnUpdated; } } +} diff --git a/CodexUsageDock/Pages/CodexActionsPage.cs b/CodexUsageDock/Pages/CodexActionsPage.cs index dc204bc..a5ec9ad 100644 --- a/CodexUsageDock/Pages/CodexActionsPage.cs +++ b/CodexUsageDock/Pages/CodexActionsPage.cs @@ -31,30 +31,30 @@ internal CodexActionsPage(CodexUsageService service) private void Refresh() { - var presentation = _service.GetActionPresentation(); - var body = new StringBuilder("# Task usage\n\n").Append(presentation.TaskStatus).Append("\n\n"); - if (presentation.Task is { } task) body.Append(FormatTask(task)); - body.Append("\n# Earned resets\n\nReported available: ") - .Append(presentation.Usage.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Unknown") - .Append(".\n\n").Append(presentation.ResetStatus) - .Append("\n\nUse the confirmed reset action only when you want to redeem one existing earned reset. ") - .Append("After an unknown outcome, retrying uses the same saved request ID. The backend decides eligibility.\n"); - var expectedAccount = presentation.Usage.AccountKey; - var commands = new List - { - new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, - }; - if (expectedAccount is not null) - { - commands.Add(new CommandContextItem(new ConfirmableCommand( - new AnonymousCommand(() => { _ = _service.ConsumeEarnedResetAsync(expectedAccount); }) { Result = CommandResult.KeepOpen() }, - "Use or retry an earned reset?", - "Redeem one existing earned reset for the currently identified Codex account. A retry of an uncertain request reuses its saved ID. No reset is purchased.", - () => true) { Name = "Use or retry an earned reset" }) { Title = "Use or retry an earned reset" }); - } lock (_gate) { if (_disposed) return; + var presentation = _service.GetActionPresentation(); + var body = new StringBuilder("# Task usage\n\n").Append(presentation.TaskStatus).Append("\n\n"); + if (presentation.Task is { } task) body.Append(FormatTask(task)); + body.Append("\n# Earned resets\n\nReported available: ") + .Append(presentation.Usage.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Unknown") + .Append(".\n\n").Append(presentation.ResetStatus) + .Append("\n\nUse the confirmed reset action only when you want to redeem one existing earned reset. ") + .Append("After an unknown outcome, retrying uses the same saved request ID. The backend decides eligibility.\n"); + var expectedAccount = presentation.Usage.AccountKey; + var commands = new List + { + new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + }; + if (expectedAccount is not null) + { + commands.Add(new CommandContextItem(new ConfirmableCommand( + new AnonymousCommand(() => { _ = _service.ConsumeEarnedResetAsync(expectedAccount); }) { Result = CommandResult.KeepOpen() }, + "Use or retry an earned reset?", + "Redeem one existing earned reset for the currently identified Codex account. A retry of an uncertain request reuses its saved ID. No reset is purchased.", + () => true) { Name = "Use or retry an earned reset" }) { Title = "Use or retry an earned reset" }); + } _content = new MarkdownContent(body.ToString()); Commands = commands.ToArray(); } diff --git a/CodexUsageDock/Pages/CodexHistoryPage.cs b/CodexUsageDock/Pages/CodexHistoryPage.cs index 27029cf..122d179 100644 --- a/CodexUsageDock/Pages/CodexHistoryPage.cs +++ b/CodexUsageDock/Pages/CodexHistoryPage.cs @@ -32,23 +32,23 @@ internal CodexHistoryPage(CodexUsageService service) internal void Refresh() { - var history = _service.GetAggregateHistory(); - var body = new StringBuilder("# Usage history\n\n"); - if (_operation is not null) body.Append(UsageText.EscapeMarkdown(_operation)).Append("\n\n"); - if (!history.Identified) body.Append("Waiting for an identified account. Histories are separated by account and quota category.\n\n"); - body.Append(history.RetentionDays == 0 ? "Collection paused. Choose 7, 30, or 90 days in settings to retain observations." - : $"Retention: {history.RetentionDays} days. Up to one observation per five minutes, with separate reset transitions.") - .Append("\n\n").Append(history.Points.Count.ToString(CultureInfo.InvariantCulture)).Append(" retained observations. ") - .Append("Exports contain UTC quota percentages and reset times, without account IDs, conversation content, tokens, or costs.\n\n"); - if (history.Error is not null) body.Append(history.Error).Append("\n\n"); - body.Append("The most recent 30 observations are shown. The exports include all retained rows for this context.\n\n") - .Append("| Observed UTC | Five-hour remaining | Weekly remaining |\n| --- | ---: | ---: |\n"); - foreach (var point in history.Points.TakeLast(30).Reverse()) - body.Append("| ").Append(point.RecordedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)).Append(" | ") - .Append(Percent(point.PrimaryRemainingPercent)).Append(" | ").Append(Percent(point.WeeklyRemainingPercent)).Append(" |\n"); lock (_gate) { if (_disposed) return; + var history = _service.GetAggregateHistory(); + var body = new StringBuilder("# Usage history\n\n"); + if (_operation is not null) body.Append(UsageText.EscapeMarkdown(_operation)).Append("\n\n"); + if (!history.Identified) body.Append("Waiting for an identified account. Histories are separated by account and quota category.\n\n"); + body.Append(history.RetentionDays == 0 ? "Collection paused. Choose 7, 30, or 90 days in settings to retain observations." + : $"Retention: {history.RetentionDays} days. Up to one observation per five minutes, with separate reset transitions.") + .Append("\n\n").Append(history.Points.Count.ToString(CultureInfo.InvariantCulture)).Append(" retained observations. ") + .Append("Exports contain UTC quota percentages and reset times, without account IDs, conversation content, tokens, or costs.\n\n"); + if (history.Error is not null) body.Append(history.Error).Append("\n\n"); + body.Append("The most recent 30 observations are shown. The exports include all retained rows for this context.\n\n") + .Append("| Observed UTC | Five-hour remaining | Weekly remaining |\n| --- | ---: | ---: |\n"); + foreach (var point in history.Points.TakeLast(30).Reverse()) + body.Append("| ").Append(point.RecordedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)).Append(" | ") + .Append(Percent(point.PrimaryRemainingPercent)).Append(" | ").Append(Percent(point.WeeklyRemainingPercent)).Append(" |\n"); _content = new MarkdownContent(body.ToString()); Commands = history.Context is not { } context ? [] : [ diff --git a/CodexUsageDock/Pages/CodexPlanningPage.cs b/CodexUsageDock/Pages/CodexPlanningPage.cs index a340e18..1429952 100644 --- a/CodexUsageDock/Pages/CodexPlanningPage.cs +++ b/CodexUsageDock/Pages/CodexPlanningPage.cs @@ -31,15 +31,15 @@ internal CodexPlanningPage(CodexUsageService service, CodexUsageDockSettingsPage internal void Refresh() { - var now = _clock(); - var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); - var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) - ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." - : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, - new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); lock (_gate) { if (_disposed) return; + var now = _clock(); + var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); + var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) + ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." + : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, + new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); _content = new MarkdownContent(body); Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, new CommandContextItem(_settings) { Title = "Change planning assumptions" }, diff --git a/CodexUsageDock/Pages/CodexProfilesPage.cs b/CodexUsageDock/Pages/CodexProfilesPage.cs new file mode 100644 index 0000000..b89b26e --- /dev/null +++ b/CodexUsageDock/Pages/CodexProfilesPage.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; + +namespace CodexUsageDock; + +internal sealed class CodexProfileSelectedEventArgs : EventArgs +{ + internal CodexProfileSelectedEventArgs(string name, CodexSourceOptions options) + { + Name = name; + Options = options; + } + + internal string Name { get; } + + internal CodexSourceOptions Options { get; } +} + +internal sealed partial class CodexProfilesPage : ListPage, IDisposable +{ + private readonly object _gate = new(); + private readonly CodexProfileStore _store; + private readonly NewProfileFormPage _newProfilePage; + private bool _disposed; + + internal CodexProfilesPage(CodexProfileStore store) + { + _store = store; + _newProfilePage = new NewProfileFormPage(store, RefreshItems); + Id = "nl.mathijs.codexusage.profiles"; + Name = "Open"; + Title = "Codex source profiles"; + Icon = new IconInfo("\uE77B"); + PlaceholderText = "Search saved profiles"; + } + + internal event EventHandler? ProfileSelected; + + public override IListItem[] GetItems() + { + lock (_gate) + { + if (_disposed) + { + return []; + } + } + + var items = new List(); + if (_store.StorageError is { Length: > 0 }) + { + items.Add(new ListItem(new NoOpCommand()) + { + Title = "Saved profiles unavailable", + Subtitle = "Saved profiles could not be read. Saving a new profile will replace the unreadable file.", + Icon = new IconInfo("\uE783"), + }); + } + + items.Add(new ListItem(_newProfilePage) + { + Title = "Add or replace profile", + Subtitle = "Enter a name and optional Codex source paths", + Icon = new IconInfo("\uE710"), + TextToSuggest = "Add or replace profile", + }); + + foreach (var profile in _store.Profiles) + { + var id = profile.Id; + var deleteCommand = new AnonymousCommand(() => RemoveProfile(id)) + { + Name = "Delete profile", + Id = $"nl.mathijs.codexusage.profile.delete.{id:N}", + Result = CommandResult.KeepOpen(), + }; + var deleteConfirmation = new ConfirmableCommand( + deleteCommand, + "Delete this Codex profile?", + "This removes the saved profile only. It does not change the active source or Codex configuration.", + () => true) + { + Name = "Delete profile", + Id = $"nl.mathijs.codexusage.profile.confirm-delete.{id:N}", + }; + + items.Add(new ListItem(new UseProfileCommand(this, id)) + { + Title = profile.DisplayName, + Subtitle = DescribeSource(profile.SourceOptions), + TextToSuggest = profile.DisplayName, + MoreCommands = + [ + new CommandContextItem(deleteConfirmation) + { + Title = "Delete profile", + Icon = new IconInfo("\uE74D"), + }, + ], + }); + } + + return [.. items]; + } + + private CommandResult UseProfile(Guid id) + { + if (!_store.TryGet(id, out var profile) || profile is null) + { + return CommandResult.ShowToast("This Codex profile is no longer available."); + } + + // Store loading keeps structurally valid offline WSL/network paths. Use + // validates availability at activation so a stale profile fails closed. + if (!CodexSourceOptions.TryCreate( + profile.SourceOptions.ExecutablePath, + profile.SourceOptions.HomePath, + out var options, + out _)) + { + return CommandResult.ShowToast("The saved Codex source path is invalid or unavailable."); + } + + ProfileSelected?.Invoke(this, new CodexProfileSelectedEventArgs(profile.DisplayName, options)); + return CommandResult.KeepOpen(); + } + + private void RemoveProfile(Guid id) + { + _store.TryRemove(id, out _); + RefreshItems(); + } + + private void RefreshItems() + { + lock (_gate) + { + if (_disposed) + { + return; + } + } + + RaiseItemsChanged(0); + } + + private static string DescribeSource(CodexSourceOptions options) + { + if (options.ExecutablePath is null && options.HomePath is null) + { + return "Uses automatic Codex source discovery"; + } + + if (options.ExecutablePath is not null && options.HomePath is not null) + { + return "Custom executable and Codex home"; + } + + return options.ExecutablePath is not null ? "Custom executable" : "Custom Codex home"; + } + + private sealed partial class UseProfileCommand : InvokableCommand + { + private readonly CodexProfilesPage _owner; + private readonly Guid _id; + + internal UseProfileCommand(CodexProfilesPage owner, Guid id) + { + _owner = owner; + _id = id; + Id = $"nl.mathijs.codexusage.profile.use.{id:N}"; + } + + public override string Name => "Use profile"; + + public override ICommandResult Invoke() => _owner.UseProfile(_id); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + _newProfilePage.Dispose(); + GC.SuppressFinalize(this); + } +} + +internal sealed partial class NewProfileFormPage : ContentPage, IDisposable +{ + private readonly object _gate = new(); + private readonly ProfileFormContent _form; + private MarkdownContent _message; + private bool _disposed; + + internal NewProfileFormPage(CodexProfileStore store, Action? saved = null) + { + _form = new ProfileFormContent(store, HandleSubmit); + _message = new MarkdownContent("# Add or replace a Codex profile\n\nSave a named source for later use. Paths are checked before they are saved."); + Id = "nl.mathijs.codexusage.profile.new"; + Name = "Open"; + Title = "Add or replace Codex profile"; + Icon = new IconInfo("\uE710"); + Saved = saved; + } + + private Action? Saved { get; } + + public override IContent[] GetContent() + { + lock (_gate) + { + return _disposed ? [] : [_message, _form]; + } + } + + private CommandResult HandleSubmit( + string? displayName, + string? executablePath, + string? homePath) + { + if (_disposed) + { + return CommandResult.KeepOpen(); + } + + if (!_form.Store.TryUpsert(displayName, executablePath, homePath, out _, out var error)) + { + SetMessage($"# Add or replace a Codex profile\n\n**Could not save the profile:** {UsageText.EscapeMarkdown(error ?? "The profile is invalid.")}"); + return CommandResult.KeepOpen(); + } + + Saved?.Invoke(); + SetMessage("# Profile saved\n\nThe profile is ready to select from the list."); + return CommandResult.GoBack(); + } + + private void SetMessage(string message) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _message = new MarkdownContent(message); + } + + RaiseItemsChanged(0); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + GC.SuppressFinalize(this); + } + + private sealed partial class ProfileFormContent : FormContent + { + private const string InvalidPathValue = ""; + private readonly Func _submit; + + internal ProfileFormContent(CodexProfileStore store, Func submit) + { + Store = store; + _submit = submit; + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[ + {"type":"Input.Text","id":"name","label":"Profile name","placeholder":"Work","maxLength":40,"isRequired":true}, + {"type":"Input.Text","id":"executablePath","label":"Codex executable path (optional)","placeholder":"C:\\Path\\to\\codex.exe","maxLength":1024}, + {"type":"Input.Text","id":"homePath","label":"Codex home path (optional)","placeholder":"C:\\Users\\you\\.codex","maxLength":1024}, + {"type":"TextBlock","text":"Use a full Windows path. A Windows-accessible WSL directory is allowed when available to Windows; this extension does not launch WSL or modify Codex configuration.","wrap":true} + ],"actions":[{"type":"Action.Submit","title":"Save profile"}]} + """; + } + + internal CodexProfileStore Store { get; } + + public override CommandResult SubmitForm(string payload) + { + if (string.IsNullOrWhiteSpace(payload) || payload.Length > 8192) + { + return _submit(null, null, null); + } + + try + { + using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return _submit(null, null, null); + } + + if (!TryReadOptionalString(document.RootElement, "executablePath", out var executablePath) + || !TryReadOptionalString(document.RootElement, "homePath", out var homePath)) + { + // A malformed optional path must not silently select the + // default source. The marker is deliberately invalid and + // never leaves this form or reaches storage. + return _submit(ReadString(document.RootElement, "name"), InvalidPathValue, null); + } + + return _submit( + ReadString(document.RootElement, "name"), + executablePath, + homePath); + } + catch (JsonException) + { + return _submit(null, null, null); + } + } + + private static bool TryReadOptionalString(JsonElement root, string propertyName, out string? value) + { + value = null; + if (!root.TryGetProperty(propertyName, out var property) + || property.ValueKind == JsonValueKind.Null) + { + return true; + } + + if (property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString(); + return true; + } + + private static string? ReadString(JsonElement root, string propertyName) => + root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } +} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 9dd522a..6ef4aae 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -25,6 +25,9 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string HistoryRetentionKey = "historyRetentionDays"; private const string WorkdayEndKey = "workdayEnd"; private const string RemainingWorkdaysKey = "remainingWorkdays"; + private const string SourceLabelKey = "sourceLabel"; + private const string EnableClaudeKey = "enableClaude"; + private const string ClaudeBridgePathKey = "claudeBridgePath"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -105,6 +108,23 @@ internal CodexUsageDockSettingsPage(string path) Placeholder = @"C:\Users\you\.codex", Multiline = false, }); + _settings.Add(new TextSetting(SourceLabelKey, "Default") + { + Label = "Source label", + Description = "An optional name for these source paths. A label does not verify the signed-in account.", + Multiline = false, + }); + _settings.Add(new ToggleSetting(EnableClaudeKey, false) + { + Label = "Enable Claude usage pilot", + Description = "Read only the local quota snapshot produced by your optional Claude statusline bridge.", + }); + _settings.Add(new TextSetting(ClaudeBridgePathKey, string.Empty) + { + Label = "Claude bridge file", + Description = "The full path to your bridge JSON file. Configure the optional statusline bridge before enabling this pilot.", + Multiline = false, + }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, [ @@ -183,6 +203,22 @@ internal CodexUsageDockSettingsPage(string path) public string CodexHomePath => GetPathSetting(CodexHomePathKey); + internal string SourceLabel => UsageText.SanitizeExternal(_settings.GetSetting(SourceLabelKey), 40) ?? "Default"; + internal bool EnableClaude => _settings.GetSetting(EnableClaudeKey); + internal string ClaudeBridgePath => GetPathSetting(ClaudeBridgePathKey); + internal string ProfileStoragePath => Path.Combine(Path.GetDirectoryName(_path)!, "profiles.json"); + + internal void ApplySourceProfile(string label, CodexSourceOptions options) + { + _settings.Update(new JsonObject + { + [SourceLabelKey] = UsageText.SanitizeExternal(label, 40) ?? "Custom", + [CodexExecutablePathKey] = options.ExecutablePath ?? string.Empty, + [CodexHomePathKey] = options.HomePath ?? string.Empty, + }.ToJsonString()); + OnSettingsChanged(_settings, _settings); + } + public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch @@ -230,7 +266,7 @@ private void Load() var valid = new JsonObject(); foreach (var property in document.RootElement.EnumerateObject()) { - if (property.Name is CodexExecutablePathKey or CodexHomePathKey) + if (property.Name is CodexExecutablePathKey or CodexHomePathKey or ClaudeBridgePathKey) { valid[property.Name] = property.Value.ValueKind == JsonValueKind.String && IsValidPathSetting(property.Value.GetString()) ? property.Value.GetString() : InvalidSourcePath; @@ -249,7 +285,11 @@ private void Load() } var value = property.Value.GetString(); - if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") + if (property.Name == SourceLabelKey) + { + valid[property.Name] = UsageText.SanitizeExternal(value, 40) ?? "Default"; + } + else if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") { valid[property.Name] = value; } @@ -278,7 +318,7 @@ private void Load() private static bool IsBooleanSetting(string name) => name is ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or UseAdaptiveWeeklyForecastKey or EnableUsageAlertsKey or CompactDockKey or SeparateDockItemsKey or - ShowAccountActivityKey; + ShowAccountActivityKey or EnableClaudeKey; private static bool IsValidPathSetting(string? value) { diff --git a/CodexUsageDock/Pages/CodexUsageTablePage.cs b/CodexUsageDock/Pages/CodexUsageTablePage.cs new file mode 100644 index 0000000..1b39943 --- /dev/null +++ b/CodexUsageDock/Pages/CodexUsageTablePage.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class CodexUsageTablePage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly Func _clock; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexUsageTablePage(CodexUsageService service, Func? clock = null) + { + _service = service; + _clock = clock ?? (() => DateTimeOffset.Now); + Id = "nl.mathijs.codexusage.table"; + Name = "Open"; + Title = "Codex usage in text"; + Icon = new IconInfo("\uE8A5"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + internal static string Format(UsagePresentation view, DateTimeOffset now, TimeSpan interval) + { + var snapshot = view.Usage; + var freshness = snapshot.Source is UsageDataSource.Initializing or UsageDataSource.Unavailable + ? "Unavailable" : UsageFreshness.Classify(snapshot.UpdatedAt, now, interval, snapshot.Source == UsageDataSource.LastConfirmed).ToString(); + var body = new StringBuilder("# Codex usage in text\n\nA text alternative to the dashboard charts.\n\n") + .Append("Status: ").Append(freshness).Append(view.IsLoading ? "; refreshing" : string.Empty) + .Append(". Source: ").Append(snapshot.SourceDisplayName).Append(".\n\n"); + if (snapshot.Source is not (UsageDataSource.Initializing or UsageDataSource.Unavailable)) + body.Append("Observed UTC: ").Append(Utc(snapshot.UpdatedAt)).Append(". Percentages below describe that observation.\n\n"); + if (snapshot.OrdinaryUsageAllowed == false) body.Append("**Ordinary usage was reported blocked.**\n\n"); + body.Append("| Quota category / window | Remaining at observation | Reset UTC | Window state now |\n| --- | ---: | --- | --- |\n"); + AppendWindow(body, "Default five-hour", snapshot.Primary, now); + AppendWindow(body, "Default weekly", snapshot.Secondary, now); + foreach (var bucket in snapshot.Buckets?.Take(32) ?? []) + { + var label = UsageText.SanitizeExternal(bucket.Name, 70) ?? UsageText.SanitizeExternal(bucket.Id, 70) ?? "Additional category"; + if (bucket.Id != snapshot.DefaultBucketId || bucket.Primary != snapshot.Primary) AppendWindow(body, label + " primary", bucket.Primary, now); + if (bucket.Id != snapshot.DefaultBucketId || bucket.Secondary != snapshot.Secondary) AppendWindow(body, label + " secondary", bucket.Secondary, now); + } + body.Append("\nAvailable earned resets at observation: ").Append(snapshot.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Not reported") + .Append(".\n\n## Recent weekly observations\n\nUp to 20 measured points, without projected values.\n\n| Observed UTC | Remaining |\n| --- | ---: |\n"); + foreach (var point in view.WeeklyHistory.TakeLast(20)) + body.Append("| ").Append(Utc(point.RecordedAt)).Append(" | ").Append(point.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% |\n"); + body.Append("\n## Locally observed daily tokens\n\nThese are local activity totals, not account-wide billing or quota percentages.\n\n"); + body.Append("Availability: ").Append(view.TokenUsage.Status).Append(".\n\n| Local calendar date | Tokens |\n| --- | ---: |\n"); + foreach (var day in view.TokenUsage.Days.TakeLast(8)) + body.Append("| ").Append(day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(" | ") + .Append(day.TotalTokens.ToString("N0", CultureInfo.InvariantCulture)).Append(" |\n"); + return body.ToString(); + } + + private static void AppendWindow(StringBuilder body, string label, RateLimitWindow? window, DateTimeOffset now) + { + body.Append("| ").Append(UsageText.EscapeMarkdown(label)); + if (window is null) { body.Append(" | Not reported | Not reported | Unknown |\n"); return; } + var valid = double.IsFinite(window.UsedPercent) && window.UsedPercent is >= 0 and <= 100 && window.WindowMinutes > 0; + body.Append(" (").Append(window.WindowMinutes.ToString(CultureInfo.InvariantCulture)).Append(" minutes) | ") + .Append(valid ? window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%" : "Invalid") + .Append(" | ").Append(Utc(window.ResetsAt)).Append(" | ") + .Append(!valid ? "Invalid" : window.ResetsAt <= now ? "Reset passed; refresh required" : "Active window").Append(" |\n"); + } + + private static string Utc(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + private void Refresh() + { + lock (_gate) + { + if (_disposed) return; + var body = Format(_service.GetPresentation(), _clock(), _service.RefreshInterval); + _content = new MarkdownContent(body); + Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy usage text" }]; + } + RaiseItemsChanged(0); + } + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} diff --git a/PRIVACY.md b/PRIVACY.md index ac7cd4b..7549117 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -28,6 +28,10 @@ Optional retained history stores at most 27,000 aggregate quota observations wit An explicitly requested task-usage read passes the user-entered task ID through the local Codex app-server and keeps the resulting aggregate estimates only in memory. A confirmed earned-reset action sends a mutation through that server. Before sending, the extension stores a random request ID in the account's hashed local context. An unresolved ID is retained across restarts so a retry cannot accidentally become a separate redemption. Recovery records contain no authentication credentials or raw account identifiers and are separate from history deletion. +Optional source profiles store up to eight display names and executable/home paths in the local `profiles.json` beside settings. Profile selection changes only the extension's source preferences; it does not copy authentication or alter Codex configuration. Deleting a preset does not delete a source directory or change the active source. The quota fallback keeps bounded file metadata, read positions, partial lines, and its latest parsed quota event only in memory. + +The optional Claude pilot reads only the capture file explicitly selected in settings. Its separately configured companion script receives Claude statusline input and retains at most 256 KiB in memory for parsing. Default mode forwards that input to the user's existing formatter; standalone mode emits only a compact quota line. The saved capture contains only a schema version, provider label, UTC timestamp, validated five-hour/seven-day usage percentages and reset times, and generic status messages. It does not copy workspace paths, model details, account identifiers, credentials, prompts, or responses into the capture. The extension does not request Claude credentials, contact Claude services, edit Claude configuration, or upload the capture. Users manage the script and its output file separately; disabling the pilot clears its displayed state and stops new reads but does not delete the file. + ## Permissions The Windows `runFullTrust` capability is required to run the packaged Command Palette COM server and to communicate with the locally installed Codex process. It is not used to bypass Windows security controls or access unrelated user data. diff --git a/README.md b/README.md index a333e1c..d9c95d1 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,29 @@ Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd **Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +**Codex source profiles** saves up to eight named sets of executable and home paths. Add a profile, then choose **Use profile** to apply it and persist it for the next start. Reusing a name replaces that preset. Profiles contain no copied credentials; a name does not verify the signed-in account. Saved WSL or network directories can remain listed while offline, but paths must be accessible before use. Deleting a preset asks for confirmation and leaves the active source settings unchanged. Only one Codex source is active at a time. + +**Codex usage in text**, also available from Details, provides quota tables, reset times, recent measured weekly points, and local daily token totals without relying on charts or color. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. + +## Optional Claude usage pilot + +The pilot displays separate Claude five-hour and seven-day limits from an explicitly selected local capture file. It is off by default and adds its own Dock band when enabled. It does not verify the Claude account, combine Claude percentages with Codex, or infer costs. Claude reads run independently of a slow Codex refresh. + +The bridge uses Claude Code's documented `rate_limits.five_hour` and `rate_limits.seven_day` statusline fields. These may be absent independently, appear only after a session receives an API response, and require an eligible subscription. The pilot does not support gateway spend-limit fields. See the [official statusline field documentation](https://code.claude.com/docs/en/statusline#available-data). + +1. Copy [capture-claude-usage.ps1](scripts/capture-claude-usage.ps1) from this repository to a permanent location you control. The companion script is not bundled into the MSIX application. +2. Configure the command in your Claude statusline settings using the official instructions. If you have no existing formatter, use the script's **standalone** mode; replace both example paths with your own absolute paths: + + ```text + powershell.exe -NoProfile -NonInteractive -File "C:/Tools/capture-claude-usage.ps1" -OutputPath "C:/UsageCaptures/claude-usage.json" -Standalone + ``` + + Standalone mode displays a compact remaining-quota line. To retain an existing formatter, omit `-Standalone`, launch the capture script as a separate PowerShell process, and pipe that process's stdout into your existing formatter command. Default mode forwards the original stdin bytes unchanged, including when the capture destination fails. Calling the script inside the same PowerShell process is not a supported pipeline arrangement. The extension does not edit your Claude settings or replace a statusline automatically. +3. In **Codex Usage settings**, set **Claude bridge file** to the same absolute JSON file path and turn on **Enable Claude usage pilot**. The script creates the output directory when needed. +4. Open **Claude usage pilot** to inspect capture status, observation time, and each independent window. Add its Claude Dock band through Dock customization. + +The bridge retains at most 256 KiB of input for parsing and writes only the schema, provider, UTC capture time, validated quota windows, and generic availability messages. Writes replace the snapshot atomically. Missing, malformed, or oversized input writes an unavailable snapshot; it never refreshes the timestamp on old quota values. The extension reads at most 64 KiB per capture. Stale, future-dated, missing, and expired data do not appear as available Dock quota. **Refresh captures** rereads the file; it does not make Claude emit new data. After a quiet session, wait for a new Claude statusline update. + ## History, planning, and optional account actions **Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. @@ -110,6 +133,8 @@ Freshness uses the greater of five minutes and the configured refresh interval t When Codex supplies an account identity, weekly history and learned profiles are stored separately for that account and default quota category using opaque hashed directory names. History appears only after the account is identified. Older history files have no identity and are not imported into an account. Without a verified account identity, recent observations remain in memory and adaptive learning is paused. +The local quota fallback caches read positions and the latest valid quota event in memory. Unchanged files still in its cache are not reread for content; appended, replaced, truncated, and deleted files are handled on later refreshes. Each scan reads at most 8 MiB of file content, tracks up to 512 files, and bounds partial lines to 128 KiB. It still enumerates session file metadata; these limits do not promise constant scan time for large directories. Incomplete scans are reported, and later refreshes continue discovery. No session payload or read-position cache is saved to disk by this quota fallback. + ## Development Build, test, Store packaging, and release instructions are in [DEVELOPMENT.md](DEVELOPMENT.md). diff --git a/SPRINTS.md b/SPRINTS.md index f8ad822..6fc256f 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -6,8 +6,8 @@ This series implements the recommended Codex-first roadmap, followed by a small, | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | | 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | -| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests and both architecture builds passed | -| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests; x64 tests, both builds, and package validation passed in CI | +| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | 360 native ARM64 tests and both architecture builds passed; PR preparation complete | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. @@ -34,6 +34,14 @@ The x64 and ARM64 application code compiles without warnings. Local ARM64 test e ### Sprint 3 verification -Native ARM64 test execution was available again and passed 314 tests. Both application architecture builds passed without warnings. Tests cover retention/export scope, planner assumptions and held-out observations, optional protocol support, task-request ordering, and persistent reset idempotency across ambiguous results and restarts. No real reset was redeemed. Command Palette forms, confirmations, and real-account compatibility still need live verification after an authorized installation. The GitHub workflow validates the pushed branch separately. +Native ARM64 test execution was available again and passed 314 tests. Both application architecture builds passed without warnings. Tests cover retention/export scope, planner assumptions and held-out observations, optional protocol support, task-request ordering, and persistent reset idempotency across ambiguous results and restarts. No real reset was redeemed. Command Palette forms, confirmations, and real-account compatibility still need live verification after an authorized installation. The final head `ebe7efe` passed all 314 x64 tests, both builds, and package validation in [GitHub Actions](https://github.com/TheBeems/CodexUsageDock/actions/runs/34385299160). Both integration preflights passed source/generated manifest, identity, asset, and self-contained runtime checks. The registered ARM64 Store package was healthy and discoverable, but its process and registration do not point at the new Debug builds. These expected mismatches leave live verification of the new code open; no registration or installation was changed. + +### Sprint 4 scope + +The pilot uses only an explicit local Claude capture and keeps its two quota windows and refresh state independent of Codex. The optional script preserves a formatter's input or displays a standalone quota line; setup is manual and described in README. Profiles save at most eight names and validated source paths, including Windows-accessible WSL directories, without copying credentials or launching WSL. Text views expose measured values without chart or color dependence. The quota fallback has bounded content reads and caches read positions; filesystem metadata enumeration remains proportional to the session inventory. + +Execution used separate protocol/integration and implementation agents, with Luna at max effort for the bounded implementation tasks. Review covered provider concurrency and profile changes. Tests use synthetic local data; no real Claude configuration, account authentication, or reset redemption is part of verification. + +All 360 native ARM64 tests passed, including script execution on synthetic stdin, profile persistence and invalid input, independent provider refresh, and bounded incremental session reads. Both application builds passed with zero warnings. Only the pre-existing test-name analyzer warnings remain. The two integration preflights passed source/generated manifest, COM identity, asset, output-freshness, and self-contained runtime checks. Registration and process matching failed as expected because Command Palette runs the installed ARM64 Store package rather than either new Debug build; the x64 preflight also reports the installed architecture mismatch. No package was registered or installed. Live forms, accessibility, Dock pinning, and real-provider compatibility remain unverified; GitHub validation is recorded in the sprint PR. diff --git a/scripts/capture-claude-usage.ps1 b/scripts/capture-claude-usage.ps1 new file mode 100644 index 0000000..8e1e16b --- /dev/null +++ b/scripts/capture-claude-usage.ps1 @@ -0,0 +1,313 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [switch]$Standalone +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$MaximumInputBytes = 256 * 1024 + +function Test-FullyQualifiedPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + return [IO.Path]::IsPathFullyQualified($Path) + } + catch { + # Windows PowerShell 5.1 does not expose IsPathFullyQualified. + return $Path -match '^(?:[A-Za-z]:[\\/]|\\\\)' + } +} + +function Get-JsonProperty { + param( + [AllowNull()] + [object]$Object, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($null -eq $Object) { + return $null + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + return $property.Value +} + +function Convert-RateLimitWindow { + param( + [AllowNull()] + [object]$RateLimits, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [DateTimeOffset]$CapturedAt + ) + + $sourceWindow = Get-JsonProperty -Object $RateLimits -Name $Name + if ($null -eq $sourceWindow -or $sourceWindow -is [string] -or $sourceWindow -is [System.Array]) { + return $null + } + + $used = Get-JsonProperty -Object $sourceWindow -Name "used_percentage" + if ($null -eq $used -or $used -is [string] -or $used -is [char] -or $used -is [bool]) { + return $null + } + + try { + $usedNumber = [double]$used + } + catch { + return $null + } + + if ([double]::IsNaN($usedNumber) -or [double]::IsInfinity($usedNumber) -or + $usedNumber -lt 0 -or $usedNumber -gt 100) { + return $null + } + + $reset = Get-JsonProperty -Object $sourceWindow -Name "resets_at" + if ($null -eq $reset -or $reset -is [string] -or $reset -is [char] -or $reset -is [bool]) { + return $null + } + + try { + $resetNumber = [double]$reset + if ([double]::IsNaN($resetNumber) -or [double]::IsInfinity($resetNumber) -or + $resetNumber -ne [Math]::Truncate($resetNumber)) { + return $null + } + + $resetSeconds = [long]$resetNumber + $resetAt = [DateTimeOffset]::FromUnixTimeSeconds($resetSeconds) + } + catch { + return $null + } + + if ($resetAt -le $CapturedAt) { + return $null + } + + return [pscustomobject][ordered]@{ + used_percentage = $usedNumber + resets_at = $resetSeconds + } +} + +function Read-StandardInputAndPassThrough { + param( + [switch]$SuppressOutput + ) + + $inputStream = [Console]::OpenStandardInput() + $outputStream = [Console]::OpenStandardOutput() + $retained = [IO.MemoryStream]::new() + $buffer = New-Object byte[] 8192 + $oversized = $false + + try { + while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + # Keep the existing statusline contract unless standalone output was requested. + if (-not $SuppressOutput) { + $outputStream.Write($buffer, 0, $count) + } + + if (-not $oversized) { + $remaining = $MaximumInputBytes - [int]$retained.Length + if ($count -le $remaining) { + $retained.Write($buffer, 0, $count) + } + else { + if ($remaining -gt 0) { + $retained.Write($buffer, 0, $remaining) + } + + $oversized = $true + } + } + } + + if (-not $SuppressOutput) { + $outputStream.Flush() + } + return [pscustomobject]@{ + Bytes = $retained.ToArray() + Oversized = $oversized + } + } + finally { + $retained.Dispose() + $inputStream.Dispose() + } +} + +function Write-AtomicSnapshot { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Content + ) + + $directory = [IO.Path]::GetDirectoryName($Path) + if ([string]::IsNullOrWhiteSpace($directory)) { + throw [ArgumentException]::new("The output path has no directory.") + } + + $directory = [IO.Path]::GetFullPath($directory) + [IO.Directory]::CreateDirectory($directory) | Out-Null + $leaf = [IO.Path]::GetFileName($Path) + if ([string]::IsNullOrWhiteSpace($leaf)) { + throw [ArgumentException]::new("The output path has no file name.") + } + + $temporaryLeaf = "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".tmp" + $temporaryPath = [IO.Path]::Combine($directory, $temporaryLeaf) + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content) + $stream = $null + + try { + $stream = [IO.File]::Open( + $temporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + $stream.Dispose() + $stream = $null + + if ([IO.File]::Exists($Path)) { + $backupPath = [IO.Path]::Combine( + $directory, + "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".bak") + try { + [IO.File]::Replace($temporaryPath, $Path, $backupPath, $true) + } + finally { + if ([IO.File]::Exists($backupPath)) { + [IO.File]::Delete($backupPath) + } + } + } + else { + [IO.File]::Move($temporaryPath, $Path) + } + } + finally { + if ($null -ne $stream) { + $stream.Dispose() + } + + if ([IO.File]::Exists($temporaryPath)) { + [IO.File]::Delete($temporaryPath) + } + } +} + +try { + $captured = Read-StandardInputAndPassThrough -SuppressOutput:$Standalone + + if ([string]::IsNullOrWhiteSpace($OutputPath) -or -not (Test-FullyQualifiedPath -Path $OutputPath)) { + throw [ArgumentException]::new("The output path must be fully qualified.") + } + + $capturedAt = [DateTimeOffset]::UtcNow + $primary = $null + $weekly = $null + $parseSucceeded = -not $captured.Oversized + + if ($parseSucceeded) { + try { + $json = [Text.UTF8Encoding]::new($false, $true).GetString($captured.Bytes) + $source = $json | ConvertFrom-Json + $rateLimits = Get-JsonProperty -Object $source -Name "rate_limits" + $primary = Convert-RateLimitWindow -RateLimits $rateLimits -Name "five_hour" -CapturedAt $capturedAt + $weekly = Convert-RateLimitWindow -RateLimits $rateLimits -Name "seven_day" -CapturedAt $capturedAt + } + catch { + $parseSucceeded = $false + $primary = $null + $weekly = $null + } + } + + $validWindows = @($primary, $weekly) | Where-Object { $null -ne $_ } + $validCount = @($validWindows).Count + $status = if (-not $parseSucceeded -or $validCount -eq 0) { + "unavailable" + } + elseif ($validCount -eq 2) { + "available" + } + else { + "partial" + } + $message = if ($validCount -eq 2) { + "Claude rate-limit windows are available." + } + elseif ($validCount -eq 1) { + "One Claude rate-limit window is unavailable; windows remain independent." + } + else { + "No valid Claude rate-limit windows were provided." + } + + $snapshot = [ordered]@{ + schemaVersion = 1 + provider = "claude" + observedAtUTC = $capturedAt.ToString("O", [Globalization.CultureInfo]::InvariantCulture) + rate_limits = [ordered]@{ + five_hour = $primary + seven_day = $weekly + } + status = $status + message = $message + } + $snapshotJson = $snapshot | ConvertTo-Json -Depth 8 -Compress + Write-AtomicSnapshot -Path $OutputPath -Content $snapshotJson + + if ($Standalone) { + $primaryRemaining = if ($null -eq $primary) { + "--" + } + else { + ([double](100 - [double](Get-JsonProperty -Object $primary -Name "used_percentage"))).ToString( + "0.##", + [Globalization.CultureInfo]::InvariantCulture) + "%" + } + $weeklyRemaining = if ($null -eq $weekly) { + "--" + } + else { + ([double](100 - [double](Get-JsonProperty -Object $weekly -Name "used_percentage"))).ToString( + "0.##", + [Globalization.CultureInfo]::InvariantCulture) + "%" + } + + [Console]::WriteLine("Claude 5h $primaryRemaining / week $weeklyRemaining") + } + + exit 0 +} +catch { + [Console]::Error.WriteLine("Claude usage capture could not write the snapshot.") + exit 1 +} From e07db18d37b14313959190d4928ba3228599f2dd Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:25:27 +0200 Subject: [PATCH 09/21] Link sprint 4 to its review and verification record --- CHANGELOG.md | 8 ++++---- SPRINTS.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b63ab4..7b771e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- An opt-in Claude pilot with an independent Dock band and a local statusline capture script that preserves an existing formatter or supplies a standalone quota line. -- Named local/Windows-accessible WSL source profiles and a text alternative for quota, reset, trend, and local token data. +- An opt-in Claude pilot with an independent Dock band and a local statusline capture script that preserves an existing formatter or supplies a standalone quota line. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- Named local/Windows-accessible WSL source profiles and a text alternative for quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) @@ -23,13 +23,13 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed -- Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. +- Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Changed -- Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. +- Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) - Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/SPRINTS.md b/SPRINTS.md index 6fc256f..230c8fe 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -7,7 +7,7 @@ This series implements the recommended Codex-first roadmap, followed by a small, | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | | 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | | 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests; x64 tests, both builds, and package validation passed in CI | -| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | 360 native ARM64 tests and both architecture builds passed; PR preparation complete | +| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | [PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21); 360 native ARM64 tests and both architecture builds passed; final GitHub validation recorded in the PR | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. From 3c50c02edaf2a272fa88dd78fec77c9b4a784aa8 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:51:27 +0200 Subject: [PATCH 10/21] fix: preserve Dock bands and respect the active display mode --- CodexUsageDock.Tests/ProviderDockTests.cs | 438 +++++++++++++++++- CodexUsageDock.Tests/UsageDataTests.cs | 12 +- .../CodexUsageDockCommandsProvider.cs | 83 ++-- .../Pages/CodexUsageDockSettingsPage.cs | 2 +- CodexUsageDock/UsageDockBand.cs | 48 ++ CodexUsageDock/UsageDockItem.cs | 2 +- CodexUsageDock/UsageDockListItem.cs | 16 + README.md | 2 +- 8 files changed, 556 insertions(+), 47 deletions(-) create mode 100644 CodexUsageDock/UsageDockBand.cs create mode 100644 CodexUsageDock/UsageDockListItem.cs diff --git a/CodexUsageDock.Tests/ProviderDockTests.cs b/CodexUsageDock.Tests/ProviderDockTests.cs index 6fdea69..f25e50b 100644 --- a/CodexUsageDock.Tests/ProviderDockTests.cs +++ b/CodexUsageDock.Tests/ProviderDockTests.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.Json; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; using Xunit; @@ -50,11 +52,441 @@ public void SeparateDockBandsHaveStableRestorableIdentities() foreach (var band in bands) { Assert.Single(Assert.IsAssignableFrom(band.Command).GetItems()); - Assert.Equal(band.Command.Id, provider.GetCommandItem(band.Command.Id)!.Command.Id); + Assert.Same(band, provider.GetCommandItem(band.Command.Id)); } Assert.Null(provider.GetCommandItem("unknown")); Assert.Null(provider.GetCommandItem(string.Empty)); - var combined = provider.GetCommandItem("nl.mathijs.codexusage.dock"); - Assert.Equal(3, Assert.IsAssignableFrom(combined!.Command).GetItems().Length); + Assert.Null(provider.GetCommandItem(CombinedDockId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DockModeTransitionsKeepStableBandObjectsAndPersist(bool initiallySeparate) + { + File.WriteAllText( + _environment.PathFor("settings.json"), + JsonSerializer.Serialize(new Dictionary + { + [SeparateDockItemsKey] = initiallySeparate.ToString().ToLowerInvariant(), + })); + + var initialId = initiallySeparate ? FiveHourDockId : CombinedDockId; + var settings = _environment.CreateSettings(); + using (var service = _environment.CreateService()) + using (var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { })) + { + var initialBand = FindBand(provider, initialId); + Assert.Same(initialBand, provider.GetCommandItem(initialId)); + + SubmitSettings(settings, + (SeparateDockItemsKey, (!initiallySeparate).ToString().ToLowerInvariant())); + Assert.Equal(initiallySeparate ? 1 : 3, provider.GetDockBands()!.Length); + + SubmitSettings(settings, + (SeparateDockItemsKey, initiallySeparate.ToString().ToLowerInvariant())); + Assert.Same(initialBand, FindBand(provider, initialId)); + Assert.Same(initialBand, provider.GetCommandItem(initialId)); + } + + using var restartedService = _environment.CreateService(); + using var restartedProvider = new CodexUsageDockCommandsProvider( + restartedService, + _environment.CreateSettings(), + _ => { }); + Assert.Equal(initiallySeparate ? 3 : 1, restartedProvider.GetDockBands()!.Length); + Assert.Same( + FindBand(restartedProvider, initialId), + restartedProvider.GetCommandItem(initialId)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DockModeSwitchPersistsTheOppositeModeAcrossRestart(bool initiallySeparate) + { + File.WriteAllText( + _environment.PathFor("settings.json"), + JsonSerializer.Serialize(new Dictionary + { + [SeparateDockItemsKey] = initiallySeparate.ToString().ToLowerInvariant(), + })); + + var settings = _environment.CreateSettings(); + var oldIds = initiallySeparate + ? new[] { FiveHourDockId, WeeklyDockId, CreditsDockId } + : new[] { CombinedDockId }; + var newIds = initiallySeparate + ? new[] { CombinedDockId } + : new[] { FiveHourDockId, WeeklyDockId, CreditsDockId }; + using (var service = _environment.CreateService()) + using (var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { })) + { + SubmitSettings(settings, (SeparateDockItemsKey, (!initiallySeparate).ToString().ToLowerInvariant())); + foreach (var id in oldIds) + { + Assert.Null(provider.GetCommandItem(id)); + } + } + + using var restartedService = _environment.CreateService(); + using var restartedProvider = new CodexUsageDockCommandsProvider( + restartedService, + _environment.CreateSettings(), + _ => { }); + Assert.Equal(newIds, restartedProvider.GetDockBands()!.Select(band => band.Command.Id)); + foreach (var id in oldIds) + { + Assert.Null(restartedProvider.GetCommandItem(id)); + } + foreach (var id in newIds) + { + Assert.Same(FindBand(restartedProvider, id), restartedProvider.GetCommandItem(id)); + } + } + + [Fact] + public void RestorableLookupUsesOnlyTheActiveDockSelection() + { + File.WriteAllText(_environment.PathFor("settings.json"), $"{{\"{SeparateDockItemsKey}\":\"true\"}}"); + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + foreach (var band in provider.GetDockBands()!) + { + Assert.Same(band, provider.GetCommandItem(band.Command.Id)); + } + + Assert.Null(provider.GetCommandItem(CombinedDockId)); + SubmitSettings(settings, (SeparateDockItemsKey, "false")); + + var combined = FindBand(provider, CombinedDockId); + Assert.Same(combined, provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Null(provider.GetCommandItem(WeeklyDockId)); + Assert.Null(provider.GetCommandItem(CreditsDockId)); + } + + [Fact] + public void HiddenAndDisabledDockIdsAreNotRestorableInBothModes() + { + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + Assert.NotNull(provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + + SubmitSettings(settings, (ShowFiveHourLimitKey, "false")); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Equal(2, Assert.IsAssignableFrom(FindBand(provider, CombinedDockId).Command).GetItems().Length); + + SubmitSettings(settings, + (ShowWeeklyLimitKey, "false"), + (ShowResetsAndCreditsKey, "false")); + Assert.Empty(provider.GetDockBands()!); + foreach (var id in AllDockIds) + { + Assert.Null(provider.GetCommandItem(id)); + } + + SubmitSettings(settings, (SeparateDockItemsKey, "true"), (ShowWeeklyLimitKey, "true")); + Assert.Single(provider.GetDockBands()!); + Assert.Null(provider.GetCommandItem(CombinedDockId)); + Assert.Null(provider.GetCommandItem(FiveHourDockId)); + Assert.Same( + FindBand(provider, WeeklyDockId), + provider.GetCommandItem(WeeklyDockId)); + Assert.Null(provider.GetCommandItem(CreditsDockId)); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + Assert.Null(provider.GetCommandItem("testhost-owned-pin")); + + SubmitSettings(settings, (ShowWeeklyLimitKey, "false")); + Assert.Empty(provider.GetDockBands()!); + Assert.Null(provider.GetCommandItem(WeeklyDockId)); + } + + [Fact] + public void RetainedBandPagesAreClearedWhenTheirBandBecomesInactive() + { + using var service = _environment.CreateService(); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }); + + var combinedBand = FindBand(provider, CombinedDockId); + var combinedPage = Assert.IsAssignableFrom(combinedBand.Command); + Assert.Equal(3, combinedPage.GetItems().Length); + var combinedEmptyNotifications = 0; + combinedPage.ItemsChanged += (_, _) => + { + if (combinedPage.GetItems().Length == 0) + { + combinedEmptyNotifications++; + } + }; + + SubmitSettings(settings, + (ShowFiveHourLimitKey, "false"), + (ShowWeeklyLimitKey, "false"), + (ShowResetsAndCreditsKey, "false")); + Assert.Empty(combinedPage.GetItems()); + Assert.True(combinedEmptyNotifications > 0); + Assert.Empty(provider.GetDockBands()!); + + SubmitSettings(settings, (ShowWeeklyLimitKey, "true")); + Assert.Same(combinedBand, FindBand(provider, CombinedDockId)); + Assert.Single(combinedPage.GetItems()); + + var emptyNotificationsBeforeSeparate = combinedEmptyNotifications; + SubmitSettings(settings, (SeparateDockItemsKey, "true")); + Assert.Empty(combinedPage.GetItems()); + Assert.True(combinedEmptyNotifications > emptyNotificationsBeforeSeparate); + var weeklyBand = FindBand(provider, WeeklyDockId); + var weeklyPage = Assert.IsAssignableFrom(weeklyBand.Command); + Assert.Single(weeklyPage.GetItems()); + var weeklyEmptyNotifications = 0; + weeklyPage.ItemsChanged += (_, _) => + { + if (weeklyPage.GetItems().Length == 0) + { + weeklyEmptyNotifications++; + } + }; + + SubmitSettings(settings, (ShowWeeklyLimitKey, "false")); + Assert.Empty(weeklyPage.GetItems()); + Assert.True(weeklyEmptyNotifications > 0); + Assert.Empty(provider.GetDockBands()!); + } + + [Fact] + public void RetainedClaudeBandPageClearsAndNotifiesWhenClaudeIsDisabled() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var capture = WriteClaudeCapture(now, 25); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + [EnableClaudeKey] = "true", + [ClaudeBridgePathKey] = capture, + })); + using var service = _environment.CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), + () => CodexUsageSnapshot.Loading, + clock: () => now); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now); + + var band = FindBand(provider, ClaudeDockId); + var page = Assert.IsAssignableFrom(band.Command); + Assert.Equal(2, page.GetItems().Length); + var emptyNotifications = 0; + page.ItemsChanged += (_, _) => + { + if (page.GetItems().Length == 0) + { + emptyNotifications++; + } + }; + + SubmitSettings(settings, (EnableClaudeKey, "false")); + + Assert.Empty(page.GetItems()); + Assert.True(emptyNotifications > 0); + Assert.Null(provider.GetCommandItem(ClaudeDockId)); + } + + [Fact] + public async Task QuotaRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, now.AddHours(4)), + Secondary = new RateLimitWindow(40, 10080, now.AddDays(5)), + UpdatedAt = now, + Source = UsageDataSource.AppServer, + Error = null, + AccountKey = "test-account", + DefaultBucketId = "codex", + }; + using var service = _environment.CreateService( + _ => pending.Task, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + var band = FindBand(provider, CombinedDockId); + var list = Assert.IsAssignableFrom(band.Command); + var providerInvalidations = 0; + var bandInvalidations = 0; + provider.ItemsChanged += (_, _) => providerInvalidations++; + list.ItemsChanged += (_, _) => bandInvalidations++; + + var refresh = service.RefreshAsync(); + pending.SetResult(snapshot); + await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(band, FindBand(provider, CombinedDockId)); + Assert.Same(band, provider.GetCommandItem(CombinedDockId)); + Assert.Equal(0, providerInvalidations); + Assert.True(bandInvalidations > 0); + } + + [Fact] + public async Task RepeatedCompletedQuotaRefreshNotifiesAPreviouslyReadItem() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var firstRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var secondRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var readNumber = 0; + var snapshot = CodexUsageSnapshot.Loading with + { + Primary = new RateLimitWindow(25, 300, now.AddHours(4)), + Secondary = new RateLimitWindow(40, 10080, now.AddDays(5)), + UpdatedAt = now, + Source = UsageDataSource.AppServer, + Error = null, + AccountKey = "test-account", + DefaultBucketId = "codex", + ResetCredits = new RateLimitResetCredits(2, null), + }; + Task Read(CancellationToken _) => + Interlocked.Increment(ref readNumber) == 1 ? firstRead.Task : secondRead.Task; + using var service = _environment.CreateService( + Read, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + + var band = FindBand(provider, CombinedDockId); + // Reset-credit text is independent of the real-time window validity clock. + var item = Assert.IsAssignableFrom(band.Command).GetItems()[2]; + var cachedTitle = item.Title; + var firstRefresh = service.RefreshAsync(); + firstRead.SetResult(snapshot); + await firstRefresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.NotEqual(item.Title, cachedTitle); + item.PropChanged += (_, args) => + { + if (args.PropertyName == nameof(ICommandItem.Title)) cachedTitle = item.Title; + }; + + var secondRefresh = service.RefreshAsync(); + secondRead.SetResult(snapshot); + await secondRefresh.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(item.Title, cachedTitle); + } + + [Fact] + public async Task ClaudeRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var firstCapture = WriteClaudeCapture(now, 25); + var secondCapture = WriteClaudeCapture(now, 35); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( + new Dictionary + { + [EnableClaudeKey] = "true", + [ClaudeBridgePathKey] = firstCapture, + })); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var service = _environment.CreateService( + _ => pending.Task, + () => CodexUsageSnapshot.Loading, + clock: () => now); + using var provider = new CodexUsageDockCommandsProvider( + service, + _environment.CreateSettings(), + _ => { }, + () => now); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + + var codexBand = FindBand(provider, CombinedDockId); + var claudeBand = FindBand(provider, ClaudeDockId); + var claudeList = Assert.IsAssignableFrom(claudeBand.Command); + var providerInvalidations = 0; + var claudeInvalidations = 0; + provider.ItemsChanged += (_, _) => providerInvalidations++; + claudeList.ItemsChanged += (_, _) => claudeInvalidations++; + + service.ConfigureClaude(false, firstCapture); + service.ConfigureClaude(true, secondCapture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Same(codexBand, FindBand(provider, CombinedDockId)); + Assert.Same(claudeBand, FindBand(provider, ClaudeDockId)); + Assert.Same(claudeBand, provider.GetCommandItem(ClaudeDockId)); + Assert.Equal(0, providerInvalidations); + Assert.True(claudeInvalidations > 0); + + pending.TrySetResult(CodexUsageSnapshot.Loading); + } + + private const string CombinedDockId = "nl.mathijs.codexusage.dock"; + private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; + private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; + private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; + private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; + private const string SeparateDockItemsKey = "separateDockItems"; + private const string ShowFiveHourLimitKey = "showFiveHourLimit"; + private const string ShowWeeklyLimitKey = "showWeeklyLimit"; + private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; + private const string EnableClaudeKey = "enableClaude"; + private const string ClaudeBridgePathKey = "claudeBridgePath"; + private static readonly string[] AllDockIds = [ + CombinedDockId, + FiveHourDockId, + WeeklyDockId, + CreditsDockId, + ClaudeDockId, + ]; + + private static ICommandItem FindBand(CodexUsageDockCommandsProvider provider, string id) => + Assert.Single(provider.GetDockBands() ?? Array.Empty(), item => item.Command.Id == id); + + private static void SubmitSettings( + CodexUsageDockSettingsPage page, + params (string Key, string Value)[] values) + { + var payload = values.ToDictionary(pair => pair.Key, pair => pair.Value); + page.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(payload), "{}"); + } + + private string WriteClaudeCapture(DateTimeOffset now, int fiveHourUsed) + { + var path = _environment.PathFor($"claude-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = 1, + provider = "claude", + observedAtUTC = now.ToString("O", CultureInfo.InvariantCulture), + rate_limits = new + { + five_hour = new + { + used_percentage = fiveHourUsed, + resets_at = now.AddHours(4).ToUnixTimeSeconds(), + }, + seven_day = new + { + used_percentage = 40, + resets_at = now.AddDays(4).ToUnixTimeSeconds(), + }, + }, + })); + return path; } } diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 66598a2..6f9bb60 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -445,7 +445,7 @@ public async Task DetailsPageRefreshUpdatesMainContentAndDetailsPane() } [Fact] - public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() + public async Task CompletedRefreshUpdatesExistingDockBandWithoutReloadingProvider() { var now = DateTimeOffset.Now; var result = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -457,6 +457,10 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() { var invalidationCount = 0; provider.ItemsChanged += (_, _) => invalidationCount++; + var band = Assert.Single(provider.GetDockBands()!); + var list = Assert.IsAssignableFrom(band.Command); + var bandInvalidations = 0; + list.ItemsChanged += (_, _) => bandInvalidations++; var refresh = service.RefreshAsync(); result.SetResult(CodexUsageSnapshot.Loading with @@ -468,9 +472,9 @@ public async Task CompletedRefreshRebuildsAndInvalidatesDockBands() }); await refresh.WaitAsync(AsyncTestTimeout); - Assert.Equal(1, invalidationCount); - var band = Assert.Single(provider.GetDockBands()!); - var list = Assert.IsAssignableFrom(band.Command); + Assert.Equal(0, invalidationCount); + Assert.True(bandInvalidations > 0); + Assert.Same(band, Assert.Single(provider.GetDockBands()!)); Assert.Contains(list.GetItems(), item => item.Title == "5h 75%"); } finally diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 2688702..651c30e 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -20,8 +20,8 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexUsageTablePage _textUsage; private readonly CodexProfilesPage _profiles; private readonly ClaudeUsagePage _claude; - private readonly ListItem _claudeFiveHour; - private readonly ListItem _claudeWeekly; + private readonly UsageDockListItem _claudeFiveHour; + private readonly UsageDockListItem _claudeWeekly; private readonly object _claudePresentationLock = new(); private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; @@ -31,6 +31,13 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; + private readonly object _dockLayoutLock = new(); + private readonly UsageDockBand _combinedBand; + private readonly UsageDockBand _fiveHourBand; + private readonly UsageDockBand _weeklyBand; + private readonly UsageDockBand _creditsBand; + private readonly UsageDockBand _claudeBand; + private readonly UsageDockBand[] _allDockBands; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -68,12 +75,18 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath)); _profiles.ProfileSelected += OnProfileSelected; _claude = new ClaudeUsagePage(_usage); - _claudeFiveHour = new ListItem(_claude); - _claudeWeekly = new ListItem(_claude); + _claudeFiveHour = new UsageDockListItem(_claude); + _claudeWeekly = new UsageDockListItem(_claude); _details.Commands = [.. _details.Commands, new CommandContextItem(_textUsage) { Title = "Read usage in text" }]; _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); + _combinedBand = new("nl.mathijs.codexusage.dock", DisplayName); + _fiveHourBand = new(FiveHourDockId, "Codex five-hour usage"); + _weeklyBand = new(WeeklyDockId, "Codex weekly usage"); + _creditsBand = new(CreditsDockId, "Codex resets and credits"); + _claudeBand = new(ClaudeDockId, "Claude usage"); + _allDockBands = [_combinedBand, _fiveHourBand, _weeklyBand, _creditsBand, _claudeBand]; _commands = [ @@ -111,29 +124,19 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.Updated += OnUsageUpdated; _usage.ClaudeUpdated += OnClaudeUpdated; RefreshClaudeItems(); - RebuildDockBands(); + UpdateDockLayout(); _usage.Start(); } public override ICommandItem[] TopLevelCommands() => _commands; - public override ICommandItem[]? GetDockBands() => _dockBands; + public override ICommandItem[]? GetDockBands() => [.. Volatile.Read(ref _dockBands)]; public override ICommandItem? GetCommandItem(string id) { if (string.IsNullOrWhiteSpace(id)) return null; - var known = _commands.Concat(_dockBands).FirstOrDefault(item => item.Command.Id == id); - if (known is not null) return known; - return id switch - { - "nl.mathijs.codexusage.dock" => new WrappedDockItem(GetVisibleDockItems(), "nl.mathijs.codexusage.dock", DisplayName), - FiveHourDockId => new WrappedDockItem([_fiveHour], FiveHourDockId, "Codex five-hour usage"), - WeeklyDockId => new WrappedDockItem([_weekly], WeeklyDockId, "Codex weekly usage"), - CreditsDockId => new WrappedDockItem([_resetsAndCredits], CreditsDockId, "Codex resets and credits"), - ClaudeDockId => new WrappedDockItem(_settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : [], ClaudeDockId, "Claude usage"), - _ => null, - }; + return _commands.Concat(Volatile.Read(ref _dockBands)).FirstOrDefault(item => item.Command.Id == id); } private void OnSettingsChanged(object? sender, EventArgs e) @@ -154,8 +157,7 @@ private void OnSettingsChanged(object? sender, EventArgs e) _details.Refresh(); _planner.Refresh(); _history.Refresh(); - RebuildDockBands(); - RaiseItemsChanged(); + UpdateDockLayout(); if (sourceChanged) _ = _usage.RefreshAsync(); } @@ -171,8 +173,7 @@ private bool ApplySourceSettings() private void OnClaudeUpdated(object? sender, EventArgs args) { RefreshClaudeItems(); - RebuildDockBands(); - RaiseItemsChanged(); + if (_claudeBand.HasItems) _claudeBand.NotifyItemsChanged(); } private void RefreshClaudeItems() @@ -212,8 +213,10 @@ private void OnUsageUpdated(object? sender, EventArgs e) return; } - RebuildDockBands(); - RaiseItemsChanged(); + foreach (var band in Volatile.Read(ref _dockBands).OfType()) + { + if (!ReferenceEquals(band, _claudeBand)) band.NotifyItemsChanged(); + } var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval, new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts)); if (alerts.Count > 0) @@ -224,25 +227,31 @@ private void OnUsageUpdated(object? sender, EventArgs e) } } - private void RebuildDockBands() + private void UpdateDockLayout() { - var items = GetVisibleDockItems(); - ICommandItem[] bands; - if (_settings.SeparateDockItems) + var changedBands = new List(); + bool catalogChanged; + lock (_dockLayoutLock) { - bands = items.Select(item => new WrappedDockItem([item], - ReferenceEquals(item, _fiveHour) ? FiveHourDockId : ReferenceEquals(item, _weekly) ? WeeklyDockId : CreditsDockId, - ReferenceEquals(item, _fiveHour) ? "Codex five-hour usage" : ReferenceEquals(item, _weekly) ? "Codex weekly usage" : "Codex resets and credits")) - .Cast().ToArray(); + var separate = _settings.SeparateDockItems; + Publish(_combinedBand, separate ? [] : GetVisibleDockItems()); + Publish(_fiveHourBand, separate && _settings.ShowFiveHourLimit ? [_fiveHour] : []); + Publish(_weeklyBand, separate && _settings.ShowWeeklyLimit ? [_weekly] : []); + Publish(_creditsBand, separate && _settings.ShowResetsAndCredits ? [_resetsAndCredits] : []); + Publish(_claudeBand, _settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : []); + ICommandItem[] bands = _allDockBands.Where(band => band.HasItems).ToArray(); + catalogChanged = !Volatile.Read(ref _dockBands).SequenceEqual(bands); + Volatile.Write(ref _dockBands, bands); } - else + + // No host callback may run while the layout lock is held. + foreach (var band in changedBands) band.NotifyItemsChanged(); + if (catalogChanged) RaiseItemsChanged(); + + void Publish(UsageDockBand band, IListItem[] items) { - var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); - bands = dockBand is null ? [] : [dockBand]; + if (band.PublishItems(items)) changedBands.Add(band); } - if (_settings.EnableClaude) - bands = [.. bands, new WrappedDockItem([_claudeFiveHour, _claudeWeekly], ClaudeDockId, "Claude usage")]; - _dockBands = bands; } private IListItem[] GetVisibleDockItems() diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 6ef4aae..5c84922 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -87,7 +87,7 @@ internal CodexUsageDockSettingsPage(string path) _settings.Add(new ToggleSetting(SeparateDockItemsKey, false) { Label = "Separate Dock items", - Description = "Show each usage item as its own Dock entry.", + Description = "Offer separate metric bands instead of the combined band. Other-mode pins are hidden. After switching, add the desired bands through Dock customization if needed.", }); _settings.Add(new ToggleSetting(ShowAccountActivityKey, true) { diff --git a/CodexUsageDock/UsageDockBand.cs b/CodexUsageDock/UsageDockBand.cs new file mode 100644 index 0000000..867d57d --- /dev/null +++ b/CodexUsageDock/UsageDockBand.cs @@ -0,0 +1,48 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class UsageDockBand : CommandItem +{ + private readonly DockBandPage _page; + + internal UsageDockBand(string id, string title) : this(new DockBandPage(id, title)) { } + + private UsageDockBand(DockBandPage page) : base(page) => _page = page; + + internal bool HasItems => _page.HasItems; + internal bool PublishItems(IListItem[] items) => _page.PublishItems(items); + internal void NotifyItemsChanged() => _page.NotifyItemsChanged(); + + private sealed partial class DockBandPage : ListPage + { + private IListItem[] _items = []; + + internal DockBandPage(string id, string title) + { + Id = id; + Name = title; + Title = title; + } + + internal bool HasItems => Volatile.Read(ref _items).Length > 0; + public override IListItem[] GetItems() => [.. Volatile.Read(ref _items)]; + + // The host synchronously calls GetItems from ItemsChanged. Publish the + // complete layout before notifying, including newly inactive bands. + internal bool PublishItems(IListItem[] items) + { + if (Volatile.Read(ref _items).SequenceEqual(items)) return false; + Volatile.Write(ref _items, items); + return true; + } + + internal void NotifyItemsChanged() + { + var items = Volatile.Read(ref _items); + foreach (var item in items.OfType()) item.NotifyDisplayPropertiesChanged(); + RaiseItemsChanged(Volatile.Read(ref _items).Length); + } + } +} diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 9a03271..46c3576 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -10,7 +10,7 @@ internal enum UsageDockItemKind ResetsAndCredits, } -internal sealed partial class UsageDockItem : ListItem, IDisposable +internal sealed partial class UsageDockItem : UsageDockListItem, IDisposable { private readonly CodexUsageService _usage; private readonly UsageDockItemKind _kind; diff --git a/CodexUsageDock/UsageDockListItem.cs b/CodexUsageDock/UsageDockListItem.cs new file mode 100644 index 0000000..9771c52 --- /dev/null +++ b/CodexUsageDock/UsageDockListItem.cs @@ -0,0 +1,16 @@ +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal partial class UsageDockListItem(ICommand command) : ListItem(command) +{ + internal void NotifyDisplayPropertiesChanged() + { + // A host can subscribe after reading the initial values and miss an + // intervening update. Refresh even unchanged values on the next read. + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(Subtitle)); + OnPropertyChanged(nameof(Icon)); + } +} diff --git a/README.md b/README.md index d9c95d1..e276ed3 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10. ## Customize the Dock -**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each metric as a separate pinnable band; existing combined-band and individual pin identifiers remain resolvable after changing modes. +**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each visible metric as a separate pinnable band. Turning it off offers the combined band. Pins belonging to the inactive mode, hidden metrics, and the disabled Claude pilot stop displaying items and are not restored as active bands after a reload. Command Palette keeps its saved pins: switching modes does not move or convert them. Add the desired bands through Dock customization if they were not already pinned; switching back makes matching saved pins available again. **Enable usage alerts** is off by default. When enabled, fresh, identified account data can notify on a downward crossing of 10% remaining, a new projected limit within one hour, or a reset credit entering its last 24 hours. The first measurement establishes a baseline. Duplicate refreshes do not repeat alerts, small reset-time fluctuations stay in the same cycle, and account/category changes start a new baseline. Multiple simultaneous alerts are combined into one host notification. Delivery depends on the Command Palette host. From 36363df7e8051d279d6a1288fbb980ae95aa0b07 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:52:24 +0200 Subject: [PATCH 11/21] docs: record Dock mode fix and regression coverage --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b771e3..51e76d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22)) - Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) From 188b267f5ee731eb9dd8e56c9891be542ada2bb4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:10:55 +0200 Subject: [PATCH 12/21] feat: show reset and expiry dates in Dock subtitles --- CodexUsageDock.Tests/DockDateSubtitleTests.cs | 48 ++++++++++++++++ CodexUsageDock.Tests/UsageDataTests.cs | 10 ++-- CodexUsageDock/UsageDockItem.cs | 56 +++++++++++++------ README.md | 2 +- 4 files changed, 93 insertions(+), 23 deletions(-) create mode 100644 CodexUsageDock.Tests/DockDateSubtitleTests.cs diff --git a/CodexUsageDock.Tests/DockDateSubtitleTests.cs b/CodexUsageDock.Tests/DockDateSubtitleTests.cs new file mode 100644 index 0000000..00645b6 --- /dev/null +++ b/CodexUsageDock.Tests/DockDateSubtitleTests.cs @@ -0,0 +1,48 @@ +using System.Globalization; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class DockDateSubtitleTests +{ + [Theory] + [InlineData(9, 15, 12, 56, "15 sept 12:56")] + [InlineData(10, 4, 4, 0, "4 okt 4:00")] + [InlineData(1, 1, 0, 5, "1 jan 0:05")] + public void DutchDatesUseAbbreviatedMonthsAndUnpaddedHours(int month, int day, int hour, int minute, string expected) + { + var local = new DateTimeOffset(2026, month, day, hour, minute, 0, TimeSpan.FromHours(2)); + Assert.Equal(expected, UsageDockItem.FormatLocalDateTime(local, CultureInfo.GetCultureInfo("nl-NL"))); + } + + [Theory] + [InlineData("Reset - 15 sept 12:56")] + [InlineData("Expires - 4 okt 4:00")] + public void FreshLiveSubtitleShowsOnlyTheDateDetail(string detail) + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var snapshot = CodexUsageSnapshot.Loading with { Source = UsageDataSource.AppServer, UpdatedAt = now.AddMinutes(-10) }; + Assert.Equal(detail, UsageDockItem.FormatLiveDetailOrStatus(snapshot, now, TimeSpan.FromMinutes(15), detail)); + } + + [Fact] + public void StaleAndFallbackSubtitlesRetainTheirWarning() + { + var now = DateTimeOffset.UnixEpoch.AddDays(1); + var stale = CodexUsageSnapshot.Loading with { Source = UsageDataSource.AppServer, UpdatedAt = now.AddHours(-1) }; + Assert.StartsWith("Stale", UsageDockItem.FormatLiveDetailOrStatus(stale, now, TimeSpan.FromMinutes(1), "Reset - 2 jan 4:00")); + var fallback = stale with { Source = UsageDataSource.LastConfirmed }; + Assert.StartsWith("Last confirmed", UsageDockItem.FormatLiveDetailOrStatus(fallback, now, TimeSpan.FromMinutes(1), "Expires - 2 jan 4:00")); + var local = stale with { Source = UsageDataSource.LocalSession, UpdatedAt = now }; + Assert.StartsWith("Fallback", UsageDockItem.FormatLiveDetailOrStatus(local, now, TimeSpan.FromMinutes(1), "Reset - 2 jan 4:00")); + } + + [Fact] + public void HiddenDateRetainsExistingStatusText() + { + var now = DateTimeOffset.UnixEpoch; + var snapshot = CodexUsageSnapshot.Loading with { Source = UsageDataSource.AppServer, UpdatedAt = now }; + Assert.Equal(UsageDockItem.FormatSourceFreshness(snapshot, now), + UsageDockItem.FormatLiveDetailOrStatus(snapshot, now, TimeSpan.FromMinutes(1), string.Empty)); + } +} diff --git a/CodexUsageDock.Tests/UsageDataTests.cs b/CodexUsageDock.Tests/UsageDataTests.cs index 6f9bb60..26bef81 100644 --- a/CodexUsageDock.Tests/UsageDataTests.cs +++ b/CodexUsageDock.Tests/UsageDataTests.cs @@ -1370,7 +1370,7 @@ public void UnavailableDockItemsUseConsistentStatus(string kindName, string expe } [Fact] - public void ResetExpiryUsesTheNextFutureExpiryRoundedUpToWholeDays() + public void ResetExpiryUsesTheNextFutureExpiryDate() { var now = new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero); var resets = new RateLimitResetCredits( @@ -1381,24 +1381,24 @@ public void ResetExpiryUsesTheNextFutureExpiryRoundedUpToWholeDays() new RateLimitResetCredit("Expired reset", "available", now.AddDays(-1)), ]); - Assert.Equal("expires in 13 days", UsageDockItem.FormatResetExpiry(resets, now)); + Assert.Equal($"Expires - {UsageDockItem.FormatLocalDateTime(now.AddDays(12).AddHours(1).ToLocalTime(), System.Globalization.CultureInfo.CurrentCulture)}", UsageDockItem.FormatResetExpiry(resets, now)); } [Fact] public void ResetExpiryReportsUnavailableWhenNoFutureExpiryIsKnown() { - Assert.Equal("expiration unavailable", UsageDockItem.FormatResetExpiry(null, DateTimeOffset.Now)); + Assert.Equal("Expires - unavailable", UsageDockItem.FormatResetExpiry(null, DateTimeOffset.UnixEpoch)); } [Fact] - public void ResetExpiryUsesWholeHoursWhenLessThanOneDayRemains() + public void ResetExpiryIncludesDateAndMinutesForSameDayExpiry() { var now = new DateTimeOffset(2026, 7, 16, 12, 0, 0, TimeSpan.Zero); var resets = new RateLimitResetCredits( 1, [new RateLimitResetCredit("Next reset", "available", now.AddHours(12).AddMinutes(1))]); - Assert.Equal("expires in 13 hours", UsageDockItem.FormatResetExpiry(resets, now)); + Assert.Equal($"Expires - {UsageDockItem.FormatLocalDateTime(now.AddHours(12).AddMinutes(1).ToLocalTime(), System.Globalization.CultureInfo.CurrentCulture)}", UsageDockItem.FormatResetExpiry(resets, now)); } [Fact] diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 46c3576..1d33561 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -43,8 +43,10 @@ private void UpdateText() if (_kind == UsageDockItemKind.ResetsAndCredits) { Title = FormatResetsAndCredits(snapshot); - Subtitle = CombineStatusAndDetail( - FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), + Subtitle = FormatLiveDetailOrStatus( + snapshot, + now, + _usage.RefreshInterval, _settings?.CompactDock == true ? string.Empty : FormatResetExpiry(snapshot.ResetCredits, now)); Icon = new IconInfo("\uE777"); return; @@ -74,8 +76,14 @@ private void UpdateText() } Title = FormatQuotaTitle(_kind, window.RemainingPercent, compact); - var reset = compact || _settings?.ShowResetTime == false ? string.Empty : $"reset {FormatReset(window.ResetsAt)}"; - Subtitle = CombineStatusAndDetail(FormatSourceFreshness(snapshot, now, _usage.RefreshInterval), reset); + var reset = compact || _settings?.ShowResetTime == false + ? string.Empty + : $"Reset - {FormatLocalDateTime(window.ResetsAt.ToLocalTime(), CultureInfo.CurrentCulture)}"; + Subtitle = FormatLiveDetailOrStatus( + snapshot, + now, + _usage.RefreshInterval, + reset); Icon = new IconInfo(window.RemainingPercent <= 10 ? "\uE7BA" : "\uE916"); } @@ -189,6 +197,31 @@ private static string FormatAge(DateTimeOffset timestamp, DateTimeOffset now) private static string FormatLocalTime(DateTimeOffset value) => value.ToLocalTime().ToString("HH:mm", CultureInfo.CurrentCulture); + internal static string FormatLocalDateTime(DateTimeOffset local, CultureInfo culture) + { + var month = local.ToString("MMM", culture).TrimEnd('.'); + // Windows globalization data can abbreviate Dutch September as "sep". + if (culture.TwoLetterISOLanguageName == "nl" && local.Month == 9) month = "sept"; + return $"{local.Day.ToString(culture)} {month} {local.ToString("H:mm", culture)}"; + } + + internal static string FormatLiveDetailOrStatus( + CodexUsageSnapshot snapshot, + DateTimeOffset now, + TimeSpan refreshInterval, + string detail) + { + var state = UsageFreshness.Classify( + snapshot.UpdatedAt, + now, + refreshInterval); + return snapshot.Source == UsageDataSource.AppServer + && state == UsageFreshnessState.Fresh + && detail.Length > 0 + ? detail + : CombineStatusAndDetail(FormatSourceFreshness(snapshot, now, refreshInterval), detail); + } + private static string CombineStatusAndDetail(string status, string detail) => detail.Length == 0 ? status : $"{status} · {detail}"; @@ -220,13 +253,10 @@ internal static string FormatResetExpiry(RateLimitResetCredits? resets, DateTime if (nextExpiry is not { } expiry) { - return "expiration unavailable"; + return "Expires - unavailable"; } - var remaining = expiry - now; - return remaining < TimeSpan.FromHours(24) - ? $"expires in {(int)Math.Ceiling(remaining.TotalHours)} hours" - : $"expires in {(int)Math.Ceiling(remaining.TotalDays)} days"; + return $"Expires - {FormatLocalDateTime(expiry.ToLocalTime(), CultureInfo.CurrentCulture)}"; } internal static (string Title, string Subtitle) FormatUnavailable(UsageDockItemKind kind, bool compact = false) => @@ -237,14 +267,6 @@ internal static (string Title, string Subtitle) FormatUnavailable(UsageDockItemK _ => "-- resets", }, "Codex usage unavailable"); - private static string FormatReset(DateTimeOffset reset) - { - var local = reset.ToLocalTime(); - return local.Date == DateTime.Today - ? local.ToString("HH:mm", CultureInfo.CurrentCulture) - : local.ToString("ddd HH:mm", CultureInfo.CurrentCulture); - } - public void Dispose() { _usage.Updated -= OnUpdated; diff --git a/README.md b/README.md index e276ed3..93d98df 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Codex Usage Dock is activated inside PowerToys Command Palette and intentionally 5. Choose **Add command** (`+`) in the section where you want the widget. 6. Search for **Codex Usage** and select its Dock band. -The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10.00`. The percentages represent the amount remaining. The final entry shows available earned resets, the time until the next reset credit expires in whole hours or days, and, when available, the credits balance. Select an entry to see reset expiry details or refresh the data manually. +The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10.00`. The percentages represent the amount remaining. Quota subtitles show the next reset, such as `Reset - 15 sept 12:56`. The final entry shows available earned resets, the next credit expiry as `Expires - 4 okt 4:00`, and, when available, the credits balance. Dates use your local time zone and abbreviated month names from your regional settings. Stale or fallback data retains its source warning. Select an entry to see reset expiry details or refresh the data manually. ## Customize the Dock From cb82f54b7d780d78654002699dcb1dfc3183ae2c Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Wed, 9 Sep 2026 23:11:21 +0200 Subject: [PATCH 13/21] docs: record Dock date subtitles --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51e76d1..9c23894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Each entry links to the commit or pull request that introduced the change. ### Changed +- Show the local reset date and next earned-reset expiry directly in fresh Dock subtitles, with short regional month names and minute-precise times. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23)) - Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) - Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) From 7c63ff2ad0ae1543dac0677c050ce90938031216 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:02:02 +0200 Subject: [PATCH 14/21] fix: calculate planner budgets in the local time zone --- CHANGELOG.md | 1 + CodexUsageDock.Tests/UsagePlanningTests.cs | 19 +++++++++++++++++++ CodexUsageDock/UsagePlanning.cs | 18 +++++++++++------- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a71a60..c8132f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/UsagePlanningTests.cs b/CodexUsageDock.Tests/UsagePlanningTests.cs index d20d5ba..10fdcde 100644 --- a/CodexUsageDock.Tests/UsagePlanningTests.cs +++ b/CodexUsageDock.Tests/UsagePlanningTests.cs @@ -64,6 +64,25 @@ public void WeeklyWorkdayAssumptionAffectsDailyAndHourlyBudget() Assert.Equal(10, oneDayPlan.Weekly.AvailablePointsPerHour, precision: 8); } + [Theory] + [InlineData(2, 23, 3)] + [InlineData(-7, 1, 1)] + public void WeeklyBudgetCapsWorkdaysUsingLocalCalendarDates(int offsetHours, int resetHour, int expectedDays) + { + var timeZone = TimeZoneInfo.CreateCustomTimeZone("Planning test", TimeSpan.FromHours(offsetHours), "Planning test", "Planning test"); + var reset = new DateTimeOffset(2026, 9, 11, resetHour, 0, 0, TimeSpan.Zero); + var presentation = Presentation(primaryRemaining: null, secondaryRemaining: 60, secondaryReset: reset); + + var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(5), 7, timeZone); + var equivalentOffsetPlan = UsagePlanner.Plan( + presentation, Now.ToOffset(TimeSpan.FromHours(14)), RefreshInterval, Now.AddHours(5), 7, timeZone); + + Assert.Equal(expectedDays, plan.Weekly!.Workdays); + Assert.Equal(60d / expectedDays, plan.Weekly.AvailablePointsPerWorkday, precision: 8); + Assert.Equal(60d / expectedDays / 5, plan.Weekly.AvailablePointsPerHour, precision: 8); + Assert.Equal(plan.Weekly.Workdays, equivalentOffsetPlan.Weekly!.Workdays); + } + [Fact] public void InvalidEndOrWorkdayCountReturnsAnExplanation() { diff --git a/CodexUsageDock/UsagePlanning.cs b/CodexUsageDock/UsagePlanning.cs index 2f5fb97..70fad83 100644 --- a/CodexUsageDock/UsagePlanning.cs +++ b/CodexUsageDock/UsagePlanning.cs @@ -11,7 +11,8 @@ internal static UsagePlanningResult Plan( DateTimeOffset now, TimeSpan refreshInterval, DateTimeOffset desiredEnd, - int? remainingWorkdays = null) + int? remainingWorkdays = null, + TimeZoneInfo? timeZone = null) { ArgumentNullException.ThrowIfNull(presentation); @@ -80,7 +81,8 @@ internal static UsagePlanningResult Plan( remainingWorkdays, maximumSampleAge, adaptiveCycleCount: 0, - isPrimary: true); + isPrimary: true, + timeZone ?? TimeZoneInfo.Local); var weekly = CreateWindowPlan( snapshot.Secondary, presentation.WeeklyHistory, @@ -89,7 +91,8 @@ internal static UsagePlanningResult Plan( remainingWorkdays, maximumSampleAge, CountAdaptiveCycles(presentation), - isPrimary: false); + isPrimary: false, + timeZone ?? TimeZoneInfo.Local); if (primary is null && weekly is null) { @@ -130,7 +133,8 @@ internal static UsagePlanningResult Plan( int? requestedWorkdays, TimeSpan maximumSampleAge, int adaptiveCycleCount, - bool isPrimary) + bool isPrimary, + TimeZoneInfo timeZone) { if (!UsageFreshness.IsValidWindow(window, now)) { @@ -147,7 +151,7 @@ internal static UsagePlanningResult Plan( var workdays = isPrimary ? 1 - : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now))); + : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now, timeZone))); var remaining = validWindow.RemainingPercent; var pointsPerWorkday = remaining / workdays; var pointsPerHour = pointsPerWorkday / horizon.TotalHours; @@ -413,9 +417,9 @@ private static int CountAdaptiveCycles(UsagePresentation presentation) return completed + (history.ActiveCycle is null ? 0 : 1); } - private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now) + private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now, TimeZoneInfo timeZone) { - var days = (resetsAt.Date - now.Date).Days; + var days = (TimeZoneInfo.ConvertTime(resetsAt, timeZone).Date - TimeZoneInfo.ConvertTime(now, timeZone).Date).Days; if (days <= 1) { return 1; From 6014fd9e9a7be740004bf82c19bda6a9d2d35cf5 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:09:18 +0200 Subject: [PATCH 15/21] fix: tolerate inaccessible sessions and refresh Claude interval status --- CHANGELOG.md | 1 + .../CachedSessionReaderTests.cs | 32 +++++++++++++++++++ .../ProviderPilotIntegrationTests.cs | 28 ++++++++++++++++ CodexUsageDock/CachedCodexSessionReader.cs | 2 +- CodexUsageDock/CodexUsageService.Claude.cs | 5 ++- README.md | 4 +-- 6 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5e8e8..021a482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Skip inaccessible session subdirectories during local fallback discovery and recheck Claude capture freshness when the refresh interval changes. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/CachedSessionReaderTests.cs b/CodexUsageDock.Tests/CachedSessionReaderTests.cs index 3fc8429..08ec044 100644 --- a/CodexUsageDock.Tests/CachedSessionReaderTests.cs +++ b/CodexUsageDock.Tests/CachedSessionReaderTests.cs @@ -1,3 +1,5 @@ +using System.Security.AccessControl; +using System.Security.Principal; using System.Text; using System.Text.Json; using Xunit; @@ -13,6 +15,36 @@ public sealed class CachedSessionReaderTests : IDisposable public void Dispose() => _environment.Dispose(); + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InaccessibleSubdirectoryDoesNotHideLaterReadableSessions(bool archived) + { + var root = Path.Combine(HomePath, archived ? "archived_sessions" : "sessions"); + var blocked = Directory.CreateDirectory(Path.Combine(root, "000-blocked")); + var readable = Directory.CreateDirectory(Path.Combine(root, "zzz-readable")); + File.WriteAllText(Path.Combine(readable.FullName, "rollout-valid.jsonl"), QuotaLine(Now, 25)); + var originalAccess = blocked.GetAccessControl(); + var deniedAccess = blocked.GetAccessControl(); + using var identity = WindowsIdentity.GetCurrent(); + deniedAccess.AddAccessRule(new FileSystemAccessRule(identity.User!, FileSystemRights.ListDirectory, AccessControlType.Deny)); + try + { + blocked.SetAccessControl(deniedAccess); + Assert.Throws(() => Directory.GetFiles(blocked.FullName)); + var reader = CreateReader(); + + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + Assert.Equal(0, reader.BytesReadLastScan); + } + finally + { + deniedAccess.SetSecurityDescriptorBinaryForm(originalAccess.GetSecurityDescriptorBinaryForm(), AccessControlSections.Access); + blocked.SetAccessControl(deniedAccess); + } + } + [Fact] public void SelectionUsesEventTimeAcrossActiveAndArchivedFilesAndClearsInactiveWindows() { diff --git a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs index 82e3aa0..9af260a 100644 --- a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs +++ b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs @@ -11,6 +11,34 @@ public sealed class ProviderPilotIntegrationTests : IDisposable private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); public void Dispose() => _environment.Dispose(); + [Theory] + [InlineData(1, 15, "Stale", "Available")] + [InlineData(15, 1, "Available", "Stale")] + public async Task ChangingOnlyTheRefreshIntervalReclassifiesClaudeAndNotifiesPresentation( + int previousMinutes, int nextMinutes, string before, string after) + { + var capture = WriteCapture(); + using var service = _environment.CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), () => CodexUsageSnapshot.Loading, + clock: () => Now.AddMinutes(6)); + service.SetRefreshInterval(TimeSpan.FromMinutes(previousMinutes)); + service.ConfigureClaude(true, capture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(before, service.GetClaudeUsage().Status.ToString()); + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + service.ClaudeUpdated += (_, _) => + { + if (service.GetClaudeUsage().Status.ToString() == after) updated.TrySetResult(); + }; + + service.SetRefreshInterval(TimeSpan.FromMinutes(nextMinutes)); + service.ConfigureClaude(true, capture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(after, service.GetClaudeUsage().Status.ToString()); + await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + [Fact] public void ApplyingAProfilePersistsPathsAndLabelBeforeTheNextStart() { diff --git a/CodexUsageDock/CachedCodexSessionReader.cs b/CodexUsageDock/CachedCodexSessionReader.cs index 5dfada4..2b9fb7c 100644 --- a/CodexUsageDock/CachedCodexSessionReader.cs +++ b/CodexUsageDock/CachedCodexSessionReader.cs @@ -96,7 +96,7 @@ private bool DiscoverFiles(CancellationToken cancellationToken) var options = new EnumerationOptions { RecurseSubdirectories = true, - IgnoreInaccessible = false, + IgnoreInaccessible = true, AttributesToSkip = FileAttributes.ReparsePoint, }; try diff --git a/CodexUsageDock/CodexUsageService.Claude.cs b/CodexUsageDock/CodexUsageService.Claude.cs index 19bf9f5..5c1d051 100644 --- a/CodexUsageDock/CodexUsageService.Claude.cs +++ b/CodexUsageDock/CodexUsageService.Claude.cs @@ -4,6 +4,7 @@ internal sealed partial class CodexUsageService { private bool _claudeEnabled; private string _claudePath = string.Empty; + private TimeSpan _claudeRefreshInterval; private long _claudeGeneration; private Task? _claudeReadTask; private ClaudeUsageSnapshot _claudeUsage = ClaudeUsageSnapshot.Unavailable("The Claude pilot is disabled."); @@ -16,9 +17,11 @@ internal void ConfigureClaude(bool enabled, string path) { lock (_refreshStateLock) { - if (_disposed || _claudeEnabled == enabled && _claudePath == path) return; + var interval = RefreshInterval; + if (_disposed || _claudeEnabled == enabled && _claudePath == path && _claudeRefreshInterval == interval) return; _claudeEnabled = enabled; _claudePath = path; + _claudeRefreshInterval = interval; _claudeGeneration++; _claudeUsage = ClaudeUsageSnapshot.Unavailable(enabled ? "Waiting for a local Claude usage capture." : "The Claude pilot is disabled."); } diff --git a/README.md b/README.md index d9c95d1..72117b0 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd ## Optional Claude usage pilot -The pilot displays separate Claude five-hour and seven-day limits from an explicitly selected local capture file. It is off by default and adds its own Dock band when enabled. It does not verify the Claude account, combine Claude percentages with Codex, or infer costs. Claude reads run independently of a slow Codex refresh. +The pilot displays separate Claude five-hour and seven-day limits from an explicitly selected local capture file. It is off by default and adds its own Dock band when enabled. It does not verify the Claude account, combine Claude percentages with Codex, or infer costs. Claude reads run independently of a slow Codex refresh. Changing the refresh interval immediately rereads the capture and updates its freshness status. The bridge uses Claude Code's documented `rate_limits.five_hour` and `rate_limits.seven_day` statusline fields. These may be absent independently, appear only after a session receives an API response, and require an eligible subscription. The pilot does not support gateway spend-limit fields. See the [official statusline field documentation](https://code.claude.com/docs/en/statusline#available-data). @@ -133,7 +133,7 @@ Freshness uses the greater of five minutes and the configured refresh interval t When Codex supplies an account identity, weekly history and learned profiles are stored separately for that account and default quota category using opaque hashed directory names. History appears only after the account is identified. Older history files have no identity and are not imported into an account. Without a verified account identity, recent observations remain in memory and adaptive learning is paused. -The local quota fallback caches read positions and the latest valid quota event in memory. Unchanged files still in its cache are not reread for content; appended, replaced, truncated, and deleted files are handled on later refreshes. Each scan reads at most 8 MiB of file content, tracks up to 512 files, and bounds partial lines to 128 KiB. It still enumerates session file metadata; these limits do not promise constant scan time for large directories. Incomplete scans are reported, and later refreshes continue discovery. No session payload or read-position cache is saved to disk by this quota fallback. +The local quota fallback caches read positions and the latest valid quota event in memory. Unchanged files still in its cache are not reread for content; appended, replaced, truncated, and deleted files are handled on later refreshes. Each scan reads at most 8 MiB of file content, tracks up to 512 files, and bounds partial lines to 128 KiB. It still enumerates session file metadata and skips inaccessible subdirectories; these limits do not promise constant scan time for large directories. Incomplete scans are reported, and later refreshes continue discovery. No session payload or read-position cache is saved to disk by this quota fallback. ## Development From df4d18f1bdf87c1f3bae386503d5add6ed811c88 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:14:53 +0200 Subject: [PATCH 16/21] fix: use regional calendar days in Dock subtitles --- CHANGELOG.md | 1 + CodexUsageDock.Tests/DockDateSubtitleTests.cs | 14 ++++++++++++++ CodexUsageDock/UsageDockItem.cs | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb09425..b8fd39c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Use the regional calendar consistently for the day and month in Dock reset and expiry dates. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23)) - Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22)) - Skip inaccessible session subdirectories during local fallback discovery and recheck Claude capture freshness when the refresh interval changes. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) diff --git a/CodexUsageDock.Tests/DockDateSubtitleTests.cs b/CodexUsageDock.Tests/DockDateSubtitleTests.cs index 00645b6..7a5c880 100644 --- a/CodexUsageDock.Tests/DockDateSubtitleTests.cs +++ b/CodexUsageDock.Tests/DockDateSubtitleTests.cs @@ -5,6 +5,20 @@ namespace CodexUsageDock.Tests; public sealed class DockDateSubtitleTests { + [Theory] + [InlineData("ar-SA")] + [InlineData("fa-IR")] + public void DatesUseTheRegionalCalendarForBothDayAndMonth(string cultureName) + { + var culture = CultureInfo.GetCultureInfo(cultureName); + var local = new DateTimeOffset(2026, 9, 15, 12, 56, 0, TimeSpan.FromHours(2)); + var calendarDay = culture.DateTimeFormat.Calendar.GetDayOfMonth(local.DateTime); + Assert.NotEqual(local.Day, calendarDay); + var expected = $"{calendarDay.ToString(culture)} {local.ToString("MMM", culture).TrimEnd('.')} 12:56"; + + Assert.Equal(expected, UsageDockItem.FormatLocalDateTime(local, culture)); + } + [Theory] [InlineData(9, 15, 12, 56, "15 sept 12:56")] [InlineData(10, 4, 4, 0, "4 okt 4:00")] diff --git a/CodexUsageDock/UsageDockItem.cs b/CodexUsageDock/UsageDockItem.cs index 1d33561..2c02dd4 100644 --- a/CodexUsageDock/UsageDockItem.cs +++ b/CodexUsageDock/UsageDockItem.cs @@ -202,7 +202,7 @@ internal static string FormatLocalDateTime(DateTimeOffset local, CultureInfo cul var month = local.ToString("MMM", culture).TrimEnd('.'); // Windows globalization data can abbreviate Dutch September as "sep". if (culture.TwoLetterISOLanguageName == "nl" && local.Month == 9) month = "sept"; - return $"{local.Day.ToString(culture)} {month} {local.ToString("H:mm", culture)}"; + return $"{local.ToString("%d", culture)} {month} {local.ToString("H:mm", culture)}"; } internal static string FormatLiveDetailOrStatus( From 39bf74cd476920441146f37737ad1216a9c0b8ad Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:17:13 +0200 Subject: [PATCH 17/21] Remove Claude integration and manual source settings --- CHANGELOG.md | 12 +- .../ClaudeUsageReaderTests.cs | 367 ------------------ .../CodexOnlySettingsTests.cs | 106 +++++ .../CodexProfileStoreTests.cs | 226 ----------- CodexUsageDock.Tests/CodexUsageTableTests.cs | 24 ++ CodexUsageDock.Tests/ProviderDockTests.cs | 110 ------ .../ProviderPilotIntegrationTests.cs | 140 ------- CodexUsageDock.Tests/UsagePreferenceTests.cs | 27 +- CodexUsageDock/ClaudeUsageReader.cs | 335 ---------------- CodexUsageDock/CodexProfileStore.cs | 348 ----------------- .../CodexUsageDockCommandsProvider.cs | 63 +-- CodexUsageDock/CodexUsageService.Claude.cs | 72 ---- CodexUsageDock/CodexUsageService.cs | 4 +- CodexUsageDock/Pages/ClaudeUsagePage.cs | 64 --- CodexUsageDock/Pages/CodexProfilesPage.cs | 356 ----------------- .../Pages/CodexUsageDockSettingsPage.cs | 96 +---- DEVELOPMENT.md | 2 + PRIVACY.md | 10 +- README.md | 27 +- SPRINTS.md | 4 +- scripts/capture-claude-usage.ps1 | 313 --------------- 21 files changed, 157 insertions(+), 2549 deletions(-) delete mode 100644 CodexUsageDock.Tests/ClaudeUsageReaderTests.cs create mode 100644 CodexUsageDock.Tests/CodexOnlySettingsTests.cs delete mode 100644 CodexUsageDock.Tests/CodexProfileStoreTests.cs create mode 100644 CodexUsageDock.Tests/CodexUsageTableTests.cs delete mode 100644 CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs delete mode 100644 CodexUsageDock/ClaudeUsageReader.cs delete mode 100644 CodexUsageDock/CodexProfileStore.cs delete mode 100644 CodexUsageDock/CodexUsageService.Claude.cs delete mode 100644 CodexUsageDock/Pages/ClaudeUsagePage.cs delete mode 100644 CodexUsageDock/Pages/CodexProfilesPage.cs delete mode 100644 scripts/capture-claude-usage.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index b8fd39c..69f2a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,14 +10,12 @@ Each entry links to the commit or pull request that introduced the change. ### Added -- An opt-in Claude pilot with an independent Dock band and a local statusline capture script that preserves an existing formatter or supplies a standalone quota line. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) -- Named local/Windows-accessible WSL source profiles and a text alternative for quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- A text alternative for Codex quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) -- Explicit executable and Codex home settings, with invalid-source errors and protection against results from a previous profile. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Separate quota categories and arbitrary window durations from modern Codex responses, while preserving legacy five-hour and weekly limits. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Safe diagnostics with running-build version, source, freshness, refresh attempts, and reset-field availability. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) @@ -25,8 +23,8 @@ Each entry links to the commit or pull request that introduced the change. - Use the regional calendar consistently for the day and month in Dock reset and expiry dates. ([PR #23](https://github.com/TheBeems/CodexUsageDock/pull/23)) - Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22)) -- Skip inaccessible session subdirectories during local fallback discovery and recheck Claude capture freshness when the refresh interval changes. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) -- Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- Skip inaccessible session subdirectories during local fallback discovery. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- Serialize source-sensitive presentation changes so delayed updates cannot restore old account values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) @@ -38,6 +36,10 @@ Each entry links to the commit or pull request that introduced the change. - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) - Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) +### Removed + +- Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([implementation](https://github.com/TheBeems/CodexUsageDock/commit/codex%2Fcodex-only-settings)) + ## [0.6.1] - 2026-09-09 ### Fixed diff --git a/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs b/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs deleted file mode 100644 index 32d1dbc..0000000 --- a/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs +++ /dev/null @@ -1,367 +0,0 @@ -using System.Diagnostics; -using System.Globalization; -using System.Text; -using System.Text.Json; -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class ClaudeUsageReaderTests -{ - private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); - - [Fact] - public void ReaderParsesDocumentedWindowsAndIgnoresUnrelatedFields() - { - var snapshot = ReadJson( - CaptureJson( - Now, - WindowJson(23.5, Now.AddHours(1)), - WindowJson(41.25, Now.AddDays(3)), - extra: "\"private\":{\"account\":\"secret\",\"model\":\"private-model\"}")); - - Assert.Equal(ClaudeUsageReadStatus.Available, snapshot.Status); - Assert.True(snapshot.IsAvailable); - Assert.Equal(23.5, snapshot.Primary!.UsedPercent); - Assert.Equal(300, snapshot.Primary!.WindowMinutes); - Assert.Equal(76.5, snapshot.Primary!.RemainingPercent); - Assert.Equal(Now.AddHours(1), snapshot.Primary!.ResetsAt); - Assert.Equal(41.25, snapshot.Weekly!.UsedPercent); - Assert.Equal(10080, snapshot.Weekly!.WindowMinutes); - Assert.Equal(Now, snapshot.ObservedAt); - } - - [Fact] - public void ReaderRequiresTheBridgeSchemaAndDoesNotAcceptUnspecifiedAliases() - { - var aliases = """ - { - "schemaVersion": 1, - "provider": "claude", - "observedAtUTC": "2026-09-09T12:00:00.0000000Z", - "fiveHour": { "usedPercentage": 20, "resetsAt": 1788958800 }, - "sevenDay": { "usedPercentage": 30, "resetsAt": 1789214400 } - } - """; - - var snapshot = ReadJson(aliases); - - Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); - Assert.Null(snapshot.Primary); - Assert.Null(snapshot.Weekly); - } - - [Fact] - public void ReaderKeepsWindowsIndependentWhenOneIsMissingOrInvalid() - { - var missingWeekly = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddHours(1)), null)); - var invalidPrimary = ReadJson(CaptureJson(Now, WindowJson(-1, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - - Assert.Equal(ClaudeUsageReadStatus.Partial, missingWeekly.Status); - Assert.NotNull(missingWeekly.Primary); - Assert.Null(missingWeekly.Weekly); - Assert.Equal(ClaudeUsageReadStatus.Partial, invalidPrimary.Status); - Assert.Null(invalidPrimary.Primary); - Assert.NotNull(invalidPrimary.Weekly); - } - - [Fact] - public void ReaderMarksFutureAndStaleObservationsWithoutCallingThemAvailable() - { - var future = ReadJson(CaptureJson(Now.AddSeconds(1), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - var stale = ReadJson(CaptureJson(Now.AddMinutes(-6), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - - Assert.Equal(ClaudeUsageReadStatus.Future, future.Status); - Assert.False(future.IsAvailable); - Assert.NotNull(future.Primary); - Assert.Equal(ClaudeUsageReadStatus.Stale, stale.Status); - Assert.False(stale.IsAvailable); - Assert.NotNull(stale.Weekly); - } - - [Fact] - public void ReaderRejectsZoneLessObservationTimes() - { - var zoneLess = """ - { - "schemaVersion": 1, - "provider": "claude", - "observedAtUTC": "2026-09-09T12:00:00", - "rate_limits": { - "five_hour": { "used_percentage": 20, "resets_at": 1788958800 }, - "seven_day": { "used_percentage": 30, "resets_at": 1789214400 } - } - } - """; - - Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson(zoneLess).Status); - } - - [Fact] - public void ReaderRejectsExpiredAndOutOfRangeWindowValues() - { - var expiredPrimary = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddDays(3)))); - var expiredBoth = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddSeconds(-1)))); - var outOfRange = ReadJson(CaptureJson(Now, WindowJson(101, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); - - Assert.Equal(ClaudeUsageReadStatus.Partial, expiredPrimary.Status); - Assert.Null(expiredPrimary.Primary); - Assert.NotNull(expiredPrimary.Weekly); - Assert.Equal(ClaudeUsageReadStatus.Unavailable, expiredBoth.Status); - Assert.Equal(ClaudeUsageReadStatus.Partial, outOfRange.Status); - Assert.Null(outOfRange.Primary); - } - - [Fact] - public void ReaderRejectsMissingMalformedAndOversizedFilesSafely() - { - Assert.Equal( - ClaudeUsageReadStatus.Unavailable, - ClaudeUsageReader.Read( - Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.json"), - Now, - RefreshInterval).Status); - Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson("not-json").Status); - - var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); - try - { - File.WriteAllBytes(path, new byte[ClaudeUsageReader.MaximumFileBytes + 1]); - var snapshot = ClaudeUsageReader.Read(path, Now, RefreshInterval); - Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); - } - finally - { - File.Delete(path); - } - } - - [Fact] - public void ReaderRequiresAQualifiedCapturePath() - { - var snapshot = ClaudeUsageReader.Read("relative-claude-usage.json", Now, RefreshInterval); - - Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); - Assert.Contains("fully qualified", snapshot.Message, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void ScriptPassesThroughStatuslineAndWritesOnlyAggregateWindows() - { - var input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}},\"workspace\":{\"path\":\"private\"}}"; - var result = RunCaptureScript(input); - - Assert.Equal(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - Assert.DoesNotContain("do-not-copy", result.SnapshotJson, StringComparison.Ordinal); - using var document = JsonDocument.Parse(result.SnapshotJson); - var root = document.RootElement; - Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); - Assert.Equal("claude", root.GetProperty("provider").GetString()); - Assert.EndsWith("+00:00", root.GetProperty("observedAtUTC").GetString()!, StringComparison.Ordinal); - var limits = root.GetProperty("rate_limits"); - Assert.Equal(23.5, limits.GetProperty("five_hour").GetProperty("used_percentage").GetDouble()); - Assert.Equal(41, limits.GetProperty("seven_day").GetProperty("used_percentage").GetDouble()); - Assert.DoesNotContain("model", result.SnapshotJson, StringComparison.Ordinal); - Assert.DoesNotContain("workspace", result.SnapshotJson, StringComparison.Ordinal); - } - - [Fact] - public void ScriptStandaloneModeSuppressesRawStatuslineMetadata() - { - const string input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}}}"; - var result = RunCaptureScript(input, standalone: true); - - Assert.Equal(0, result.ExitCode); - Assert.Contains("Claude 5h 76.5% / week 59%", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("private-model", result.StandardOutput, StringComparison.Ordinal); - Assert.DoesNotContain("do-not-copy", result.StandardOutput, StringComparison.Ordinal); - } - - [Fact] - public void ScriptDrainsAndPassesThroughInputWhenDestinationIsRelative() - { - const string input = "{\"rate_limits\":{},\"private\":\"unchanged\"}"; - var result = RunCaptureScript(input, "relative-claude-capture.json"); - - Assert.NotEqual(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); - } - - [Fact] - public void ScriptWritesUnavailableSnapshotForMissingOrInvalidFields() - { - var input = "{\"rate_limits\":{\"five_hour\":{\"used_percentage\":\"75\",\"resets_at\":0},\"seven_day\":{\"used_percentage\":101,\"resets_at\":0}},\"secret\":\"keep-out\"}"; - var result = RunCaptureScript(input); - - Assert.Equal(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - using var document = JsonDocument.Parse(result.SnapshotJson); - var root = document.RootElement; - Assert.Equal("unavailable", root.GetProperty("status").GetString()); - Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("five_hour").ValueKind); - Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("seven_day").ValueKind); - Assert.DoesNotContain("keep-out", result.SnapshotJson, StringComparison.Ordinal); - } - - [Fact] - public void ScriptBoundsCapturedInputButStillPassesThroughOversizedStatuslineData() - { - var input = new string('x', 256 * 1024 + 1); - var result = RunCaptureScript(input); - - Assert.Equal(0, result.ExitCode); - Assert.Equal(input, result.StandardOutput); - using var document = JsonDocument.Parse(result.SnapshotJson); - Assert.Equal("unavailable", document.RootElement.GetProperty("status").GetString()); - } - - [Fact] - public void ScriptRequiresAnAbsoluteDestinationAndReportsWriteFailuresGenerically() - { - var script = FindScript(); - var destinationDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(destinationDirectory); - try - { - var result = RunCaptureScript("{}", destinationDirectory); - - Assert.NotEqual(0, result.ExitCode); - Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); - Assert.DoesNotContain("{}", result.StandardError, StringComparison.Ordinal); - Assert.NotEmpty(script); - } - finally - { - Directory.Delete(destinationDirectory, recursive: true); - } - } - - private static ClaudeUsageSnapshot ReadJson( - string json, - DateTimeOffset? now = null, - TimeSpan? refreshInterval = null) - { - var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); - try - { - File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); - return ClaudeUsageReader.Read(path, now ?? Now, refreshInterval ?? RefreshInterval); - } - finally - { - File.Delete(path); - } - } - - private static string CaptureJson( - DateTimeOffset observedAt, - string? primary, - string? weekly, - string? extra = null) - { - var primaryText = primary ?? "null"; - var weeklyText = weekly ?? "null"; - var suffix = string.IsNullOrWhiteSpace(extra) ? string.Empty : $",{extra}"; - return "{\"schemaVersion\":1,\"provider\":\"claude\",\"observedAtUTC\":\"" - + observedAt.ToString("O", CultureInfo.InvariantCulture) - + "\",\"rate_limits\":{\"five_hour\":" - + primaryText - + ",\"seven_day\":" - + weeklyText - + "}" - + suffix - + "}"; - } - - private static string WindowJson(double usedPercent, DateTimeOffset resetsAt) => - "{\"used_percentage\":" - + usedPercent.ToString(CultureInfo.InvariantCulture) - + ",\"resets_at\":" - + Unix(resetsAt) - + "}"; - - private static long Unix(DateTimeOffset timestamp) => timestamp.ToUnixTimeSeconds(); - - private static ScriptResult RunCaptureScript(string input, string? outputPath = null, bool standalone = false) - { - var destination = outputPath ?? Path.Combine(Path.GetTempPath(), $"claude-capture-{Guid.NewGuid():N}.json"); - try - { - var startInfo = new ProcessStartInfo - { - FileName = FindPowerShell(), - UseShellExecute = false, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), - }; - startInfo.ArgumentList.Add("-NoLogo"); - startInfo.ArgumentList.Add("-NoProfile"); - startInfo.ArgumentList.Add("-NonInteractive"); - startInfo.ArgumentList.Add("-File"); - startInfo.ArgumentList.Add(FindScript()); - startInfo.ArgumentList.Add("-OutputPath"); - startInfo.ArgumentList.Add(destination); - if (standalone) - { - startInfo.ArgumentList.Add("-Standalone"); - } - - using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("PowerShell did not start."); - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - process.StandardInput.Write(input); - process.StandardInput.Close(); - if (!process.WaitForExit(30_000)) - { - process.Kill(entireProcessTree: true); - throw new TimeoutException("The capture fixture did not finish."); - } - - var stdout = stdoutTask.GetAwaiter().GetResult(); - var stderr = stderrTask.GetAwaiter().GetResult(); - var snapshot = File.Exists(destination) ? File.ReadAllText(destination, Encoding.UTF8) : string.Empty; - return new ScriptResult(process.ExitCode, stdout, stderr, snapshot); - } - finally - { - if (File.Exists(destination)) - { - File.Delete(destination); - } - } - } - - private static string FindScript() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null) - { - var candidate = Path.Combine(directory.FullName, "scripts", "capture-claude-usage.ps1"); - if (File.Exists(candidate)) - { - return candidate; - } - - directory = directory.Parent; - } - - throw new FileNotFoundException("The Claude capture fixture was not found."); - } - - private static string FindPowerShell() - { - var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - var candidate = Path.Combine(windows, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); - return File.Exists(candidate) ? candidate : "pwsh"; - } - - private sealed record ScriptResult(int ExitCode, string StandardOutput, string StandardError, string SnapshotJson); -} diff --git a/CodexUsageDock.Tests/CodexOnlySettingsTests.cs b/CodexUsageDock.Tests/CodexOnlySettingsTests.cs new file mode 100644 index 0000000..043dd2b --- /dev/null +++ b/CodexUsageDock.Tests/CodexOnlySettingsTests.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CodexOnlySettingsTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Theory] + [InlineData("codexExecutablePath")] + [InlineData("codexHomePath")] + [InlineData("sourceLabel")] + [InlineData("enableClaude")] + [InlineData("claudeBridgePath")] + public void SettingsDoNotOfferRemovedSourceControls(string key) + { + var settings = _environment.CreateSettings(); + var content = string.Join("\n", settings.GetContent().OfType() + .Select(form => form.TemplateJson + form.DataJson)); + + Assert.DoesNotContain(key, content, StringComparison.Ordinal); + } + + [Fact] + public void SavingLegacySettingsKeepsCodexChoicesAndDropsRemovedPreferences() + { + var legacy = LegacySettings(); + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize(legacy)); + var settings = _environment.CreateSettings(); + + Assert.False(settings.ShowFiveHourLimit); + Assert.True(settings.CompactDock); + Assert.Equal(TimeSpan.FromMinutes(5), settings.RefreshInterval); + Assert.Null(settings.StatusMessage); + + legacy["refreshInterval"] = "15"; + settings.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(legacy), "{}"); + + using var saved = JsonDocument.Parse(File.ReadAllText(_environment.PathFor("settings.json"))); + foreach (var key in new[] { "codexExecutablePath", "codexHomePath", "sourceLabel", "enableClaude", "claudeBridgePath" }) + { + Assert.False(saved.RootElement.TryGetProperty(key, out _), key); + } + + var restarted = _environment.CreateSettings(); + Assert.False(restarted.ShowFiveHourLimit); + Assert.True(restarted.CompactDock); + Assert.Equal(TimeSpan.FromMinutes(15), restarted.RefreshInterval); + Assert.Null(restarted.StatusMessage); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LegacySourcePreferencesDoNotBlockCodexOrRestoreRemovedCommands(bool separate) + { + var legacy = LegacySettings(); + legacy["separateDockItems"] = separate ? "true" : "false"; + File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize(legacy)); + var now = new DateTimeOffset(2026, 9, 13, 12, 0, 0, TimeSpan.Zero); + var quota = new CodexUsageSnapshot(new(25, 300, now.AddHours(4)), new(40, 10080, now.AddDays(3)), + null, null, null, now, UsageDataSource.AppServer, null, AccountKey: "account-a"); + using var service = _environment.CreateService(_ => Task.FromResult(quota), () => CodexUsageSnapshot.Loading, + clock: () => now); + var settings = _environment.CreateSettings(); + using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now); + + await service.RefreshAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(UsageDataSource.AppServer, service.Current.Source); + Assert.Equal(quota.AccountKey, service.Current.AccountKey); + Assert.Equal(quota.Primary, service.Current.Primary); + Assert.Equal(quota.Secondary, service.Current.Secondary); + Assert.Null(settings.StatusMessage); + var removedIds = new[] { "nl.mathijs.codexusage.dock.claude", "nl.mathijs.codexusage.claude", "nl.mathijs.codexusage.profiles" }; + foreach (var id in removedIds) + { + Assert.Null(provider.GetCommandItem(id)); + Assert.DoesNotContain(provider.TopLevelCommands(), item => item.Command.Id == id); + } + + var expectedIds = separate + ? new[] { "nl.mathijs.codexusage.dock.weekly", "nl.mathijs.codexusage.dock.credits" } + : ["nl.mathijs.codexusage.dock"]; + Assert.Equal(expectedIds, provider.GetDockBands()!.Select(item => item.Command.Id)); + Assert.Contains(provider.TopLevelCommands(), item => item.Command.Id == "nl.mathijs.codexusage.table"); + Assert.All(provider.GetDockBands()!, item => Assert.IsAssignableFrom(item.Command)); + } + + private static Dictionary LegacySettings() => new() + { + ["showFiveHourLimit"] = "false", + ["compactDock"] = "true", + ["refreshInterval"] = "5", + ["codexExecutablePath"] = "removed-invalid-executable-path", + ["codexHomePath"] = "removed-invalid-home-path", + ["sourceLabel"] = "Old source", + ["enableClaude"] = "true", + ["claudeBridgePath"] = "removed-invalid-capture-path", + }; +} diff --git a/CodexUsageDock.Tests/CodexProfileStoreTests.cs b/CodexUsageDock.Tests/CodexProfileStoreTests.cs deleted file mode 100644 index ddbf137..0000000 --- a/CodexUsageDock.Tests/CodexProfileStoreTests.cs +++ /dev/null @@ -1,226 +0,0 @@ -using System.Text.Json; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.CmdPal.Common.Commands; -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class CodexProfileStoreTests : IDisposable -{ - private readonly TestEnvironment _environment = new(); - - public void Dispose() => _environment.Dispose(); - - [Fact] - public void UpsertPersistsAndReplacesNamesCaseInsensitively() - { - var executable = _environment.PathFor("codex.exe"); - var home = _environment.PathFor("codex-home"); - File.WriteAllText(executable, string.Empty); - Directory.CreateDirectory(home); - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - - Assert.True(store.TryUpsert("Work", executable, home, out var first, out var firstError), firstError); - Assert.NotNull(first); - Assert.Equal(32, first!.Id.ToString("N").Length); - Assert.Equal(executable, first.SourceOptions.ExecutablePath); - Assert.Equal(home, first.SourceOptions.HomePath); - - Assert.True(store.TryUpsert("work", null, null, out var replacement, out var replacementError), replacementError); - Assert.NotNull(replacement); - Assert.Equal(first.Id, replacement!.Id); - Assert.Equal("work", replacement.DisplayName); - Assert.Null(replacement.SourceOptions.ExecutablePath); - Assert.Null(replacement.SourceOptions.HomePath); - - var reloaded = new CodexProfileStore(_environment.PathFor("profiles.json")); - var saved = Assert.Single(reloaded.Profiles); - Assert.Equal(replacement.Id, saved.Id); - Assert.Equal("work", saved.DisplayName); - Assert.Null(saved.SourceOptions.ExecutablePath); - Assert.Null(saved.SourceOptions.HomePath); - } - - [Fact] - public void InvalidNamesAndPathsAreRejectedAndTheProfileCountIsBounded() - { - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - - Assert.False(store.TryUpsert(string.Empty, null, null, out _, out var emptyNameError)); - Assert.Contains("1 to 40", emptyNameError, StringComparison.Ordinal); - Assert.False(store.TryUpsert(new string('x', 41), null, null, out _, out _)); - Assert.False(store.TryUpsert("bad\u0001name", null, null, out _, out _)); - - var relativeExecutable = Path.Combine("relative", "codex.exe"); - Assert.False(store.TryUpsert("Relative", relativeExecutable, null, out _, out var relativeError)); - Assert.DoesNotContain(relativeExecutable, relativeError, StringComparison.Ordinal); - - var missingHome = Path.Combine(_environment.PathFor("missing"), "home"); - Assert.False(store.TryUpsert("Missing home", null, missingHome, out _, out var missingHomeError)); - Assert.DoesNotContain(missingHome, missingHomeError, StringComparison.Ordinal); - - for (var index = 0; index < CodexProfileStore.MaximumProfiles; index++) - { - Assert.True(store.TryUpsert($"Profile {index}", null, null, out _, out var error), error); - } - - Assert.False(store.TryUpsert("Profile 8", null, null, out _, out var limitError)); - Assert.Contains("eight", limitError, StringComparison.OrdinalIgnoreCase); - Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Count); - Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Select(profile => profile.Id).Distinct().Count()); - } - - [Fact] - public void ReloadKeepsOfflinePathsButPersistsOnlyProfileFields() - { - var unavailableExecutable = Path.Combine(_environment.PathFor("offline"), "codex.exe"); - var unavailableHome = _environment.PathFor("offline-home"); - var path = _environment.PathFor("profiles.json"); - File.WriteAllText(path, JsonSerializer.Serialize(new - { - schemaVersion = CodexProfileStore.SchemaVersion, - profiles = new[] - { - new - { - id = Guid.NewGuid().ToString("N"), - displayName = "Offline WSL", - executablePath = unavailableExecutable, - homePath = unavailableHome, - accountEmail = "private@example.com", - }, - }, - })); - - var store = new CodexProfileStore(path); - var loaded = Assert.Single(store.Profiles); - Assert.Equal(unavailableExecutable, loaded.SourceOptions.ExecutablePath); - Assert.Equal(unavailableHome, loaded.SourceOptions.HomePath); - - Assert.True(store.TryUpsert("Offline WSL", null, null, out _, out var error), error); - var saved = File.ReadAllText(path); - Assert.DoesNotContain("private@example.com", saved, StringComparison.Ordinal); - Assert.DoesNotContain("accountEmail", saved, StringComparison.Ordinal); - } - - [Fact] - public void MalformedAndOversizedProfileDocumentsFailSafelyAndLoadAtMostEight() - { - var path = _environment.PathFor("profiles.json"); - File.WriteAllText(path, "null"); - - var malformed = new CodexProfileStore(path); - Assert.Empty(malformed.Profiles); - Assert.Contains("could not be read", malformed.StorageError, StringComparison.Ordinal); - Assert.DoesNotContain("null", malformed.StorageError, StringComparison.OrdinalIgnoreCase); - - File.WriteAllText(path, JsonSerializer.Serialize(new - { - schemaVersion = CodexProfileStore.SchemaVersion, - profiles = Enumerable.Range(0, CodexProfileStore.MaximumProfiles + 4) - .Select(index => new - { - id = Guid.NewGuid().ToString("N"), - displayName = $"Profile {index}", - executablePath = (string?)null, - homePath = (string?)null, - }) - .ToArray(), - })); - - var bounded = new CodexProfileStore(path); - Assert.Equal(CodexProfileStore.MaximumProfiles, bounded.Profiles.Count); - } - - [Fact] - public void RemoveUsesStableIdentityAndPersistsTheDeletion() - { - var path = _environment.PathFor("profiles.json"); - var store = new CodexProfileStore(path); - Assert.True(store.TryUpsert("Work", null, null, out var profile, out var error), error); - Assert.NotNull(profile); - Assert.True(store.TryGet(profile!.Id, out var found)); - Assert.Equal(profile, found); - - Assert.False(store.TryRemove(Guid.Empty, out var invalidIdError)); - Assert.Contains("identifier", invalidIdError, StringComparison.OrdinalIgnoreCase); - Assert.True(store.TryRemove(profile.Id, out var removeError), removeError); - Assert.Empty(store.Profiles); - Assert.Empty(new CodexProfileStore(path).Profiles); - } - - [Fact] - public void ProfilesPageOffersFormUseAndConfirmedRemoval() - { - var executable = _environment.PathFor("codex.exe"); - var home = _environment.PathFor("codex-home"); - File.WriteAllText(executable, string.Empty); - Directory.CreateDirectory(home); - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - Assert.True(store.TryUpsert("Work", executable, home, out _, out var error), error); - - using var page = new CodexProfilesPage(store); - var items = page.GetItems(); - Assert.Equal(2, items.Length); - var add = Assert.Single(items, item => item.Title == "Add or replace profile"); - var formPage = Assert.IsType(add.Command); - var form = Assert.IsAssignableFrom(Assert.Single(formPage.GetContent().OfType())); - Assert.Contains("\"id\":\"name\"", form.TemplateJson, StringComparison.Ordinal); - Assert.Contains("\"id\":\"executablePath\"", form.TemplateJson, StringComparison.Ordinal); - Assert.Contains("\"id\":\"homePath\"", form.TemplateJson, StringComparison.Ordinal); - - var selected = new List(); - page.ProfileSelected += (_, args) => selected.Add(args); - var profileItem = Assert.Single(items, item => item.Title == "Work"); - var use = Assert.IsAssignableFrom(profileItem.Command); - use.Invoke(page); - var selection = Assert.Single(selected); - Assert.Equal("Work", selection.Name); - Assert.Equal(executable, selection.Options.ExecutablePath); - Assert.Equal(home, selection.Options.HomePath); - - var deleteContext = Assert.IsType(Assert.Single(profileItem.MoreCommands)); - var confirmation = Assert.IsType(deleteContext.Command); - Assert.Equal("Delete profile", deleteContext.Title); - confirmation.Command.Invoke(page); - Assert.Empty(store.Profiles); - Assert.Single(page.GetItems()); - } - - [Fact] - public void NewProfileFormUpsertsAProfileWithoutApplyingIt() - { - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - using var page = new NewProfileFormPage(store); - var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); - var result = form.SubmitForm(JsonSerializer.Serialize(new - { - name = "Work", - executablePath = string.Empty, - homePath = string.Empty, - }), "{}"); - - Assert.NotNull(result); - var profile = Assert.Single(store.Profiles); - Assert.Equal("Work", profile.DisplayName); - Assert.Null(profile.SourceOptions.ExecutablePath); - Assert.Null(profile.SourceOptions.HomePath); - } - - [Theory] - [InlineData("{\"name\":\"Work\",\"executablePath\":42}")] - [InlineData("{\"name\":\"Work\",\"homePath\":false}")] - [InlineData("{\"name\":\"Work\",\"homePath\":{}}")] - public void NewProfileFormRejectsNonStringPathValues(string payload) - { - var store = new CodexProfileStore(_environment.PathFor("profiles.json")); - using var page = new NewProfileFormPage(store); - var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); - - var result = form.SubmitForm(payload, "{}"); - - Assert.NotNull(result); - Assert.Empty(store.Profiles); - } -} diff --git a/CodexUsageDock.Tests/CodexUsageTableTests.cs b/CodexUsageDock.Tests/CodexUsageTableTests.cs new file mode 100644 index 0000000..2ebf050 --- /dev/null +++ b/CodexUsageDock.Tests/CodexUsageTableTests.cs @@ -0,0 +1,24 @@ +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CodexUsageTableTests +{ + [Fact] + public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() + { + var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + var quota = new CodexUsageSnapshot(new(100, 300, now.AddHours(-1)), null, null, null, new(0, null), + now.AddHours(-2), UsageDataSource.LastConfirmed, null, + [new("extra", "Extra | quota", new(0, 60, now.AddHours(1)), null)], DefaultBucketId: "codex"); + var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var body = CodexUsageTablePage.Format(view, now, TimeSpan.FromMinutes(1)); + Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); + Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); + Assert.Contains("Not reported", body, StringComparison.Ordinal); + Assert.Contains("0%", body, StringComparison.Ordinal); + Assert.Contains("100%", body, StringComparison.Ordinal); + Assert.Contains("Extra \\| quota", body, StringComparison.Ordinal); + Assert.DoesNotContain("![", body, StringComparison.Ordinal); + } +} diff --git a/CodexUsageDock.Tests/ProviderDockTests.cs b/CodexUsageDock.Tests/ProviderDockTests.cs index f25e50b..de41742 100644 --- a/CodexUsageDock.Tests/ProviderDockTests.cs +++ b/CodexUsageDock.Tests/ProviderDockTests.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Text.Json; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -261,43 +260,6 @@ public void RetainedBandPagesAreClearedWhenTheirBandBecomesInactive() Assert.Empty(provider.GetDockBands()!); } - [Fact] - public void RetainedClaudeBandPageClearsAndNotifiesWhenClaudeIsDisabled() - { - var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - var capture = WriteClaudeCapture(now, 25); - File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( - new Dictionary - { - [EnableClaudeKey] = "true", - [ClaudeBridgePathKey] = capture, - })); - using var service = _environment.CreateService( - _ => Task.FromResult(CodexUsageSnapshot.Loading), - () => CodexUsageSnapshot.Loading, - clock: () => now); - var settings = _environment.CreateSettings(); - using var provider = new CodexUsageDockCommandsProvider(service, settings, _ => { }, () => now); - - var band = FindBand(provider, ClaudeDockId); - var page = Assert.IsAssignableFrom(band.Command); - Assert.Equal(2, page.GetItems().Length); - var emptyNotifications = 0; - page.ItemsChanged += (_, _) => - { - if (page.GetItems().Length == 0) - { - emptyNotifications++; - } - }; - - SubmitSettings(settings, (EnableClaudeKey, "false")); - - Assert.Empty(page.GetItems()); - Assert.True(emptyNotifications > 0); - Assert.Null(provider.GetCommandItem(ClaudeDockId)); - } - [Fact] public async Task QuotaRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() { @@ -390,51 +352,6 @@ Task Read(CancellationToken _) => Assert.Equal(item.Title, cachedTitle); } - [Fact] - public async Task ClaudeRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() - { - var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - var firstCapture = WriteClaudeCapture(now, 25); - var secondCapture = WriteClaudeCapture(now, 35); - File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( - new Dictionary - { - [EnableClaudeKey] = "true", - [ClaudeBridgePathKey] = firstCapture, - })); - var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var service = _environment.CreateService( - _ => pending.Task, - () => CodexUsageSnapshot.Loading, - clock: () => now); - using var provider = new CodexUsageDockCommandsProvider( - service, - _environment.CreateSettings(), - _ => { }, - () => now); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - - var codexBand = FindBand(provider, CombinedDockId); - var claudeBand = FindBand(provider, ClaudeDockId); - var claudeList = Assert.IsAssignableFrom(claudeBand.Command); - var providerInvalidations = 0; - var claudeInvalidations = 0; - provider.ItemsChanged += (_, _) => providerInvalidations++; - claudeList.ItemsChanged += (_, _) => claudeInvalidations++; - - service.ConfigureClaude(false, firstCapture); - service.ConfigureClaude(true, secondCapture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Same(codexBand, FindBand(provider, CombinedDockId)); - Assert.Same(claudeBand, FindBand(provider, ClaudeDockId)); - Assert.Same(claudeBand, provider.GetCommandItem(ClaudeDockId)); - Assert.Equal(0, providerInvalidations); - Assert.True(claudeInvalidations > 0); - - pending.TrySetResult(CodexUsageSnapshot.Loading); - } - private const string CombinedDockId = "nl.mathijs.codexusage.dock"; private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; @@ -444,8 +361,6 @@ public async Task ClaudeRefreshKeepsBandIdentityAndNotifiesOnlyItsBandList() private const string ShowFiveHourLimitKey = "showFiveHourLimit"; private const string ShowWeeklyLimitKey = "showWeeklyLimit"; private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; - private const string EnableClaudeKey = "enableClaude"; - private const string ClaudeBridgePathKey = "claudeBridgePath"; private static readonly string[] AllDockIds = [ CombinedDockId, FiveHourDockId, @@ -464,29 +379,4 @@ private static void SubmitSettings( var payload = values.ToDictionary(pair => pair.Key, pair => pair.Value); page.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(payload), "{}"); } - - private string WriteClaudeCapture(DateTimeOffset now, int fiveHourUsed) - { - var path = _environment.PathFor($"claude-{Guid.NewGuid():N}.json"); - File.WriteAllText(path, JsonSerializer.Serialize(new - { - schemaVersion = 1, - provider = "claude", - observedAtUTC = now.ToString("O", CultureInfo.InvariantCulture), - rate_limits = new - { - five_hour = new - { - used_percentage = fiveHourUsed, - resets_at = now.AddHours(4).ToUnixTimeSeconds(), - }, - seven_day = new - { - used_percentage = 40, - resets_at = now.AddDays(4).ToUnixTimeSeconds(), - }, - }, - })); - return path; - } } diff --git a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs deleted file mode 100644 index 9af260a..0000000 --- a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System.Globalization; -using System.Text.Json.Nodes; -using Microsoft.CommandPalette.Extensions; -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class ProviderPilotIntegrationTests : IDisposable -{ - private readonly TestEnvironment _environment = new(); - private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - public void Dispose() => _environment.Dispose(); - - [Theory] - [InlineData(1, 15, "Stale", "Available")] - [InlineData(15, 1, "Available", "Stale")] - public async Task ChangingOnlyTheRefreshIntervalReclassifiesClaudeAndNotifiesPresentation( - int previousMinutes, int nextMinutes, string before, string after) - { - var capture = WriteCapture(); - using var service = _environment.CreateService( - _ => Task.FromResult(CodexUsageSnapshot.Loading), () => CodexUsageSnapshot.Loading, - clock: () => Now.AddMinutes(6)); - service.SetRefreshInterval(TimeSpan.FromMinutes(previousMinutes)); - service.ConfigureClaude(true, capture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(before, service.GetClaudeUsage().Status.ToString()); - var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - service.ClaudeUpdated += (_, _) => - { - if (service.GetClaudeUsage().Status.ToString() == after) updated.TrySetResult(); - }; - - service.SetRefreshInterval(TimeSpan.FromMinutes(nextMinutes)); - service.ConfigureClaude(true, capture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Equal(after, service.GetClaudeUsage().Status.ToString()); - await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); - } - - [Fact] - public void ApplyingAProfilePersistsPathsAndLabelBeforeTheNextStart() - { - var executable = _environment.PathFor("codex.exe"); - File.WriteAllText(executable, string.Empty); - var source = new CodexSourceOptions(executable, Path.GetDirectoryName(executable)); - var settings = _environment.CreateSettings(); - var changes = 0; - settings.Changed += (_, _) => changes++; - settings.ApplySourceProfile("Local work", source); - var restored = _environment.CreateSettings(); - Assert.Equal(source.ExecutablePath, restored.CodexExecutablePath); - Assert.Equal(source.HomePath, restored.CodexHomePath); - Assert.Equal("Local work", restored.SourceLabel); - Assert.Equal(1, changes); - } - - [Fact] - public async Task ClaudeCaptureCompletesIndependentlyOfABlockedCodexReadAndDisablingClearsIt() - { - var capture = WriteCapture(); - var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var service = _environment.CreateService(_ => pending.Task, () => CodexUsageSnapshot.Loading, clock: () => Now); - var codexRead = service.RefreshAsync(); - service.ConfigureClaude(true, capture); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.False(codexRead.IsCompleted); - Assert.Equal(ClaudeUsageReadStatus.Available, service.GetClaudeUsage().Status); - Assert.Equal(75, service.GetClaudeUsage().Primary!.RemainingPercent); - var changed = JsonNode.Parse(File.ReadAllText(capture))!.AsObject(); - changed["rate_limits"]!["five_hour"]!["used_percentage"] = 50; - File.WriteAllText(capture, changed.ToJsonString()); - Assert.Same(codexRead, service.RefreshAsync()); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(50, service.GetClaudeUsage().Primary!.RemainingPercent); - Assert.False(codexRead.IsCompleted); - service.ConfigureClaude(false, capture); - Assert.Null(service.GetClaudeUsage().Primary); - Assert.Contains("disabled", service.GetClaudeUsage().Message, StringComparison.Ordinal); - pending.SetResult(CodexUsageSnapshot.Loading); - await codexRead; - } - - [Fact] - public async Task ClaudeDockHasItsOwnRestorableBandAndDoesNotAlterCodexQuotas() - { - var capture = WriteCapture(); - File.WriteAllText(_environment.PathFor("settings.json"), new JsonObject - { ["enableClaude"] = "true", ["claudeBridgePath"] = capture }.ToJsonString()); - var quota = new CodexUsageSnapshot(new(10, 300, Now.AddHours(4)), null, null, null, null, - Now, UsageDataSource.AppServer, null, AccountKey: "a"); - using var service = _environment.CreateService(_ => Task.FromResult(quota), () => quota, clock: () => Now); - using var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings(), _ => { }, () => Now); - await service.RefreshAsync(); - await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); - Assert.Equal(90, service.Current.Primary!.RemainingPercent); - Assert.Equal(2, provider.GetDockBands()!.Length); - var band = provider.GetCommandItem("nl.mathijs.codexusage.dock.claude")!; - var items = Assert.IsAssignableFrom(band.Command).GetItems(); - Assert.Equal(2, items.Length); - Assert.Equal("Claude 5h 75%", items[0].Title); - Assert.Equal("Claude week 60%", items[1].Title); - Assert.Contains(provider.TopLevelCommands(), item => item.Command.Id == "nl.mathijs.codexusage.table"); - } - - [Fact] - public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() - { - var quota = new CodexUsageSnapshot(new(100, 300, Now.AddHours(-1)), null, null, null, new(0, null), - Now.AddHours(-2), UsageDataSource.LastConfirmed, null, - [new("extra", "Extra | quota", new(0, 60, Now.AddHours(1)), null)], DefaultBucketId: "codex"); - var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); - var body = CodexUsageTablePage.Format(view, Now, TimeSpan.FromMinutes(1)); - Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); - Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); - Assert.Contains("Not reported", body, StringComparison.Ordinal); - Assert.Contains("0%", body, StringComparison.Ordinal); - Assert.Contains("100%", body, StringComparison.Ordinal); - Assert.Contains("Extra \\| quota", body, StringComparison.Ordinal); - Assert.DoesNotContain("![", body, StringComparison.Ordinal); - } - - private string WriteCapture() - { - var path = _environment.PathFor("claude.json"); - File.WriteAllText(path, new JsonObject - { - ["schemaVersion"] = 1, - ["provider"] = "claude", - ["observedAtUTC"] = Now.ToString("O", CultureInfo.InvariantCulture), - ["rate_limits"] = new JsonObject - { - ["five_hour"] = new JsonObject { ["used_percentage"] = 25, ["resets_at"] = Now.AddHours(4).ToUnixTimeSeconds() }, - ["seven_day"] = new JsonObject { ["used_percentage"] = 40, ["resets_at"] = Now.AddDays(4).ToUnixTimeSeconds() }, - }, - }.ToJsonString()); - return path; - } -} diff --git a/CodexUsageDock.Tests/UsagePreferenceTests.cs b/CodexUsageDock.Tests/UsagePreferenceTests.cs index 214a71c..737f1be 100644 --- a/CodexUsageDock.Tests/UsagePreferenceTests.cs +++ b/CodexUsageDock.Tests/UsagePreferenceTests.cs @@ -19,8 +19,6 @@ public void NewPreferencesUseSafeDefaults() Assert.False(settings.CompactDock); Assert.False(settings.SeparateDockItems); Assert.True(settings.ShowAccountActivity); - Assert.Equal(string.Empty, settings.CodexExecutablePath); - Assert.Equal(string.Empty, settings.CodexHomePath); } [Fact] @@ -33,16 +31,12 @@ public void UsagePreferencesPersistAcrossNewPages() ["compactDock"] = "true", ["separateDockItems"] = "true", ["showAccountActivity"] = "false", - ["codexExecutablePath"] = "C:/Tools/codex.cmd", - ["codexHomePath"] = "C:/Users/test/.codex", }); Assert.True(first.EnableUsageAlerts); Assert.True(first.CompactDock); Assert.True(first.SeparateDockItems); Assert.False(first.ShowAccountActivity); - Assert.Equal("C:/Tools/codex.cmd", first.CodexExecutablePath); - Assert.Equal("C:/Users/test/.codex", first.CodexHomePath); var restarted = _environment.CreateSettings(); @@ -50,14 +44,11 @@ public void UsagePreferencesPersistAcrossNewPages() Assert.True(restarted.CompactDock); Assert.True(restarted.SeparateDockItems); Assert.False(restarted.ShowAccountActivity); - Assert.Equal("C:/Tools/codex.cmd", restarted.CodexExecutablePath); - Assert.Equal("C:/Users/test/.codex", restarted.CodexHomePath); } [Fact] - public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() + public void SettingsLoadKeepsValidValuesAndRejectsInvalidChoices() { - var validBoundaryPath = new string('a', 1024); File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( new Dictionary { @@ -65,8 +56,6 @@ public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() ["compactDock"] = "true", ["separateDockItems"] = "not-a-boolean", ["showAccountActivity"] = "false", - ["codexExecutablePath"] = validBoundaryPath, - ["codexHomePath"] = new string('b', 1025), })); var settings = _environment.CreateSettings(); @@ -75,20 +64,6 @@ public void SettingsLoadKeepsValidValuesAndRejectsUnsafePathValues() Assert.True(settings.CompactDock); Assert.False(settings.SeparateDockItems); Assert.False(settings.ShowAccountActivity); - Assert.Equal(validBoundaryPath, settings.CodexExecutablePath); - Assert.Contains("Invalid source path", settings.CodexHomePath, StringComparison.Ordinal); - - File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( - new Dictionary - { - ["codexExecutablePath"] = "C:/Codex\u0001/codex.exe", - ["codexHomePath"] = "C:/Users/test/.codex", - })); - - var controlCharacterSettings = _environment.CreateSettings(); - - Assert.Contains("Invalid source path", controlCharacterSettings.CodexExecutablePath, StringComparison.Ordinal); - Assert.Equal("C:/Users/test/.codex", controlCharacterSettings.CodexHomePath); } [Theory] diff --git a/CodexUsageDock/ClaudeUsageReader.cs b/CodexUsageDock/ClaudeUsageReader.cs deleted file mode 100644 index bd6d452..0000000 --- a/CodexUsageDock/ClaudeUsageReader.cs +++ /dev/null @@ -1,335 +0,0 @@ -using System.Globalization; -using System.Text; -using System.Text.Json; - -namespace CodexUsageDock; - -internal enum ClaudeUsageReadStatus -{ - Available, - Partial, - Unavailable, - Stale, - Future, -} - -internal sealed record ClaudeUsageWindow( - double UsedPercent, - int WindowMinutes, - DateTimeOffset ResetsAt) -{ - internal double RemainingPercent => Math.Clamp(100 - UsedPercent, 0, 100); -} - -internal sealed record ClaudeUsageSnapshot( - ClaudeUsageWindow? Primary, - ClaudeUsageWindow? Weekly, - DateTimeOffset ObservedAt, - ClaudeUsageReadStatus Status, - string Message) -{ - internal bool IsAvailable => Status is ClaudeUsageReadStatus.Available or ClaudeUsageReadStatus.Partial; - - internal static ClaudeUsageSnapshot Unavailable(string message) => - new(null, null, DateTimeOffset.MinValue, ClaudeUsageReadStatus.Unavailable, message); -} - -internal static class ClaudeUsageReader -{ - internal const int MaximumFileBytes = 64 * 1024; - private const int PrimaryWindowMinutes = 5 * 60; - private const int WeeklyWindowMinutes = 7 * 24 * 60; - - internal static ClaudeUsageSnapshot Read( - string path, - DateTimeOffset now, - TimeSpan refreshInterval, - CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); - if (string.IsNullOrWhiteSpace(path) || !IsFullyQualifiedPath(path)) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture path must be a fully qualified file path."); - } - - byte[] bytes; - try - { - bytes = ReadBounded(path, cancellationToken); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - throw; - } - catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException - or NotSupportedException or PathTooLongException or InvalidDataException) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture could not be read."); - } - - JsonDocument document; - try - { - var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); - var json = encoding.GetString(bytes); - document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 }); - } - catch (Exception error) when (error is JsonException or DecoderFallbackException or ArgumentException) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture is not valid JSON."); - } - - using (document) - { - return Parse(document.RootElement, now, refreshInterval); - } - } - - internal static ClaudeUsageSnapshot Read(string path, DateTimeOffset now) => - Read(path, now, UsageFreshness.MinimumAge); - - internal static ClaudeUsageSnapshot Read(string path) => - Read(path, DateTimeOffset.UtcNow, UsageFreshness.MinimumAge); - - private static ClaudeUsageSnapshot Parse( - JsonElement root, - DateTimeOffset now, - TimeSpan refreshInterval) - { - if (root.ValueKind != JsonValueKind.Object - || !TryGetSchemaVersion(root, out var schemaVersion) - || schemaVersion != 1 - || !TryGetString(root, "provider", out var provider) - || !string.Equals(provider, "claude", StringComparison.OrdinalIgnoreCase) - || !TryGetObservedAt(root, out var observedAt)) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture has an unsupported schema or provider."); - } - - if (!TryGetObject(root, "rate_limits", out var rateLimits)) - { - return ClaudeUsageSnapshot.Unavailable("Claude usage capture has no rate-limit data."); - } - - var primary = ParseWindow(rateLimits, "five_hour", PrimaryWindowMinutes, now); - var weekly = ParseWindow(rateLimits, "seven_day", WeeklyWindowMinutes, now); - var freshness = UsageFreshness.Classify(observedAt, now, refreshInterval); - if (freshness == UsageFreshnessState.Future) - { - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Future, - "Claude usage capture timestamp is in the future."); - } - - if (freshness == UsageFreshnessState.Stale) - { - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Stale, - "Claude usage capture is stale."); - } - - if (freshness != UsageFreshnessState.Fresh) - { - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Unavailable, - "Claude usage capture freshness is unavailable."); - } - - var validCount = (primary.Window is null ? 0 : 1) + (weekly.Window is null ? 0 : 1); - var status = validCount switch - { - 2 => ClaudeUsageReadStatus.Available, - 1 => ClaudeUsageReadStatus.Partial, - _ => ClaudeUsageReadStatus.Unavailable, - }; - var message = validCount switch - { - 2 => "Claude rate-limit windows are available.", - 1 => "One Claude rate-limit window is unavailable; windows remain independent.", - _ => "No valid Claude rate-limit windows were provided.", - }; - return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, status, message); - } - - private static WindowParseResult ParseWindow( - JsonElement parent, - string propertyName, - int windowMinutes, - DateTimeOffset now) - { - if (!parent.TryGetProperty(propertyName, out var value) - || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) - { - return new WindowParseResult(null); - } - - if (value.ValueKind != JsonValueKind.Object - || !TryGetNumber(value, "used_percentage", out var usedPercent) - || !TryGetReset(value, "resets_at", out var resetsAt)) - { - return new WindowParseResult(null); - } - - var rateWindow = new RateLimitWindow(usedPercent, windowMinutes, resetsAt); - return UsageFreshness.IsValidWindow(rateWindow, now) - ? new WindowParseResult(new ClaudeUsageWindow(usedPercent, windowMinutes, resetsAt.ToUniversalTime())) - : new WindowParseResult(null); - } - - private static bool TryGetSchemaVersion(JsonElement root, out int version) - { - version = 0; - return root.TryGetProperty("schemaVersion", out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetInt32(out version); - } - - private static bool TryGetString(JsonElement parent, string propertyName, out string value) - { - value = string.Empty; - return parent.TryGetProperty(propertyName, out var element) - && element.ValueKind == JsonValueKind.String - && (value = element.GetString() ?? string.Empty).Length > 0; - } - - private static bool TryGetObservedAt(JsonElement root, out DateTimeOffset observedAt) - { - observedAt = default; - if (!TryGetString(root, "observedAtUTC", out var value) - || !DateTimeOffset.TryParse( - value, - CultureInfo.InvariantCulture, - DateTimeStyles.RoundtripKind, - out observedAt) - || !HasExplicitOffset(value)) - { - observedAt = default; - return false; - } - - observedAt = observedAt.ToUniversalTime(); - return true; - } - - private static bool TryGetObject(JsonElement parent, string propertyName, out JsonElement value) - { - value = default; - return parent.ValueKind == JsonValueKind.Object - && parent.TryGetProperty(propertyName, out value) - && value.ValueKind == JsonValueKind.Object; - } - - private static bool TryGetNumber( - JsonElement parent, - string propertyName, - out double number) - { - number = 0; - if (parent.TryGetProperty(propertyName, out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetDouble(out number) - && double.IsFinite(number) - && number is >= 0 and <= 100) - { - return true; - } - - number = 0; - return false; - } - - private static bool TryGetReset( - JsonElement parent, - string propertyName, - out DateTimeOffset resetsAt) - { - resetsAt = default; - if (parent.TryGetProperty(propertyName, out var value) - && value.ValueKind == JsonValueKind.Number - && value.TryGetInt64(out var seconds)) - { - try - { - resetsAt = DateTimeOffset.FromUnixTimeSeconds(seconds); - return true; - } - catch (ArgumentOutOfRangeException) - { - return false; - } - } - - return false; - } - - private static byte[] ReadBounded(string path, CancellationToken cancellationToken) - { - using var stream = new FileStream( - path, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite | FileShare.Delete, - bufferSize: 8192, - options: FileOptions.SequentialScan); - if (stream.Length > MaximumFileBytes) - { - throw new InvalidDataException("Claude usage capture is oversized."); - } - - using var memory = new MemoryStream((int)stream.Length); - var buffer = new byte[8192]; - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - var read = stream.Read(buffer, 0, buffer.Length); - if (read == 0) - { - break; - } - - if (memory.Length + read > MaximumFileBytes) - { - throw new InvalidDataException("Claude usage capture is oversized."); - } - - memory.Write(buffer, 0, read); - } - - return memory.ToArray(); - } - - private static bool IsFullyQualifiedPath(string path) - { - try - { - return Path.IsPathFullyQualified(path); - } - catch (Exception error) when (error is ArgumentException or NotSupportedException or PathTooLongException) - { - return false; - } - } - - private static bool HasExplicitOffset(string value) - { - var text = value.Trim(); - if (text.EndsWith("Z", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - var timeSeparator = text.IndexOf('T'); - if (timeSeparator < 0) - { - timeSeparator = text.IndexOf(' '); - } - - if (timeSeparator < 0 || timeSeparator == text.Length - 1) - { - return false; - } - - var time = text[(timeSeparator + 1)..]; - return time.Contains('+', StringComparison.Ordinal) - || time.LastIndexOf('-') > 0; - } - - private readonly record struct WindowParseResult(ClaudeUsageWindow? Window); -} diff --git a/CodexUsageDock/CodexProfileStore.cs b/CodexUsageDock/CodexProfileStore.cs deleted file mode 100644 index 7fcd52b..0000000 --- a/CodexUsageDock/CodexProfileStore.cs +++ /dev/null @@ -1,348 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace CodexUsageDock; - -internal sealed record CodexProfile( - Guid Id, - string DisplayName, - CodexSourceOptions SourceOptions); - -internal sealed class CodexProfileStore -{ - internal const int SchemaVersion = 1; - internal const int MaximumProfiles = 8; - internal const int MaximumDisplayNameLength = 40; - internal const int MaximumPathLength = 1024; - internal const int MaximumDocumentBytes = 512 * 1024; - - private const string LoadErrorMessage = "Saved Codex profiles could not be read. No profiles were loaded."; - private const string SaveErrorMessage = "The Codex profile could not be saved. Try again."; - private const string InvalidNameMessage = "Profile names must contain 1 to 40 characters without control characters."; - private const string InvalidSourceMessage = "The Codex executable or home path is invalid or unavailable."; - private const string MaximumProfilesMessage = "You can save up to eight Codex profiles."; - private const string ProfileNotFoundMessage = "The Codex profile was not found."; - private const string InvalidProfileIdMessage = "The Codex profile identifier is invalid."; - private readonly object _gate = new(); - private readonly string _path; - private List _profiles; - private string? _storageError; - - internal CodexProfileStore(string path) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - _path = Path.GetFullPath(path); - _profiles = Load(); - } - - internal static CodexProfileStore CreateDefault() => - new(LocalStorage.GetPath("profiles.json")); - - internal IReadOnlyList Profiles - { - get - { - lock (_gate) - { - return _profiles.ToArray(); - } - } - } - - internal string? StorageError - { - get - { - lock (_gate) - { - return _storageError; - } - } - } - - internal bool TryGet(Guid id, out CodexProfile? profile) - { - lock (_gate) - { - profile = id != Guid.Empty - ? _profiles.FirstOrDefault(candidate => candidate.Id == id) - : null; - return profile is not null; - } - } - - internal bool TryUpsert( - string? displayName, - string? executablePath, - string? homePath, - out CodexProfile? profile, - out string? error) - { - profile = null; - error = null; - if (!TryNormalizeDisplayName(displayName, out var normalizedName)) - { - error = InvalidNameMessage; - return false; - } - - if (!CodexSourceOptions.TryCreate(executablePath, homePath, out var options, out _)) - { - error = InvalidSourceMessage; - return false; - } - - lock (_gate) - { - var existingIndex = _profiles.FindIndex(candidate => - string.Equals(candidate.DisplayName, normalizedName, StringComparison.OrdinalIgnoreCase)); - if (existingIndex < 0 && _profiles.Count >= MaximumProfiles) - { - error = MaximumProfilesMessage; - return false; - } - - var candidate = new CodexProfile( - existingIndex >= 0 ? _profiles[existingIndex].Id : Guid.NewGuid(), - normalizedName, - options); - var updated = _profiles.ToList(); - if (existingIndex >= 0) - { - updated[existingIndex] = candidate; - } - else - { - updated.Add(candidate); - } - - if (!TrySave(updated, out error)) - { - return false; - } - - _profiles = updated; - profile = candidate; - return true; - } - } - - internal bool TryRemove(Guid id, out string? error) - { - error = null; - if (id == Guid.Empty) - { - error = InvalidProfileIdMessage; - return false; - } - - lock (_gate) - { - var existingIndex = _profiles.FindIndex(candidate => candidate.Id == id); - if (existingIndex < 0) - { - error = ProfileNotFoundMessage; - return false; - } - - var updated = _profiles.ToList(); - updated.RemoveAt(existingIndex); - if (!TrySave(updated, out error)) - { - return false; - } - - _profiles = updated; - return true; - } - } - - private List Load() - { - try - { - if (!File.Exists(_path)) - { - return []; - } - - var fileInfo = new FileInfo(_path); - if (fileInfo.Length > MaximumDocumentBytes) - { - SetStorageError(LoadErrorMessage); - return []; - } - - var document = JsonSerializer.Deserialize( - File.ReadAllText(_path), - CodexProfileStoreJsonContext.Default.CodexProfileDocument); - if (document is null || document.SchemaVersion != SchemaVersion || document.Profiles is null) - { - SetStorageError(LoadErrorMessage); - return []; - } - - var profiles = new List(Math.Min(document.Profiles.Length, MaximumProfiles)); - var ids = new HashSet(); - var names = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var entry in document.Profiles) - { - if (entry is null - || !Guid.TryParseExact(entry.Id, "N", out var id) - || id == Guid.Empty - || !TryNormalizeDisplayName(entry.DisplayName, out var displayName) - || !TryNormalizePathShape(entry.ExecutablePath, executable: true, out var executablePath) - || !TryNormalizePathShape(entry.HomePath, executable: false, out var homePath) - || !ids.Add(id) - || !names.Add(displayName)) - { - continue; - } - - profiles.Add(new CodexProfile(id, displayName, new(executablePath, homePath))); - if (profiles.Count == MaximumProfiles) - { - break; - } - } - - return profiles; - } - catch (Exception exception) when (exception is IOException - or UnauthorizedAccessException - or JsonException - or NotSupportedException - or InvalidOperationException - or ArgumentException) - { - LocalStorage.TraceFailure("load Codex profiles", exception); - SetStorageError(LoadErrorMessage); - return []; - } - } - - private bool TrySave(IReadOnlyList profiles, out string? error) - { - error = null; - try - { - var document = new CodexProfileDocument( - SchemaVersion, - profiles.Select(profile => new CodexProfileEntry( - profile.Id.ToString("N"), - profile.DisplayName, - profile.SourceOptions.ExecutablePath, - profile.SourceOptions.HomePath)).ToArray()); - var content = JsonSerializer.Serialize( - document, - CodexProfileStoreJsonContext.Default.CodexProfileDocument); - if (!LocalStorage.TryWrite(_path, content)) - { - error = SaveErrorMessage; - SetStorageError(error); - return false; - } - - SetStorageError(null); - return true; - } - catch (Exception exception) when (exception is IOException - or UnauthorizedAccessException - or JsonException - or NotSupportedException - or InvalidOperationException - or ArgumentException) - { - LocalStorage.TraceFailure("save Codex profiles", exception); - error = SaveErrorMessage; - SetStorageError(error); - return false; - } - } - - private static bool TryNormalizeDisplayName(string? value, out string normalized) - { - normalized = string.Empty; - if (value is null || value.Any(char.IsControl)) - { - return false; - } - - normalized = value.Trim(); - return normalized.Length is >= 1 and <= MaximumDisplayNameLength; - } - - // Loading deliberately checks only path shape. A temporarily unavailable - // WSL or network directory remains selectable until use-time validation. - private static bool TryNormalizePathShape(string? value, bool executable, out string? normalized) - { - normalized = null; - if (string.IsNullOrWhiteSpace(value)) - { - return true; - } - - var trimmed = value.Trim(); - if (trimmed.Length > MaximumPathLength - || trimmed.Any(char.IsControl) - || !Path.IsPathFullyQualified(trimmed)) - { - return false; - } - - try - { - normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trimmed)); - if (normalized.Length == 0) - { - return false; - } - - if (executable) - { - var fileName = Path.GetFileName(normalized); - if (!(fileName.Equals("codex.exe", StringComparison.OrdinalIgnoreCase) - || fileName.Equals("codex.cmd", StringComparison.OrdinalIgnoreCase)) - || CodexAppServerReader.IsWindowsAppsPath(normalized)) - { - normalized = null; - return false; - } - } - - return true; - } - catch (Exception exception) when (exception is ArgumentException - or NotSupportedException - or PathTooLongException) - { - normalized = null; - return false; - } - } - - private void SetStorageError(string? error) - { - lock (_gate) - { - _storageError = error; - } - } -} - -internal sealed record CodexProfileDocument( - [property: JsonPropertyName("schemaVersion")] int SchemaVersion, - [property: JsonPropertyName("profiles")] CodexProfileEntry?[]? Profiles); - -internal sealed record CodexProfileEntry( - [property: JsonPropertyName("id")] string? Id, - [property: JsonPropertyName("displayName")] string? DisplayName, - [property: JsonPropertyName("executablePath")] string? ExecutablePath, - [property: JsonPropertyName("homePath")] string? HomePath); - -[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] -[JsonSerializable(typeof(CodexProfileDocument))] -[JsonSerializable(typeof(CodexProfileEntry))] -internal sealed partial class CodexProfileStoreJsonContext : JsonSerializerContext -{ -} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 651c30e..a070adf 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -18,11 +18,6 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexHistoryPage _history; private readonly CodexActionsPage _actions; private readonly CodexUsageTablePage _textUsage; - private readonly CodexProfilesPage _profiles; - private readonly ClaudeUsagePage _claude; - private readonly UsageDockListItem _claudeFiveHour; - private readonly UsageDockListItem _claudeWeekly; - private readonly object _claudePresentationLock = new(); private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; private readonly Func _clock; @@ -30,13 +25,11 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; - private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; private readonly object _dockLayoutLock = new(); private readonly UsageDockBand _combinedBand; private readonly UsageDockBand _fiveHourBand; private readonly UsageDockBand _weeklyBand; private readonly UsageDockBand _creditsBand; - private readonly UsageDockBand _claudeBand; private readonly UsageDockBand[] _allDockBands; private ICommandItem[] _dockBands = []; @@ -60,10 +53,8 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); - ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); - _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; @@ -72,11 +63,6 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _history = new CodexHistoryPage(_usage); _actions = new CodexActionsPage(_usage); _textUsage = new CodexUsageTablePage(_usage, _clock); - _profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath)); - _profiles.ProfileSelected += OnProfileSelected; - _claude = new ClaudeUsagePage(_usage); - _claudeFiveHour = new UsageDockListItem(_claude); - _claudeWeekly = new UsageDockListItem(_claude); _details.Commands = [.. _details.Commands, new CommandContextItem(_textUsage) { Title = "Read usage in text" }]; _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); @@ -85,8 +71,7 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _fiveHourBand = new(FiveHourDockId, "Codex five-hour usage"); _weeklyBand = new(WeeklyDockId, "Codex weekly usage"); _creditsBand = new(CreditsDockId, "Codex resets and credits"); - _claudeBand = new(ClaudeDockId, "Claude usage"); - _allDockBands = [_combinedBand, _fiveHourBand, _weeklyBand, _creditsBand, _claudeBand]; + _allDockBands = [_combinedBand, _fiveHourBand, _weeklyBand, _creditsBand]; _commands = [ @@ -115,15 +100,11 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quota tables and measured values without charts or color cues" }, - new CommandItem(_profiles) { Title = "Codex source profiles", Subtitle = "Save and select named local or Windows-accessible WSL sources" }, - new CommandItem(_claude) { Title = "Claude usage pilot", Subtitle = "Optional local statusline capture; independent Claude quotas" }, ]; _settings.Changed += OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested += OnClearAdaptiveHistoryRequested; _usage.Updated += OnUsageUpdated; - _usage.ClaudeUpdated += OnClaudeUpdated; - RefreshClaudeItems(); UpdateDockLayout(); _usage.Start(); @@ -148,51 +129,16 @@ private void OnSettingsChanged(object? sender, EventArgs e) } _usage.SetRefreshInterval(_settings.RefreshInterval); _usage.SetAdaptiveWeeklyForecastEnabled(_settings.UseAdaptiveWeeklyForecast); - var sourceChanged = ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); - _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); _planner.Refresh(); _history.Refresh(); UpdateDockLayout(); - if (sourceChanged) _ = _usage.RefreshAsync(); } - private bool ApplySourceSettings() - { - _ = CodexSourceOptions.TryCreate(_settings.CodexExecutablePath, _settings.CodexHomePath, out var options, out var error); - if (error is not null) _settings.ShowOperationStatus(error); - return _usage.ConfigureSource(options, error); - } - - private void OnProfileSelected(object? sender, CodexProfileSelectedEventArgs args) => _settings.ApplySourceProfile(args.Name, args.Options); - - private void OnClaudeUpdated(object? sender, EventArgs args) - { - RefreshClaudeItems(); - if (_claudeBand.HasItems) _claudeBand.NotifyItemsChanged(); - } - - private void RefreshClaudeItems() - { - lock (_claudePresentationLock) - { - var snapshot = _usage.GetClaudeUsage(); - var now = _clock(); - var fresh = snapshot.IsAvailable && UsageFreshness.IsFresh(snapshot.ObservedAt, now, _usage.RefreshInterval); - _claudeFiveHour.Title = "Claude 5h " + FormatClaudeRemaining(snapshot.Primary, fresh, now); - _claudeWeekly.Title = "Claude week " + FormatClaudeRemaining(snapshot.Weekly, fresh, now); - _claudeFiveHour.Subtitle = snapshot.Message; - _claudeWeekly.Subtitle = snapshot.Message; - } - } - - private static string FormatClaudeRemaining(ClaudeUsageWindow? window, bool fresh, DateTimeOffset now) => fresh && window is not null && window.ResetsAt > now - ? window.RemainingPercent.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + "%" : "--"; - private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) { var cleared = _usage.ClearAdaptiveWeeklyHistory(); @@ -215,7 +161,7 @@ private void OnUsageUpdated(object? sender, EventArgs e) foreach (var band in Volatile.Read(ref _dockBands).OfType()) { - if (!ReferenceEquals(band, _claudeBand)) band.NotifyItemsChanged(); + band.NotifyItemsChanged(); } var alerts = _alerts.Evaluate(_usage.GetPresentation(), _clock(), _usage.RefreshInterval, new UsageAlertOptions(Enabled: _settings.EnableUsageAlerts)); @@ -238,7 +184,6 @@ private void UpdateDockLayout() Publish(_fiveHourBand, separate && _settings.ShowFiveHourLimit ? [_fiveHour] : []); Publish(_weeklyBand, separate && _settings.ShowWeeklyLimit ? [_weekly] : []); Publish(_creditsBand, separate && _settings.ShowResetsAndCredits ? [_resetsAndCredits] : []); - Publish(_claudeBand, _settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : []); ICommandItem[] bands = _allDockBands.Where(band => band.HasItems).ToArray(); catalogChanged = !Volatile.Read(ref _dockBands).SequenceEqual(bands); Volatile.Write(ref _dockBands, bands); @@ -280,8 +225,6 @@ public override void Dispose() _settings.Changed -= OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested -= OnClearAdaptiveHistoryRequested; _usage.Updated -= OnUsageUpdated; - _usage.ClaudeUpdated -= OnClaudeUpdated; - _profiles.ProfileSelected -= OnProfileSelected; _fiveHour.Dispose(); _weekly.Dispose(); _resetsAndCredits.Dispose(); @@ -292,8 +235,6 @@ public override void Dispose() _history.Dispose(); _actions.Dispose(); _textUsage.Dispose(); - _profiles.Dispose(); - _claude.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.Claude.cs b/CodexUsageDock/CodexUsageService.Claude.cs deleted file mode 100644 index 5c1d051..0000000 --- a/CodexUsageDock/CodexUsageService.Claude.cs +++ /dev/null @@ -1,72 +0,0 @@ -namespace CodexUsageDock; - -internal sealed partial class CodexUsageService -{ - private bool _claudeEnabled; - private string _claudePath = string.Empty; - private TimeSpan _claudeRefreshInterval; - private long _claudeGeneration; - private Task? _claudeReadTask; - private ClaudeUsageSnapshot _claudeUsage = ClaudeUsageSnapshot.Unavailable("The Claude pilot is disabled."); - internal event EventHandler? ClaudeUpdated; - - internal ClaudeUsageSnapshot GetClaudeUsage() { lock (_refreshStateLock) { return _claudeUsage; } } - internal Task ClaudeRefreshTask { get { lock (_refreshStateLock) { return _claudeReadTask ?? Task.CompletedTask; } } } - - internal void ConfigureClaude(bool enabled, string path) - { - lock (_refreshStateLock) - { - var interval = RefreshInterval; - if (_disposed || _claudeEnabled == enabled && _claudePath == path && _claudeRefreshInterval == interval) return; - _claudeEnabled = enabled; - _claudePath = path; - _claudeRefreshInterval = interval; - _claudeGeneration++; - _claudeUsage = ClaudeUsageSnapshot.Unavailable(enabled ? "Waiting for a local Claude usage capture." : "The Claude pilot is disabled."); - } - RaiseClaudeUpdated(); - StartClaudeRefresh(); - } - - private void StartClaudeRefresh() - { - lock (_refreshStateLock) - { - if (_disposed || !_claudeEnabled || _claudeReadTask is { IsCompleted: false }) return; - var path = _claudePath; - var generation = _claudeGeneration; - var interval = RefreshInterval; - var cancellationToken = _lifetimeCancellation.Token; - _claudeReadTask = Task.Run(() => - { - ClaudeUsageSnapshot result; - try { result = ClaudeUsageReader.Read(path, _clock(), interval, cancellationToken); } - catch (Exception error) - { - if (error is not OperationCanceledException) LocalStorage.TraceFailure("read Claude capture", error); - result = ClaudeUsageSnapshot.Unavailable("The Claude usage capture could not be read."); - } - lock (_refreshStateLock) - { - if (!_disposed && generation == _claudeGeneration && _claudeEnabled) _claudeUsage = result; - } - RaiseClaudeUpdated(); - bool restart; - lock (_refreshStateLock) - { - _claudeReadTask = null; - restart = !_disposed && generation != _claudeGeneration && _claudeEnabled; - } - if (restart) StartClaudeRefresh(); - }); - } - } - - private void RaiseClaudeUpdated() - { - lock (_refreshStateLock) { if (_disposed) return; } - try { ClaudeUpdated?.Invoke(this, EventArgs.Empty); } - catch (Exception error) { LocalStorage.TraceFailure("update Claude presentation", error); } - } -} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index f9f5f32..46476ea 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -261,7 +261,6 @@ internal string? HistoryStorageError public Task RefreshAsync() { - StartClaudeRefresh(); TaskCompletionSource completion; CancellationToken cancellationToken; CodexSourceOptions options; @@ -684,8 +683,7 @@ public void Dispose() _disposed = true; refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask, - (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask, - _claudeReadTask ?? Task.CompletedTask); + (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask); } _timer.Stop(); diff --git a/CodexUsageDock/Pages/ClaudeUsagePage.cs b/CodexUsageDock/Pages/ClaudeUsagePage.cs deleted file mode 100644 index c7b5b81..0000000 --- a/CodexUsageDock/Pages/ClaudeUsagePage.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.Globalization; -using System.Text; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace CodexUsageDock; - -internal sealed partial class ClaudeUsagePage : ContentPage, IDisposable -{ - private readonly CodexUsageService _service; - private readonly object _gate = new(); - private MarkdownContent _content = new(string.Empty); - private bool _disposed; - internal ClaudeUsagePage(CodexUsageService service) - { - _service = service; - Id = "nl.mathijs.codexusage.claude"; - Name = "Open"; - Title = "Claude usage pilot"; - Icon = new IconInfo("\uE943"); - service.ClaudeUpdated += OnUpdated; - Refresh(); - } - - public override IContent[] GetContent() { lock (_gate) { return [_content]; } } - - internal static string Format(ClaudeUsageSnapshot snapshot) - { - var body = new StringBuilder("# Claude usage pilot\n\n").Append(snapshot.Message).Append("\n\n") - .Append("These independent Claude quotas come from your explicitly selected local statusline capture. ") - .Append("The bridge does not verify the Claude account and its percentages are never added to Codex usage.\n\n"); - if (snapshot.ObservedAt != DateTimeOffset.MinValue) - body.Append("Observed UTC: ").Append(snapshot.ObservedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) - .Append(". Status: ").Append(snapshot.Status).Append(".\n\n"); - body.Append("| Claude window | Remaining at observation | Reset UTC |\n| --- | ---: | --- |\n"); - AppendWindow(body, "Five-hour", snapshot.Primary); - AppendWindow(body, "Seven-day", snapshot.Weekly); - return body.Append("\nSetup: use the optional capture script described in the repository README, select its output file in settings, ") - .Append("then enable the pilot. The extension does not change Claude configuration or an existing statusline. ") - .Append("Missing or expired windows remain unavailable until Claude emits a new capture.").ToString(); - } - - private static void AppendWindow(StringBuilder body, string name, ClaudeUsageWindow? window) - { - body.Append("| ").Append(name).Append(" | ") - .Append(window is null ? "Not reported" : window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%") - .Append(" | ").Append(window?.ResetsAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "Not reported").Append(" |\n"); - } - - private void Refresh() - { - lock (_gate) - { - if (_disposed) return; - var body = Format(_service.GetClaudeUsage()); - _content = new MarkdownContent(body); - Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh captures" }, - new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy Claude usage" }]; - } - RaiseItemsChanged(0); - } - private void OnUpdated(object? sender, EventArgs args) => Refresh(); - public void Dispose() { lock (_gate) { _disposed = true; _service.ClaudeUpdated -= OnUpdated; } } -} diff --git a/CodexUsageDock/Pages/CodexProfilesPage.cs b/CodexUsageDock/Pages/CodexProfilesPage.cs deleted file mode 100644 index b89b26e..0000000 --- a/CodexUsageDock/Pages/CodexProfilesPage.cs +++ /dev/null @@ -1,356 +0,0 @@ -using System.Text.Json; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Microsoft.CmdPal.Common.Commands; - -namespace CodexUsageDock; - -internal sealed class CodexProfileSelectedEventArgs : EventArgs -{ - internal CodexProfileSelectedEventArgs(string name, CodexSourceOptions options) - { - Name = name; - Options = options; - } - - internal string Name { get; } - - internal CodexSourceOptions Options { get; } -} - -internal sealed partial class CodexProfilesPage : ListPage, IDisposable -{ - private readonly object _gate = new(); - private readonly CodexProfileStore _store; - private readonly NewProfileFormPage _newProfilePage; - private bool _disposed; - - internal CodexProfilesPage(CodexProfileStore store) - { - _store = store; - _newProfilePage = new NewProfileFormPage(store, RefreshItems); - Id = "nl.mathijs.codexusage.profiles"; - Name = "Open"; - Title = "Codex source profiles"; - Icon = new IconInfo("\uE77B"); - PlaceholderText = "Search saved profiles"; - } - - internal event EventHandler? ProfileSelected; - - public override IListItem[] GetItems() - { - lock (_gate) - { - if (_disposed) - { - return []; - } - } - - var items = new List(); - if (_store.StorageError is { Length: > 0 }) - { - items.Add(new ListItem(new NoOpCommand()) - { - Title = "Saved profiles unavailable", - Subtitle = "Saved profiles could not be read. Saving a new profile will replace the unreadable file.", - Icon = new IconInfo("\uE783"), - }); - } - - items.Add(new ListItem(_newProfilePage) - { - Title = "Add or replace profile", - Subtitle = "Enter a name and optional Codex source paths", - Icon = new IconInfo("\uE710"), - TextToSuggest = "Add or replace profile", - }); - - foreach (var profile in _store.Profiles) - { - var id = profile.Id; - var deleteCommand = new AnonymousCommand(() => RemoveProfile(id)) - { - Name = "Delete profile", - Id = $"nl.mathijs.codexusage.profile.delete.{id:N}", - Result = CommandResult.KeepOpen(), - }; - var deleteConfirmation = new ConfirmableCommand( - deleteCommand, - "Delete this Codex profile?", - "This removes the saved profile only. It does not change the active source or Codex configuration.", - () => true) - { - Name = "Delete profile", - Id = $"nl.mathijs.codexusage.profile.confirm-delete.{id:N}", - }; - - items.Add(new ListItem(new UseProfileCommand(this, id)) - { - Title = profile.DisplayName, - Subtitle = DescribeSource(profile.SourceOptions), - TextToSuggest = profile.DisplayName, - MoreCommands = - [ - new CommandContextItem(deleteConfirmation) - { - Title = "Delete profile", - Icon = new IconInfo("\uE74D"), - }, - ], - }); - } - - return [.. items]; - } - - private CommandResult UseProfile(Guid id) - { - if (!_store.TryGet(id, out var profile) || profile is null) - { - return CommandResult.ShowToast("This Codex profile is no longer available."); - } - - // Store loading keeps structurally valid offline WSL/network paths. Use - // validates availability at activation so a stale profile fails closed. - if (!CodexSourceOptions.TryCreate( - profile.SourceOptions.ExecutablePath, - profile.SourceOptions.HomePath, - out var options, - out _)) - { - return CommandResult.ShowToast("The saved Codex source path is invalid or unavailable."); - } - - ProfileSelected?.Invoke(this, new CodexProfileSelectedEventArgs(profile.DisplayName, options)); - return CommandResult.KeepOpen(); - } - - private void RemoveProfile(Guid id) - { - _store.TryRemove(id, out _); - RefreshItems(); - } - - private void RefreshItems() - { - lock (_gate) - { - if (_disposed) - { - return; - } - } - - RaiseItemsChanged(0); - } - - private static string DescribeSource(CodexSourceOptions options) - { - if (options.ExecutablePath is null && options.HomePath is null) - { - return "Uses automatic Codex source discovery"; - } - - if (options.ExecutablePath is not null && options.HomePath is not null) - { - return "Custom executable and Codex home"; - } - - return options.ExecutablePath is not null ? "Custom executable" : "Custom Codex home"; - } - - private sealed partial class UseProfileCommand : InvokableCommand - { - private readonly CodexProfilesPage _owner; - private readonly Guid _id; - - internal UseProfileCommand(CodexProfilesPage owner, Guid id) - { - _owner = owner; - _id = id; - Id = $"nl.mathijs.codexusage.profile.use.{id:N}"; - } - - public override string Name => "Use profile"; - - public override ICommandResult Invoke() => _owner.UseProfile(_id); - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - } - - _newProfilePage.Dispose(); - GC.SuppressFinalize(this); - } -} - -internal sealed partial class NewProfileFormPage : ContentPage, IDisposable -{ - private readonly object _gate = new(); - private readonly ProfileFormContent _form; - private MarkdownContent _message; - private bool _disposed; - - internal NewProfileFormPage(CodexProfileStore store, Action? saved = null) - { - _form = new ProfileFormContent(store, HandleSubmit); - _message = new MarkdownContent("# Add or replace a Codex profile\n\nSave a named source for later use. Paths are checked before they are saved."); - Id = "nl.mathijs.codexusage.profile.new"; - Name = "Open"; - Title = "Add or replace Codex profile"; - Icon = new IconInfo("\uE710"); - Saved = saved; - } - - private Action? Saved { get; } - - public override IContent[] GetContent() - { - lock (_gate) - { - return _disposed ? [] : [_message, _form]; - } - } - - private CommandResult HandleSubmit( - string? displayName, - string? executablePath, - string? homePath) - { - if (_disposed) - { - return CommandResult.KeepOpen(); - } - - if (!_form.Store.TryUpsert(displayName, executablePath, homePath, out _, out var error)) - { - SetMessage($"# Add or replace a Codex profile\n\n**Could not save the profile:** {UsageText.EscapeMarkdown(error ?? "The profile is invalid.")}"); - return CommandResult.KeepOpen(); - } - - Saved?.Invoke(); - SetMessage("# Profile saved\n\nThe profile is ready to select from the list."); - return CommandResult.GoBack(); - } - - private void SetMessage(string message) - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _message = new MarkdownContent(message); - } - - RaiseItemsChanged(0); - } - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - } - - GC.SuppressFinalize(this); - } - - private sealed partial class ProfileFormContent : FormContent - { - private const string InvalidPathValue = ""; - private readonly Func _submit; - - internal ProfileFormContent(CodexProfileStore store, Func submit) - { - Store = store; - _submit = submit; - TemplateJson = """ - {"type":"AdaptiveCard","version":"1.5","body":[ - {"type":"Input.Text","id":"name","label":"Profile name","placeholder":"Work","maxLength":40,"isRequired":true}, - {"type":"Input.Text","id":"executablePath","label":"Codex executable path (optional)","placeholder":"C:\\Path\\to\\codex.exe","maxLength":1024}, - {"type":"Input.Text","id":"homePath","label":"Codex home path (optional)","placeholder":"C:\\Users\\you\\.codex","maxLength":1024}, - {"type":"TextBlock","text":"Use a full Windows path. A Windows-accessible WSL directory is allowed when available to Windows; this extension does not launch WSL or modify Codex configuration.","wrap":true} - ],"actions":[{"type":"Action.Submit","title":"Save profile"}]} - """; - } - - internal CodexProfileStore Store { get; } - - public override CommandResult SubmitForm(string payload) - { - if (string.IsNullOrWhiteSpace(payload) || payload.Length > 8192) - { - return _submit(null, null, null); - } - - try - { - using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); - if (document.RootElement.ValueKind != JsonValueKind.Object) - { - return _submit(null, null, null); - } - - if (!TryReadOptionalString(document.RootElement, "executablePath", out var executablePath) - || !TryReadOptionalString(document.RootElement, "homePath", out var homePath)) - { - // A malformed optional path must not silently select the - // default source. The marker is deliberately invalid and - // never leaves this form or reaches storage. - return _submit(ReadString(document.RootElement, "name"), InvalidPathValue, null); - } - - return _submit( - ReadString(document.RootElement, "name"), - executablePath, - homePath); - } - catch (JsonException) - { - return _submit(null, null, null); - } - } - - private static bool TryReadOptionalString(JsonElement root, string propertyName, out string? value) - { - value = null; - if (!root.TryGetProperty(propertyName, out var property) - || property.ValueKind == JsonValueKind.Null) - { - return true; - } - - if (property.ValueKind != JsonValueKind.String) - { - return false; - } - - value = property.GetString(); - return true; - } - - private static string? ReadString(JsonElement root, string propertyName) => - root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String - ? value.GetString() - : null; - } -} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 5c84922..71bf7d9 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -8,7 +8,6 @@ namespace CodexUsageDock; internal sealed partial class CodexUsageDockSettingsPage : ContentPage { - private const string InvalidSourcePath = "Invalid source path: re-enter or clear this field"; private const string ShowFiveHourLimitKey = "showFiveHourLimit"; private const string ShowWeeklyLimitKey = "showWeeklyLimit"; private const string ShowResetsAndCreditsKey = "showResetsAndCredits"; @@ -19,15 +18,9 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string CompactDockKey = "compactDock"; private const string SeparateDockItemsKey = "separateDockItems"; private const string ShowAccountActivityKey = "showAccountActivity"; - private const string CodexExecutablePathKey = "codexExecutablePath"; - private const string CodexHomePathKey = "codexHomePath"; - private const int MaximumPathLength = 1024; private const string HistoryRetentionKey = "historyRetentionDays"; private const string WorkdayEndKey = "workdayEnd"; private const string RemainingWorkdaysKey = "remainingWorkdays"; - private const string SourceLabelKey = "sourceLabel"; - private const string EnableClaudeKey = "enableClaude"; - private const string ClaudeBridgePathKey = "claudeBridgePath"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -94,37 +87,6 @@ internal CodexUsageDockSettingsPage(string path) Label = "Show account activity", Description = "Read account-wide daily tokens from the Codex service after quotas load. Older CLI versions may not support this.", }); - _settings.Add(new TextSetting(CodexExecutablePathKey, string.Empty) - { - Label = "Codex executable path", - Description = "Optional explicit path to codex.exe or codex.cmd.", - Placeholder = @"C:\Path\to\codex.exe", - Multiline = false, - }); - _settings.Add(new TextSetting(CodexHomePathKey, string.Empty) - { - Label = "Codex home path", - Description = "Optional Codex home directory. It may be a Windows-accessible WSL directory; this extension does not launch WSL or modify Codex configuration.", - Placeholder = @"C:\Users\you\.codex", - Multiline = false, - }); - _settings.Add(new TextSetting(SourceLabelKey, "Default") - { - Label = "Source label", - Description = "An optional name for these source paths. A label does not verify the signed-in account.", - Multiline = false, - }); - _settings.Add(new ToggleSetting(EnableClaudeKey, false) - { - Label = "Enable Claude usage pilot", - Description = "Read only the local quota snapshot produced by your optional Claude statusline bridge.", - }); - _settings.Add(new TextSetting(ClaudeBridgePathKey, string.Empty) - { - Label = "Claude bridge file", - Description = "The full path to your bridge JSON file. Configure the optional statusline bridge before enabling this pilot.", - Multiline = false, - }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, [ @@ -199,26 +161,6 @@ internal CodexUsageDockSettingsPage(string path) public bool ShowAccountActivity => _settings.GetSetting(ShowAccountActivityKey); - public string CodexExecutablePath => GetPathSetting(CodexExecutablePathKey); - - public string CodexHomePath => GetPathSetting(CodexHomePathKey); - - internal string SourceLabel => UsageText.SanitizeExternal(_settings.GetSetting(SourceLabelKey), 40) ?? "Default"; - internal bool EnableClaude => _settings.GetSetting(EnableClaudeKey); - internal string ClaudeBridgePath => GetPathSetting(ClaudeBridgePathKey); - internal string ProfileStoragePath => Path.Combine(Path.GetDirectoryName(_path)!, "profiles.json"); - - internal void ApplySourceProfile(string label, CodexSourceOptions options) - { - _settings.Update(new JsonObject - { - [SourceLabelKey] = UsageText.SanitizeExternal(label, 40) ?? "Custom", - [CodexExecutablePathKey] = options.ExecutablePath ?? string.Empty, - [CodexHomePathKey] = options.HomePath ?? string.Empty, - }.ToJsonString()); - OnSettingsChanged(_settings, _settings); - } - public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch @@ -266,12 +208,6 @@ private void Load() var valid = new JsonObject(); foreach (var property in document.RootElement.EnumerateObject()) { - if (property.Name is CodexExecutablePathKey or CodexHomePathKey or ClaudeBridgePathKey) - { - valid[property.Name] = property.Value.ValueKind == JsonValueKind.String && IsValidPathSetting(property.Value.GetString()) - ? property.Value.GetString() : InvalidSourcePath; - continue; - } if (property.Value.ValueKind is JsonValueKind.True or JsonValueKind.False && IsBooleanSetting(property.Name)) { @@ -285,11 +221,7 @@ private void Load() } var value = property.Value.GetString(); - if (property.Name == SourceLabelKey) - { - valid[property.Name] = UsageText.SanitizeExternal(value, 40) ?? "Default"; - } - else if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") + if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") { valid[property.Name] = value; } @@ -318,31 +250,7 @@ private void Load() private static bool IsBooleanSetting(string name) => name is ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or UseAdaptiveWeeklyForecastKey or EnableUsageAlertsKey or CompactDockKey or SeparateDockItemsKey or - ShowAccountActivityKey or EnableClaudeKey; - - private static bool IsValidPathSetting(string? value) - { - if (value is null || value.Length > MaximumPathLength) - { - return false; - } - - foreach (var character in value) - { - if (char.IsControl(character)) - { - return false; - } - } - - return true; - } - - private string GetPathSetting(string key) - { - var value = _settings.GetSetting(key); - return IsValidPathSetting(value) ? value! : InvalidSourcePath; - } + ShowAccountActivityKey; private void OnSettingsChanged(object sender, Settings args) { diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f780b24..206a746 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -99,6 +99,8 @@ Store install, update, and uninstall behavior must be tested with a Store-signed For settings and storage changes, also restart Command Palette and Windows in the isolated test environment and verify all saved choices, including the first refresh interval. Make the test settings/history file unwritable, verify a visible failure, restore write access, and retry. A failed learned-history deletion must preserve the saved history; a successful deletion must remain cleared after restart. With a large synthetic session directory, verify that limits appear before token analysis completes and that text and chart projections both pause after a measurement gap. +When upgrading from a development build with manual source paths or the Claude pilot, verify that those fields and the source-profile and Claude commands are absent. Old saved values must not prevent automatic Codex detection, and saved Claude Dock pins must not restore a band. Saving another preference must preserve the remaining Codex choices and omit the obsolete settings fields. Existing profile files and external capture scripts or files are not removed by this upgrade. + ## Build the Microsoft Store package The package artwork is generated from one canonical visual mark. Treat `scripts/generate-assets.ps1` as its source instead of editing individual PNG files. The release builder compares decoded artwork with a small rendering tolerance, because PNG encoding and anti-aliasing can differ between supported build hosts without changing the design: diff --git a/PRIVACY.md b/PRIVACY.md index 7549117..4bb3445 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy -Last updated: September 9, 2026 +Last updated: September 13, 2026 Codex Usage Dock is a local Windows extension for PowerToys Command Palette. It displays Codex usage limits, earned resets, reset expiry times, and available credits in the Command Palette Dock. @@ -16,21 +16,21 @@ Communication initiated by the extension is limited to the local Codex app-serve ## Data storage -Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, planning, retention, forecasting, and explicit source preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. +Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, planning, retention, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt from the current weekly window after a restart. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles; measurements collected while paused are not added later. Users can also delete the learned profiles from Codex Usage settings. Account-scoped history uses a one-way hash of the account identity supplied by Codex, combined with the default quota category, as an opaque local directory name. Raw account identifiers and email addresses are not stored or shown in diagnostics. Legacy history without account attribution is not imported into a verified account; unverified observations remain in memory and do not train saved forecasts. The last confirmed usage snapshot is retained only in memory during an outage, with its original timestamp. Diagnostics exposes field availability and bounded status messages, not raw service errors, credentials, or personal paths. -Optional source preferences store the user-selected executable and Codex home paths locally in settings. These paths are not included in diagnostic reports or logs. Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. +Account activity requests travel through the local Codex app-server and retain only aggregate daily token counts and optional totals in memory. Identity is checked before and after each request. Optional usage notifications contain a quota label and bounded status text, without account identifiers; their deduplication state remains in memory. Disabling account activity clears the visible account activity state and stops new optional reads. Optional retained history stores at most 27,000 aggregate quota observations with UTC timestamps and reset times, separated by hashed account/category context. The selected retention is 7, 30, or 90 days; collection starts only after opting in. Explicit CSV/JSON exports create local files without account IDs or conversation content. Users control deletion of retained observations and exported copies separately. Neither retained observations nor exports contain prompts or task contents. An explicitly requested task-usage read passes the user-entered task ID through the local Codex app-server and keeps the resulting aggregate estimates only in memory. A confirmed earned-reset action sends a mutation through that server. Before sending, the extension stores a random request ID in the account's hashed local context. An unresolved ID is retained across restarts so a retry cannot accidentally become a separate redemption. Recovery records contain no authentication credentials or raw account identifiers and are separate from history deletion. -Optional source profiles store up to eight display names and executable/home paths in the local `profiles.json` beside settings. Profile selection changes only the extension's source preferences; it does not copy authentication or alter Codex configuration. Deleting a preset does not delete a source directory or change the active source. The quota fallback keeps bounded file metadata, read positions, partial lines, and its latest parsed quota event only in memory. +The quota fallback keeps bounded file metadata, read positions, partial lines, and its latest parsed quota event only in memory. -The optional Claude pilot reads only the capture file explicitly selected in settings. Its separately configured companion script receives Claude statusline input and retains at most 256 KiB in memory for parsing. Default mode forwards that input to the user's existing formatter; standalone mode emits only a compact quota line. The saved capture contains only a schema version, provider label, UTC timestamp, validated five-hour/seven-day usage percentages and reset times, and generic status messages. It does not copy workspace paths, model details, account identifiers, credentials, prompts, or responses into the capture. The extension does not request Claude credentials, contact Claude services, edit Claude configuration, or upload the capture. Users manage the script and its output file separately; disabling the pilot clears its displayed state and stops new reads but does not delete the file. +Source-path, source-label, and Claude preferences from earlier development builds are ignored and omitted on the next settings save. The extension no longer reads saved source-profile or Claude capture files. Existing profile files and externally configured capture scripts or files remain under the user's control and are not deleted or modified by the extension. ## Permissions diff --git a/README.md b/README.md index 8d612d1..8f0ab81 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The Dock will show entries similar to `5h 47%`, `Week 86%`, and `2 resets · 10. ## Customize the Dock -**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each visible metric as a separate pinnable band. Turning it off offers the combined band. Pins belonging to the inactive mode, hidden metrics, and the disabled Claude pilot stop displaying items and are not restored as active bands after a reload. Command Palette keeps its saved pins: switching modes does not move or convert them. Add the desired bands through Dock customization if they were not already pinned; switching back makes matching saved pins available again. +**Compact Dock** shortens quota labels to forms such as `5h47%` and `W86%` and hides reset times while retaining stale/source warnings. **Separate Dock items** offers each visible metric as a separate pinnable band. Turning it off offers the combined band. Pins belonging to the inactive mode and hidden metrics stop displaying items and are not restored as active bands after a reload. Command Palette keeps its saved pins: switching modes does not move or convert them. Add the desired bands through Dock customization if they were not already pinned; switching back makes matching saved pins available again. **Enable usage alerts** is off by default. When enabled, fresh, identified account data can notify on a downward crossing of 10% remaining, a new projected limit within one hour, or a reset credit entering its last 24 hours. The first measurement establishes a baseline. Duplicate refreshes do not repeat alerts, small reset-time fluctuations stay in the same cycle, and account/category changes start a new baseline. Multiple simultaneous alerts are combined into one host notification. Delivery depends on the Command Palette host. @@ -64,33 +64,14 @@ Microsoft Store installs updates automatically. You can also check for updates f ## Sources and account activity -Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd` and an optional Codex home directory. Empty fields retain environment-based discovery. An explicit directory can be a Windows-accessible WSL path; the extension reads that directory and passes it to the Windows CLI as `CODEX_HOME`, without starting WSL or changing Codex configuration. Inaccessible or invalid explicit paths stop source reads and show a settings error. A profile change clears the displayed context and discards results from the previous in-flight read. +Codex is detected automatically using the existing environment configuration described in [Requirements](#requirements). Local session readers use `CODEX_HOME` when set, otherwise the current user's `.codex` directory. The extension has no path fields or source-profile selection in its settings. -**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +Saved source paths, source labels, and Claude preferences from earlier development builds are ignored. Other Codex preferences are preserved, and obsolete fields are omitted the next time settings are saved. Old source-profile files and externally configured capture scripts or files are not deleted or modified by the extension. -**Codex source profiles** saves up to eight named sets of executable and home paths. Add a profile, then choose **Use profile** to apply it and persist it for the next start. Reusing a name replaces that preset. Profiles contain no copied credentials; a name does not verify the signed-in account. Saved WSL or network directories can remain listed while offline, but paths must be accessible before use. Deleting a preset asks for confirmation and leaves the active source settings unchanged. Only one Codex source is active at a time. +**Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. **Codex usage in text**, also available from Details, provides quota tables, reset times, recent measured weekly points, and local daily token totals without relying on charts or color. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. -## Optional Claude usage pilot - -The pilot displays separate Claude five-hour and seven-day limits from an explicitly selected local capture file. It is off by default and adds its own Dock band when enabled. It does not verify the Claude account, combine Claude percentages with Codex, or infer costs. Claude reads run independently of a slow Codex refresh. Changing the refresh interval immediately rereads the capture and updates its freshness status. - -The bridge uses Claude Code's documented `rate_limits.five_hour` and `rate_limits.seven_day` statusline fields. These may be absent independently, appear only after a session receives an API response, and require an eligible subscription. The pilot does not support gateway spend-limit fields. See the [official statusline field documentation](https://code.claude.com/docs/en/statusline#available-data). - -1. Copy [capture-claude-usage.ps1](scripts/capture-claude-usage.ps1) from this repository to a permanent location you control. The companion script is not bundled into the MSIX application. -2. Configure the command in your Claude statusline settings using the official instructions. If you have no existing formatter, use the script's **standalone** mode; replace both example paths with your own absolute paths: - - ```text - powershell.exe -NoProfile -NonInteractive -File "C:/Tools/capture-claude-usage.ps1" -OutputPath "C:/UsageCaptures/claude-usage.json" -Standalone - ``` - - Standalone mode displays a compact remaining-quota line. To retain an existing formatter, omit `-Standalone`, launch the capture script as a separate PowerShell process, and pipe that process's stdout into your existing formatter command. Default mode forwards the original stdin bytes unchanged, including when the capture destination fails. Calling the script inside the same PowerShell process is not a supported pipeline arrangement. The extension does not edit your Claude settings or replace a statusline automatically. -3. In **Codex Usage settings**, set **Claude bridge file** to the same absolute JSON file path and turn on **Enable Claude usage pilot**. The script creates the output directory when needed. -4. Open **Claude usage pilot** to inspect capture status, observation time, and each independent window. Add its Claude Dock band through Dock customization. - -The bridge retains at most 256 KiB of input for parsing and writes only the schema, provider, UTC capture time, validated quota windows, and generic availability messages. Writes replace the snapshot atomically. Missing, malformed, or oversized input writes an unavailable snapshot; it never refreshes the timestamp on old quota values. The extension reads at most 64 KiB per capture. Stale, future-dated, missing, and expired data do not appear as available Dock quota. **Refresh captures** rereads the file; it does not make Claude emit new data. After a quiet session, wait for a new Claude statusline update. - ## History, planning, and optional account actions **Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. diff --git a/SPRINTS.md b/SPRINTS.md index 230c8fe..179e67c 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,8 @@ # Usage assistant implementation -This series implements the recommended Codex-first roadmap, followed by a small, optional Claude pilot. Source work uses separate feature branches and pull requests. Merge, Store publication, and installation are separate steps. +This series implements a Codex-only usage roadmap. Source work uses separate feature branches and pull requests. Merge, Store publication, and installation are separate steps. + +The sprint records below describe the original PRs and their historical verification. The current implementation removes the Claude pilot, manual source-path settings, and named source profiles, while retaining automatic Codex detection, bounded fallback reads, and accessible text views. See [CHANGELOG.md](CHANGELOG.md) for the current scope. | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | diff --git a/scripts/capture-claude-usage.ps1 b/scripts/capture-claude-usage.ps1 deleted file mode 100644 index 8e1e16b..0000000 --- a/scripts/capture-claude-usage.ps1 +++ /dev/null @@ -1,313 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string]$OutputPath, - - [switch]$Standalone -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$MaximumInputBytes = 256 * 1024 - -function Test-FullyQualifiedPath { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - try { - return [IO.Path]::IsPathFullyQualified($Path) - } - catch { - # Windows PowerShell 5.1 does not expose IsPathFullyQualified. - return $Path -match '^(?:[A-Za-z]:[\\/]|\\\\)' - } -} - -function Get-JsonProperty { - param( - [AllowNull()] - [object]$Object, - - [Parameter(Mandatory = $true)] - [string]$Name - ) - - if ($null -eq $Object) { - return $null - } - - $property = $Object.PSObject.Properties[$Name] - if ($null -eq $property) { - return $null - } - - return $property.Value -} - -function Convert-RateLimitWindow { - param( - [AllowNull()] - [object]$RateLimits, - - [Parameter(Mandatory = $true)] - [string]$Name, - - [Parameter(Mandatory = $true)] - [DateTimeOffset]$CapturedAt - ) - - $sourceWindow = Get-JsonProperty -Object $RateLimits -Name $Name - if ($null -eq $sourceWindow -or $sourceWindow -is [string] -or $sourceWindow -is [System.Array]) { - return $null - } - - $used = Get-JsonProperty -Object $sourceWindow -Name "used_percentage" - if ($null -eq $used -or $used -is [string] -or $used -is [char] -or $used -is [bool]) { - return $null - } - - try { - $usedNumber = [double]$used - } - catch { - return $null - } - - if ([double]::IsNaN($usedNumber) -or [double]::IsInfinity($usedNumber) -or - $usedNumber -lt 0 -or $usedNumber -gt 100) { - return $null - } - - $reset = Get-JsonProperty -Object $sourceWindow -Name "resets_at" - if ($null -eq $reset -or $reset -is [string] -or $reset -is [char] -or $reset -is [bool]) { - return $null - } - - try { - $resetNumber = [double]$reset - if ([double]::IsNaN($resetNumber) -or [double]::IsInfinity($resetNumber) -or - $resetNumber -ne [Math]::Truncate($resetNumber)) { - return $null - } - - $resetSeconds = [long]$resetNumber - $resetAt = [DateTimeOffset]::FromUnixTimeSeconds($resetSeconds) - } - catch { - return $null - } - - if ($resetAt -le $CapturedAt) { - return $null - } - - return [pscustomobject][ordered]@{ - used_percentage = $usedNumber - resets_at = $resetSeconds - } -} - -function Read-StandardInputAndPassThrough { - param( - [switch]$SuppressOutput - ) - - $inputStream = [Console]::OpenStandardInput() - $outputStream = [Console]::OpenStandardOutput() - $retained = [IO.MemoryStream]::new() - $buffer = New-Object byte[] 8192 - $oversized = $false - - try { - while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { - # Keep the existing statusline contract unless standalone output was requested. - if (-not $SuppressOutput) { - $outputStream.Write($buffer, 0, $count) - } - - if (-not $oversized) { - $remaining = $MaximumInputBytes - [int]$retained.Length - if ($count -le $remaining) { - $retained.Write($buffer, 0, $count) - } - else { - if ($remaining -gt 0) { - $retained.Write($buffer, 0, $remaining) - } - - $oversized = $true - } - } - } - - if (-not $SuppressOutput) { - $outputStream.Flush() - } - return [pscustomobject]@{ - Bytes = $retained.ToArray() - Oversized = $oversized - } - } - finally { - $retained.Dispose() - $inputStream.Dispose() - } -} - -function Write-AtomicSnapshot { - param( - [Parameter(Mandatory = $true)] - [string]$Path, - - [Parameter(Mandatory = $true)] - [string]$Content - ) - - $directory = [IO.Path]::GetDirectoryName($Path) - if ([string]::IsNullOrWhiteSpace($directory)) { - throw [ArgumentException]::new("The output path has no directory.") - } - - $directory = [IO.Path]::GetFullPath($directory) - [IO.Directory]::CreateDirectory($directory) | Out-Null - $leaf = [IO.Path]::GetFileName($Path) - if ([string]::IsNullOrWhiteSpace($leaf)) { - throw [ArgumentException]::new("The output path has no file name.") - } - - $temporaryLeaf = "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".tmp" - $temporaryPath = [IO.Path]::Combine($directory, $temporaryLeaf) - $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content) - $stream = $null - - try { - $stream = [IO.File]::Open( - $temporaryPath, - [IO.FileMode]::CreateNew, - [IO.FileAccess]::Write, - [IO.FileShare]::None) - $stream.Write($bytes, 0, $bytes.Length) - $stream.Flush($true) - $stream.Dispose() - $stream = $null - - if ([IO.File]::Exists($Path)) { - $backupPath = [IO.Path]::Combine( - $directory, - "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".bak") - try { - [IO.File]::Replace($temporaryPath, $Path, $backupPath, $true) - } - finally { - if ([IO.File]::Exists($backupPath)) { - [IO.File]::Delete($backupPath) - } - } - } - else { - [IO.File]::Move($temporaryPath, $Path) - } - } - finally { - if ($null -ne $stream) { - $stream.Dispose() - } - - if ([IO.File]::Exists($temporaryPath)) { - [IO.File]::Delete($temporaryPath) - } - } -} - -try { - $captured = Read-StandardInputAndPassThrough -SuppressOutput:$Standalone - - if ([string]::IsNullOrWhiteSpace($OutputPath) -or -not (Test-FullyQualifiedPath -Path $OutputPath)) { - throw [ArgumentException]::new("The output path must be fully qualified.") - } - - $capturedAt = [DateTimeOffset]::UtcNow - $primary = $null - $weekly = $null - $parseSucceeded = -not $captured.Oversized - - if ($parseSucceeded) { - try { - $json = [Text.UTF8Encoding]::new($false, $true).GetString($captured.Bytes) - $source = $json | ConvertFrom-Json - $rateLimits = Get-JsonProperty -Object $source -Name "rate_limits" - $primary = Convert-RateLimitWindow -RateLimits $rateLimits -Name "five_hour" -CapturedAt $capturedAt - $weekly = Convert-RateLimitWindow -RateLimits $rateLimits -Name "seven_day" -CapturedAt $capturedAt - } - catch { - $parseSucceeded = $false - $primary = $null - $weekly = $null - } - } - - $validWindows = @($primary, $weekly) | Where-Object { $null -ne $_ } - $validCount = @($validWindows).Count - $status = if (-not $parseSucceeded -or $validCount -eq 0) { - "unavailable" - } - elseif ($validCount -eq 2) { - "available" - } - else { - "partial" - } - $message = if ($validCount -eq 2) { - "Claude rate-limit windows are available." - } - elseif ($validCount -eq 1) { - "One Claude rate-limit window is unavailable; windows remain independent." - } - else { - "No valid Claude rate-limit windows were provided." - } - - $snapshot = [ordered]@{ - schemaVersion = 1 - provider = "claude" - observedAtUTC = $capturedAt.ToString("O", [Globalization.CultureInfo]::InvariantCulture) - rate_limits = [ordered]@{ - five_hour = $primary - seven_day = $weekly - } - status = $status - message = $message - } - $snapshotJson = $snapshot | ConvertTo-Json -Depth 8 -Compress - Write-AtomicSnapshot -Path $OutputPath -Content $snapshotJson - - if ($Standalone) { - $primaryRemaining = if ($null -eq $primary) { - "--" - } - else { - ([double](100 - [double](Get-JsonProperty -Object $primary -Name "used_percentage"))).ToString( - "0.##", - [Globalization.CultureInfo]::InvariantCulture) + "%" - } - $weeklyRemaining = if ($null -eq $weekly) { - "--" - } - else { - ([double](100 - [double](Get-JsonProperty -Object $weekly -Name "used_percentage"))).ToString( - "0.##", - [Globalization.CultureInfo]::InvariantCulture) + "%" - } - - [Console]::WriteLine("Claude 5h $primaryRemaining / week $weeklyRemaining") - } - - exit 0 -} -catch { - [Console]::Error.WriteLine("Claude usage capture could not write the snapshot.") - exit 1 -} From 32f2ba5437119dabea5e14662e007c46a679ef26 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sun, 13 Sep 2026 07:17:44 +0200 Subject: [PATCH 18/21] Pin Codex-only changelog entry to implementation commit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69f2a18..4761a25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ Each entry links to the commit or pull request that introduced the change. ### Removed -- Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([implementation](https://github.com/TheBeems/CodexUsageDock/commit/codex%2Fcodex-only-settings)) +- Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([commit 39bf74c](https://github.com/TheBeems/CodexUsageDock/commit/39bf74cd476920441146f37737ad1216a9c0b8ad)) ## [0.6.1] - 2026-09-09 From 263c22a650f2b7062515f94e983023e337dc7610 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:09:55 +0200 Subject: [PATCH 19/21] Remove workday planner and its settings --- CHANGELOG.md | 1 + .../CodexOnlySettingsTests.cs | 12 +- ...ionTests.cs => HistoryIntegrationTests.cs} | 20 +- CodexUsageDock.Tests/UsagePlanningTests.cs | 345 ---------- .../CodexUsageDockCommandsProvider.cs | 5 - CodexUsageDock/Pages/CodexPlanningPage.cs | 77 --- .../Pages/CodexUsageDockSettingsPage.cs | 24 +- CodexUsageDock/UsagePlanning.cs | 600 ------------------ PRIVACY.md | 2 +- README.md | 4 +- SPRINTS.md | 2 +- 11 files changed, 17 insertions(+), 1075 deletions(-) rename CodexUsageDock.Tests/{PlanningHistoryIntegrationTests.cs => HistoryIntegrationTests.cs} (89%) delete mode 100644 CodexUsageDock.Tests/UsagePlanningTests.cs delete mode 100644 CodexUsageDock/Pages/CodexPlanningPage.cs delete mode 100644 CodexUsageDock/UsagePlanning.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4761a25..a45364d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Each entry links to the commit or pull request that introduced the change. ### Removed +- Remove the workday planner and its workday-end and remaining-workdays settings. Existing planner preferences are ignored and dropped on the next settings save; usage displays, reset times, forecasts, and history remain available. ([implementation](https://github.com/TheBeems/CodexUsageDock/commit/codex/codex-only-settings)) - Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([commit 39bf74c](https://github.com/TheBeems/CodexUsageDock/commit/39bf74cd476920441146f37737ad1216a9c0b8ad)) ## [0.6.1] - 2026-09-09 diff --git a/CodexUsageDock.Tests/CodexOnlySettingsTests.cs b/CodexUsageDock.Tests/CodexOnlySettingsTests.cs index 043dd2b..5d6cdd0 100644 --- a/CodexUsageDock.Tests/CodexOnlySettingsTests.cs +++ b/CodexUsageDock.Tests/CodexOnlySettingsTests.cs @@ -17,7 +17,9 @@ public sealed class CodexOnlySettingsTests : IDisposable [InlineData("sourceLabel")] [InlineData("enableClaude")] [InlineData("claudeBridgePath")] - public void SettingsDoNotOfferRemovedSourceControls(string key) + [InlineData("workdayEnd")] + [InlineData("remainingWorkdays")] + public void SettingsDoNotOfferRemovedControls(string key) { var settings = _environment.CreateSettings(); var content = string.Join("\n", settings.GetContent().OfType() @@ -42,7 +44,7 @@ public void SavingLegacySettingsKeepsCodexChoicesAndDropsRemovedPreferences() settings.GetContent().OfType().Last().SubmitForm(JsonSerializer.Serialize(legacy), "{}"); using var saved = JsonDocument.Parse(File.ReadAllText(_environment.PathFor("settings.json"))); - foreach (var key in new[] { "codexExecutablePath", "codexHomePath", "sourceLabel", "enableClaude", "claudeBridgePath" }) + foreach (var key in new[] { "codexExecutablePath", "codexHomePath", "sourceLabel", "enableClaude", "claudeBridgePath", "workdayEnd", "remainingWorkdays" }) { Assert.False(saved.RootElement.TryGetProperty(key, out _), key); } @@ -57,7 +59,7 @@ public void SavingLegacySettingsKeepsCodexChoicesAndDropsRemovedPreferences() [Theory] [InlineData(false)] [InlineData(true)] - public async Task LegacySourcePreferencesDoNotBlockCodexOrRestoreRemovedCommands(bool separate) + public async Task LegacyPreferencesDoNotBlockCodexOrRestoreRemovedCommands(bool separate) { var legacy = LegacySettings(); legacy["separateDockItems"] = separate ? "true" : "false"; @@ -77,7 +79,7 @@ public async Task LegacySourcePreferencesDoNotBlockCodexOrRestoreRemovedCommands Assert.Equal(quota.Primary, service.Current.Primary); Assert.Equal(quota.Secondary, service.Current.Secondary); Assert.Null(settings.StatusMessage); - var removedIds = new[] { "nl.mathijs.codexusage.dock.claude", "nl.mathijs.codexusage.claude", "nl.mathijs.codexusage.profiles" }; + var removedIds = new[] { "nl.mathijs.codexusage.dock.claude", "nl.mathijs.codexusage.claude", "nl.mathijs.codexusage.profiles", "nl.mathijs.codexusage.planner" }; foreach (var id in removedIds) { Assert.Null(provider.GetCommandItem(id)); @@ -102,5 +104,7 @@ public async Task LegacySourcePreferencesDoNotBlockCodexOrRestoreRemovedCommands ["sourceLabel"] = "Old source", ["enableClaude"] = "true", ["claudeBridgePath"] = "removed-invalid-capture-path", + ["workdayEnd"] = "18:30", + ["remainingWorkdays"] = "5", }; } diff --git a/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs b/CodexUsageDock.Tests/HistoryIntegrationTests.cs similarity index 89% rename from CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs rename to CodexUsageDock.Tests/HistoryIntegrationTests.cs index b560819..4cd6965 100644 --- a/CodexUsageDock.Tests/PlanningHistoryIntegrationTests.cs +++ b/CodexUsageDock.Tests/HistoryIntegrationTests.cs @@ -4,7 +4,7 @@ namespace CodexUsageDock.Tests; -public sealed class PlanningHistoryIntegrationTests : IDisposable +public sealed class HistoryIntegrationTests : IDisposable { private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); private readonly TestEnvironment _environment = new(); @@ -12,49 +12,37 @@ public sealed class PlanningHistoryIntegrationTests : IDisposable public void Dispose() => _environment.Dispose(); [Fact] - public void PlanningPreferencesRoundTripAndExposeSafeDefaults() + public void RetentionPreferencesRoundTripAndExposeSafeDefaults() { var first = _environment.CreateSettings(); SubmitSettings(first, new Dictionary { ["historyRetentionDays"] = "90", - ["workdayEnd"] = "18:30", - ["remainingWorkdays"] = "5", }); Assert.Equal(90, first.HistoryRetentionDays); - Assert.Equal(new TimeOnly(18, 30), first.WorkdayEnd); - Assert.Equal(5, first.RemainingWorkdays); var restarted = _environment.CreateSettings(); Assert.Equal(90, restarted.HistoryRetentionDays); - Assert.Equal(new TimeOnly(18, 30), restarted.WorkdayEnd); - Assert.Equal(5, restarted.RemainingWorkdays); File.Delete(_environment.PathFor("settings.json")); var defaults = _environment.CreateSettings(); Assert.Equal(0, defaults.HistoryRetentionDays); - Assert.Equal(new TimeOnly(17, 0), defaults.WorkdayEnd); - Assert.Equal(1, defaults.RemainingWorkdays); } [Fact] - public void InvalidPlanningPreferencesUseSafeDefaults() + public void InvalidRetentionPreferencesUseSafeDefaults() { File.WriteAllText(_environment.PathFor("settings.json"), JsonSerializer.Serialize( new Dictionary { ["historyRetentionDays"] = "365", - ["workdayEnd"] = "25:61", - ["remainingWorkdays"] = "0", })); var settings = _environment.CreateSettings(); Assert.Equal(0, settings.HistoryRetentionDays); - Assert.Equal(new TimeOnly(17, 0), settings.WorkdayEnd); - Assert.Equal(1, settings.RemainingWorkdays); } [Fact] @@ -200,8 +188,6 @@ private static void SubmitSettings( var payload = new Dictionary { ["historyRetentionDays"] = "0", - ["workdayEnd"] = "17:00", - ["remainingWorkdays"] = "1", }; foreach (var pair in values) diff --git a/CodexUsageDock.Tests/UsagePlanningTests.cs b/CodexUsageDock.Tests/UsagePlanningTests.cs deleted file mode 100644 index 10fdcde..0000000 --- a/CodexUsageDock.Tests/UsagePlanningTests.cs +++ /dev/null @@ -1,345 +0,0 @@ -using Xunit; - -namespace CodexUsageDock.Tests; - -public sealed class UsagePlanningTests -{ - private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); - private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); - - [Fact] - public void FreshPlanReportsRemainingQuotaAndRatesUntilTheEarlierBoundary() - { - var desiredEnd = Now.AddHours(5); - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: 50, - secondaryRemaining: 80, - primaryReset: Now.AddHours(2), - secondaryReset: Now.AddDays(4)), - Now, - RefreshInterval, - desiredEnd, - remainingWorkdays: 3); - - Assert.True(plan.IsAvailable); - Assert.Equal(50, plan.Primary!.RemainingPercent); - Assert.Equal(TimeSpan.FromHours(2), plan.Primary.Horizon); - Assert.Equal(Now.AddHours(2), plan.Primary.HorizonEnd); - Assert.True(plan.Primary.ResetsBeforeDesiredEnd); - Assert.Equal(1, plan.Primary.Workdays); - Assert.Equal(25, plan.Primary.AvailablePointsPerHour, precision: 8); - Assert.Equal(50, plan.Primary.AvailablePointsPerWorkday, precision: 8); - Assert.Contains("resets before the desired end", plan.Primary.HorizonMessage, StringComparison.Ordinal); - - Assert.Equal(80, plan.Weekly!.RemainingPercent); - Assert.Equal(TimeSpan.FromHours(5), plan.Weekly.Horizon); - Assert.False(plan.Weekly.ResetsBeforeDesiredEnd); - Assert.Equal(3, plan.Weekly.Workdays); - Assert.Equal(80 / 3d / 5d, plan.Weekly.AvailablePointsPerHour, precision: 8); - Assert.Contains("not a guarantee", plan.Disclaimer, StringComparison.Ordinal); - Assert.Contains("not a guarantee", plan.Status, StringComparison.Ordinal); - } - - [Fact] - public void WeeklyWorkdayAssumptionAffectsDailyAndHourlyBudget() - { - var desiredEnd = Now.AddHours(5); - var fiveDayPlan = UsagePlanner.Plan( - Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), - Now, - RefreshInterval, - desiredEnd, - remainingWorkdays: 5); - var oneDayPlan = UsagePlanner.Plan( - Presentation(primaryRemaining: null, secondaryRemaining: 50, secondaryReset: Now.AddDays(7)), - Now, - RefreshInterval, - desiredEnd, - remainingWorkdays: 1); - - Assert.Equal(10, fiveDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); - Assert.Equal(2, fiveDayPlan.Weekly.AvailablePointsPerHour, precision: 8); - Assert.Equal(50, oneDayPlan.Weekly!.AvailablePointsPerWorkday, precision: 8); - Assert.Equal(10, oneDayPlan.Weekly.AvailablePointsPerHour, precision: 8); - } - - [Theory] - [InlineData(2, 23, 3)] - [InlineData(-7, 1, 1)] - public void WeeklyBudgetCapsWorkdaysUsingLocalCalendarDates(int offsetHours, int resetHour, int expectedDays) - { - var timeZone = TimeZoneInfo.CreateCustomTimeZone("Planning test", TimeSpan.FromHours(offsetHours), "Planning test", "Planning test"); - var reset = new DateTimeOffset(2026, 9, 11, resetHour, 0, 0, TimeSpan.Zero); - var presentation = Presentation(primaryRemaining: null, secondaryRemaining: 60, secondaryReset: reset); - - var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(5), 7, timeZone); - var equivalentOffsetPlan = UsagePlanner.Plan( - presentation, Now.ToOffset(TimeSpan.FromHours(14)), RefreshInterval, Now.AddHours(5), 7, timeZone); - - Assert.Equal(expectedDays, plan.Weekly!.Workdays); - Assert.Equal(60d / expectedDays, plan.Weekly.AvailablePointsPerWorkday, precision: 8); - Assert.Equal(60d / expectedDays / 5, plan.Weekly.AvailablePointsPerHour, precision: 8); - Assert.Equal(plan.Weekly.Workdays, equivalentOffsetPlan.Weekly!.Workdays); - } - - [Fact] - public void InvalidEndOrWorkdayCountReturnsAnExplanation() - { - var presentation = Presentation(); - - var now = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now); - var zeroDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 0); - var eightDays = UsagePlanner.Plan(presentation, Now, RefreshInterval, Now.AddHours(1), 8); - - Assert.False(now.IsAvailable); - Assert.Contains("desired end must be after now", now.Status, StringComparison.Ordinal); - Assert.False(zeroDays.IsAvailable); - Assert.Contains("between 1 and 7", zeroDays.Status, StringComparison.Ordinal); - Assert.False(eightDays.IsAvailable); - Assert.Contains("between 1 and 7", eightDays.Status, StringComparison.Ordinal); - } - - [Fact] - public void StaleFutureUnattributedAndBlockedDataPausePlanning() - { - var desiredEnd = Now.AddHours(2); - var cases = new[] - { - (Presentation(updatedAt: Now.AddMinutes(-6)), "usage data is stale"), - (Presentation(updatedAt: Now.AddMinutes(1)), "timestamp is in the future"), - (Presentation(accountKey: null), "account identity is unavailable"), - (Presentation(ordinaryUsageAllowed: false), "ordinary usage is currently blocked"), - }; - - foreach (var (presentation, expected) in cases) - { - var plan = UsagePlanner.Plan(presentation, Now, RefreshInterval, desiredEnd); - Assert.False(plan.IsAvailable); - Assert.Contains(expected, plan.Status, StringComparison.Ordinal); - Assert.Empty(plan.Windows); - } - } - - [Fact] - public void LastConfirmedAndLoadingDataAreNotTreatedAsFreshPlans() - { - var desiredEnd = Now.AddHours(2); - var lastConfirmed = UsagePlanner.Plan( - Presentation(source: UsageDataSource.LastConfirmed), - Now, - RefreshInterval, - desiredEnd); - var loading = UsagePlanner.Plan( - Presentation(isLoading: true), - Now, - RefreshInterval, - desiredEnd); - - Assert.False(lastConfirmed.IsAvailable); - Assert.Contains("last confirmed", lastConfirmed.Status, StringComparison.Ordinal); - Assert.False(loading.IsAvailable); - Assert.Contains("loading", loading.Status, StringComparison.Ordinal); - } - - [Fact] - public void ExpiredPrimaryCanLeaveAValidWeeklyPlanAndExpiredBothPause() - { - var desiredEnd = Now.AddDays(1); - var primaryExpired = UsagePlanner.Plan( - Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddDays(4)), - Now, - RefreshInterval, - desiredEnd); - var bothExpired = UsagePlanner.Plan( - Presentation(primaryReset: Now.AddMinutes(-1), secondaryReset: Now.AddMinutes(-1)), - Now, - RefreshInterval, - desiredEnd); - - Assert.True(primaryExpired.IsAvailable); - Assert.Null(primaryExpired.Primary); - Assert.NotNull(primaryExpired.Weekly); - Assert.False(bothExpired.IsAvailable); - Assert.Contains("no valid", bothExpired.Status, StringComparison.Ordinal); - } - - [Fact] - public void ForecastUsesOnlyAUsableCurrentPaceAndReportsUnavailableEvidenceWhenInsufficient() - { - var reset = Now.AddHours(4); - UsageHistoryEntry[] history = - [ - new(Now.AddMinutes(-10), 100), - new(Now, 50), - ]; - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: 50, - secondaryRemaining: null, - primaryReset: reset, - primaryHistory: history), - Now, - RefreshInterval, - Now.AddHours(1)); - - Assert.True(plan.IsAvailable); - Assert.True(plan.Primary!.Forecast.IsAvailable); - Assert.True(plan.Primary.Forecast.ReachesLimitBeforeReset); - Assert.Contains("current pace", plan.Primary.Forecast.Status, StringComparison.Ordinal); - Assert.Equal(2, plan.Primary.Evidence.MeasurementCount); - Assert.Equal(2, plan.Primary.Evidence.SegmentMeasurementCount); - Assert.False(plan.Primary.Backtest.IsAvailable); - - var insufficient = UsagePlanner.Plan( - Presentation( - primaryRemaining: 50, - secondaryRemaining: null, - primaryReset: reset, - primaryHistory: [new(Now, 50)]), - Now, - RefreshInterval, - Now.AddHours(1)); - Assert.False(insufficient.Primary!.Forecast.IsAvailable); - Assert.Contains("another measurement", insufficient.Primary.Forecast.Status, StringComparison.Ordinal); - } - - [Fact] - public void GapsBreakTheContinuousSegmentAndPauseForecast() - { - UsageHistoryEntry[] history = - [ - new(Now.AddMinutes(-40), 100), - new(Now.AddMinutes(-20), 90), - new(Now, 80), - ]; - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: 80, - secondaryRemaining: null, - primaryHistory: history), - Now, - RefreshInterval, - Now.AddHours(1)); - - Assert.Equal(3, plan.Primary!.Evidence.MeasurementCount); - Assert.Equal(1, plan.Primary.Evidence.SegmentMeasurementCount); - Assert.False(plan.Primary.Forecast.IsAvailable); - Assert.Contains("another measurement", plan.Primary.Forecast.Status, StringComparison.Ordinal); - Assert.False(plan.Primary.Backtest.IsAvailable); - } - - [Fact] - public void EvidenceDescribesMeasurementsSegmentsAdaptiveCyclesAndItsLimit() - { - UsageHistoryEntry[] history = - [ - new(Now.AddMinutes(-20), 100), - new(Now.AddMinutes(-10), 90), - new(Now, 80), - ]; - var adaptive = new AdaptiveWeeklyUsageHistory( - [new AdaptiveWeeklyUsageCycle(Now.AddDays(-7), 10080, 60, 10, [])], - new AdaptiveWeeklyUsageCycle(Now.AddDays(7), 10080, 60, 10, [])); - var plan = UsagePlanner.Plan( - Presentation( - primaryRemaining: null, - secondaryRemaining: 80, - weeklyHistory: history, - adaptiveHistory: adaptive), - Now, - RefreshInterval, - Now.AddHours(1)); - - var evidence = plan.Weekly!.Evidence; - Assert.Equal(3, evidence.MeasurementCount); - Assert.Equal(TimeSpan.FromMinutes(20), evidence.MeasurementSpan); - Assert.Equal(3, evidence.SegmentMeasurementCount); - Assert.Equal(TimeSpan.FromMinutes(20), evidence.SegmentSpan); - Assert.Equal(2, evidence.AdaptiveCycleCount); - Assert.Contains("3 measurements", evidence.Summary, StringComparison.Ordinal); - Assert.Contains("continuous segment", evidence.Summary, StringComparison.Ordinal); - Assert.Contains("adaptive weekly cycles: 2", evidence.Summary, StringComparison.Ordinal); - Assert.Contains("not a calibrated reliability probability", evidence.Summary, StringComparison.Ordinal); - } - - [Fact] - public void BacktestHoldsOutTheLatestMeasurementAndDoesNotTrainOnIt() - { - UsageHistoryEntry[] firstHistory = - [ - new(Now.AddMinutes(-20), 100), - new(Now.AddMinutes(-10), 90), - new(Now, 70), - ]; - UsageHistoryEntry[] changedHoldout = - [ - new(Now.AddMinutes(-20), 100), - new(Now.AddMinutes(-10), 90), - new(Now, 60), - ]; - var first = UsagePlanner.Plan( - Presentation(primaryRemaining: 70, secondaryRemaining: null, primaryHistory: firstHistory), - Now, - RefreshInterval, - Now.AddHours(1)).Primary!.Backtest; - var second = UsagePlanner.Plan( - Presentation(primaryRemaining: 60, secondaryRemaining: null, primaryHistory: changedHoldout), - Now, - RefreshInterval, - Now.AddHours(1)).Primary!.Backtest; - - Assert.True(first.IsAvailable); - Assert.Equal(2, first.TrainingSampleCount); - Assert.Equal(Now, first.HeldOutAt); - Assert.Equal(80, first.PredictedRemainingPercent!.Value, precision: 8); - Assert.Equal(70, first.ActualRemainingPercent!.Value, precision: 8); - Assert.Equal(10, first.AbsoluteErrorPercentagePoints!.Value, precision: 8); - Assert.Equal(first.PredictedRemainingPercent, second.PredictedRemainingPercent); - Assert.Equal(20, second.AbsoluteErrorPercentagePoints!.Value, precision: 8); - Assert.Contains("excluded from the preceding-pace calculation", first.Status, StringComparison.Ordinal); - } - - private static UsagePresentation Presentation( - double? primaryRemaining = 80, - double? secondaryRemaining = 80, - int primaryMinutes = 300, - int secondaryMinutes = 10080, - DateTimeOffset? primaryReset = null, - DateTimeOffset? secondaryReset = null, - DateTimeOffset? updatedAt = null, - UsageDataSource source = UsageDataSource.AppServer, - string? accountKey = "account-a", - bool isLoading = false, - bool? ordinaryUsageAllowed = null, - IReadOnlyList? primaryHistory = null, - IReadOnlyList? weeklyHistory = null, - AdaptiveWeeklyUsageHistory? adaptiveHistory = null) - { - var primary = primaryRemaining is { } primaryValue - ? new RateLimitWindow(100 - primaryValue, primaryMinutes, primaryReset ?? Now.AddHours(4)) - : null; - var secondary = secondaryRemaining is { } secondaryValue - ? new RateLimitWindow(100 - secondaryValue, secondaryMinutes, secondaryReset ?? Now.AddDays(6)) - : null; - var snapshot = new CodexUsageSnapshot( - primary, - secondary, - "pro", - null, - null, - updatedAt ?? Now, - source, - null, - AccountKey: accountKey, - OrdinaryUsageAllowed: ordinaryUsageAllowed, - DefaultBucketId: "codex"); - return new UsagePresentation( - snapshot, - primaryHistory ?? Array.Empty(), - weeklyHistory ?? Array.Empty(), - adaptiveHistory ?? new AdaptiveWeeklyUsageHistory([], null), - LocalTokenUsageSnapshot.Unavailable, - isLoading); - } -} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index a070adf..0d8dcd7 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -14,7 +14,6 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexUsageDockPage _details; private readonly CodexUsageDiagnosticsPage _diagnostics; private readonly CodexAccountActivityPage _accountActivity; - private readonly CodexPlanningPage _planner; private readonly CodexHistoryPage _history; private readonly CodexActionsPage _actions; private readonly CodexUsageTablePage _textUsage; @@ -59,7 +58,6 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; _accountActivity = new CodexAccountActivityPage(_usage); - _planner = new CodexPlanningPage(_usage, _settings, _clock); _history = new CodexHistoryPage(_usage); _actions = new CodexActionsPage(_usage); _textUsage = new CodexUsageTablePage(_usage, _clock); @@ -96,7 +94,6 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS Title = "Codex account activity", Subtitle = "Account-wide daily tokens reported by Codex", }, - new CommandItem(_planner) { Title = "Codex workday planner", Subtitle = "Daily quota budget, recent pace, and forecast evidence" }, new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quota tables and measured values without charts or color cues" }, @@ -134,7 +131,6 @@ private void OnSettingsChanged(object? sender, EventArgs e) _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); - _planner.Refresh(); _history.Refresh(); UpdateDockLayout(); } @@ -231,7 +227,6 @@ public override void Dispose() _details.Dispose(); _diagnostics.Dispose(); _accountActivity.Dispose(); - _planner.Dispose(); _history.Dispose(); _actions.Dispose(); _textUsage.Dispose(); diff --git a/CodexUsageDock/Pages/CodexPlanningPage.cs b/CodexUsageDock/Pages/CodexPlanningPage.cs deleted file mode 100644 index 1429952..0000000 --- a/CodexUsageDock/Pages/CodexPlanningPage.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Globalization; -using System.Text; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; - -namespace CodexUsageDock; - -internal sealed partial class CodexPlanningPage : ContentPage, IDisposable -{ - private readonly CodexUsageService _service; - private readonly CodexUsageDockSettingsPage _settings; - private readonly Func _clock; - private readonly object _gate = new(); - private MarkdownContent _content = new(string.Empty); - private bool _disposed; - - internal CodexPlanningPage(CodexUsageService service, CodexUsageDockSettingsPage settings, Func? clock = null) - { - _service = service; - _settings = settings; - _clock = clock ?? (() => DateTimeOffset.Now); - Id = "nl.mathijs.codexusage.planner"; - Name = "Open"; - Title = "Codex workday planner"; - Icon = new IconInfo("\uE787"); - service.Updated += OnUpdated; - Refresh(); - } - - public override IContent[] GetContent() { lock (_gate) { return [_content]; } } - - internal void Refresh() - { - lock (_gate) - { - if (_disposed) return; - var now = _clock(); - var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); - var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) - ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." - : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, - new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); - _content = new MarkdownContent(body); - Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, - new CommandContextItem(_settings) { Title = "Change planning assumptions" }, - new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy plan" }]; - } - RaiseItemsChanged(0); - } - - internal static string FormatPlan(UsagePlanningResult plan) - { - var text = new StringBuilder("# Workday planner\n\n").Append(plan.Status).Append("\n\n"); - text.Append("Target today: ").Append(plan.DesiredEnd.ToString("yyyy-MM-dd HH:mm zzz", CultureInfo.InvariantCulture)) - .Append(". Remaining workdays before weekly reset: ").Append(plan.RequestedWorkdays?.ToString(CultureInfo.InvariantCulture) ?? "1") - .Append(". Change these assumptions in settings.\n\n"); - foreach (var window in plan.Windows) - { - text.Append("## ").Append(window.Label).Append(" allowance\n\n") - .Append(window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% remaining; budget ") - .Append(window.AvailablePointsPerWorkday.ToString("0.#", CultureInfo.InvariantCulture)).Append(" quota percentage points per workday, or ") - .Append(window.AvailablePointsPerHour.ToString("0.#", CultureInfo.InvariantCulture)).Append(" points per hour today.\n\n") - .Append(window.HorizonMessage).Append("\n\n") - .Append("**Recent pace:** ").Append(window.Forecast.Status).Append("\n\n") - .Append("**Evidence:** ").Append(window.Evidence.Summary).Append("\n\n") - .Append("**Last observation check:** ").Append(window.Backtest.Status).Append("\n\n"); - if (window.Backtest.AbsoluteErrorPercentagePoints is { } error) - text.Append("Held-out prediction error: ").Append(error.ToString("0.##", CultureInfo.InvariantCulture)) - .Append(" percentage points. One held-out observation does not establish long-term accuracy.\n\n"); - } - return text.Append("Quota points are not tokens, money, or a guaranteed number of tasks. Both windows apply independently. ") - .Append("This planner uses recent pace; the dashboard may additionally use its optional learned weekly pattern.").ToString(); - } - - private void OnUpdated(object? sender, EventArgs args) => Refresh(); - public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } -} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 71bf7d9..6a0f6e7 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -19,8 +19,6 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string SeparateDockItemsKey = "separateDockItems"; private const string ShowAccountActivityKey = "showAccountActivity"; private const string HistoryRetentionKey = "historyRetentionDays"; - private const string WorkdayEndKey = "workdayEnd"; - private const string RemainingWorkdaysKey = "remainingWorkdays"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -104,19 +102,6 @@ internal CodexUsageDockSettingsPage(string path) Label = "Retain usage observations", Description = "Optional local quota history for export. Pausing keeps saved data; use History to delete it.", }); - _settings.Add(new TextSetting(WorkdayEndKey, "17:00") - { - Label = "Workday end (HH:mm)", - Description = "Local time used by the planner for today. After this time, planning pauses until the next day.", - Multiline = false, - }); - _settings.Add(new ChoiceSetSetting(RemainingWorkdaysKey, - [new("1 workday", "1"), new("2 workdays", "2"), new("3 workdays", "3"), new("4 workdays", "4"), - new("5 workdays", "5"), new("6 workdays", "6"), new("7 workdays", "7")]) - { - Label = "Workdays remaining before weekly reset", - Description = "Your planning assumption, including today. The extension does not infer your calendar.", - }); var clearHistory = new ConfirmableCommand( new AnonymousCommand(() => ClearAdaptiveHistoryRequested?.Invoke(this, EventArgs.Empty)) { @@ -165,10 +150,6 @@ internal CodexUsageDockSettingsPage(string path) internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch { "7" => 7, "30" => 30, "90" => 90, _ => 0 }; - internal TimeOnly WorkdayEnd => TimeOnly.TryParseExact(_settings.GetSetting(WorkdayEndKey), "HH:mm", - System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var time) ? time : new(17, 0); - internal int RemainingWorkdays => int.TryParse(_settings.GetSetting(RemainingWorkdaysKey), out var days) - && days is >= 1 and <= 7 ? days : 1; internal string? StatusMessage { get; private set; } @@ -225,10 +206,7 @@ private void Load() { valid[property.Name] = value; } - else if (property.Name == HistoryRetentionKey && value is "0" or "7" or "30" or "90" - || property.Name == RemainingWorkdaysKey && value is "1" or "2" or "3" or "4" or "5" or "6" or "7" - || property.Name == WorkdayEndKey && TimeOnly.TryParseExact(value, "HH:mm", - System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out _)) + else if (property.Name == HistoryRetentionKey && value is "0" or "7" or "30" or "90") { valid[property.Name] = value; } diff --git a/CodexUsageDock/UsagePlanning.cs b/CodexUsageDock/UsagePlanning.cs deleted file mode 100644 index 70fad83..0000000 --- a/CodexUsageDock/UsagePlanning.cs +++ /dev/null @@ -1,600 +0,0 @@ -using System.Globalization; - -namespace CodexUsageDock; - -internal static class UsagePlanner -{ - internal const string Disclaimer = "Planning guidance only; it is not a guarantee."; - - internal static UsagePlanningResult Plan( - UsagePresentation presentation, - DateTimeOffset now, - TimeSpan refreshInterval, - DateTimeOffset desiredEnd, - int? remainingWorkdays = null, - TimeZoneInfo? timeZone = null) - { - ArgumentNullException.ThrowIfNull(presentation); - - if (desiredEnd <= now) - { - return Unavailable(desiredEnd, "Planning unavailable: the desired end must be after now.", remainingWorkdays); - } - - if (remainingWorkdays is < 1 or > 7) - { - return Unavailable( - desiredEnd, - "Planning unavailable: remaining workdays must be between 1 and 7.", - remainingWorkdays); - } - - var snapshot = presentation.Usage; - if (presentation.IsLoading) - { - return Unavailable(desiredEnd, "Planning paused while fresh usage data is loading.", remainingWorkdays); - } - - if (snapshot.Source != UsageDataSource.AppServer) - { - var sourceMessage = snapshot.Source switch - { - UsageDataSource.LastConfirmed => "Planning paused: only last confirmed usage is available; refresh live usage first.", - UsageDataSource.Unavailable => "Planning paused: live usage is unavailable.", - UsageDataSource.LocalSession => "Planning paused: local session usage is not a verified live app-server measurement.", - _ => "Planning paused until fresh live app-server usage is available.", - }; - return Unavailable(desiredEnd, sourceMessage, remainingWorkdays); - } - - if (string.IsNullOrWhiteSpace(snapshot.AccountKey)) - { - return Unavailable(desiredEnd, "Planning paused: the verified account identity is unavailable.", remainingWorkdays); - } - - var freshness = UsageFreshness.Classify(snapshot.UpdatedAt, now, refreshInterval); - if (freshness != UsageFreshnessState.Fresh) - { - var freshnessMessage = freshness switch - { - UsageFreshnessState.Stale => "Planning paused: usage data is stale; refresh before planning.", - UsageFreshnessState.Future => "Planning paused: the usage timestamp is in the future.", - UsageFreshnessState.Unknown => "Planning paused: usage freshness is unknown.", - _ => "Planning paused until fresh usage data is available.", - }; - return Unavailable(desiredEnd, freshnessMessage, remainingWorkdays); - } - - if (snapshot.OrdinaryUsageAllowed == false) - { - return Unavailable(desiredEnd, "Planning paused: ordinary usage is currently blocked.", remainingWorkdays); - } - - try - { - var maximumSampleAge = UsageFreshness.MaximumAge(refreshInterval); - var primary = CreateWindowPlan( - snapshot.Primary, - presentation.PrimaryHistory, - now, - desiredEnd, - remainingWorkdays, - maximumSampleAge, - adaptiveCycleCount: 0, - isPrimary: true, - timeZone ?? TimeZoneInfo.Local); - var weekly = CreateWindowPlan( - snapshot.Secondary, - presentation.WeeklyHistory, - now, - desiredEnd, - remainingWorkdays, - maximumSampleAge, - CountAdaptiveCycles(presentation), - isPrimary: false, - timeZone ?? TimeZoneInfo.Local); - - if (primary is null && weekly is null) - { - return Unavailable( - desiredEnd, - "Planning paused: no valid 5-hour or weekly allowance window is available.", - remainingWorkdays); - } - - var windows = new[] { primary, weekly }.Where(window => window is not null).Cast().ToArray(); - var status = windows.Length == 2 - ? $"Planning available for both allowance windows. {Disclaimer}" - : $"Planning available for one allowance window. {Disclaimer}"; - return new UsagePlanningResult( - true, - status, - desiredEnd, - remainingWorkdays, - primary, - weekly, - Disclaimer); - } - catch (ArgumentOutOfRangeException) - { - return Unavailable(desiredEnd, "Planning paused: the usage window timestamps are out of range.", remainingWorkdays); - } - catch (OverflowException) - { - return Unavailable(desiredEnd, "Planning paused: the usage window duration is out of range.", remainingWorkdays); - } - } - - private static UsagePlanningWindow? CreateWindowPlan( - RateLimitWindow? window, - IReadOnlyList history, - DateTimeOffset now, - DateTimeOffset desiredEnd, - int? requestedWorkdays, - TimeSpan maximumSampleAge, - int adaptiveCycleCount, - bool isPrimary, - TimeZoneInfo timeZone) - { - if (!UsageFreshness.IsValidWindow(window, now)) - { - return null; - } - - var validWindow = window!; - var horizonEnd = validWindow.ResetsAt < desiredEnd ? validWindow.ResetsAt : desiredEnd; - var horizon = horizonEnd - now; - if (horizon <= TimeSpan.Zero) - { - return null; - } - - var workdays = isPrimary - ? 1 - : Math.Max(1, Math.Min(requestedWorkdays ?? 1, CalendarDaysUntilReset(validWindow.ResetsAt, now, timeZone))); - var remaining = validWindow.RemainingPercent; - var pointsPerWorkday = remaining / workdays; - var pointsPerHour = pointsPerWorkday / horizon.TotalHours; - var displayLabel = FormatWindowDuration(validWindow.WindowMinutes); - var resetsBeforeDesiredEnd = validWindow.ResetsAt < desiredEnd; - var horizonMessage = resetsBeforeDesiredEnd - ? $"This {displayLabel} window resets before the desired end at {FormatUtc(validWindow.ResetsAt)}; planning stops at that reset." - : $"Planning runs until {FormatUtc(horizonEnd)}."; - - var forecast = CreateForecast( - validWindow, - history, - now, - maximumSampleAge); - var evidence = CreateEvidence( - validWindow, - history, - now, - maximumSampleAge, - adaptiveCycleCount); - var backtest = CreateBacktest( - validWindow, - history, - now, - maximumSampleAge); - - return new UsagePlanningWindow( - displayLabel, - remaining, - validWindow.WindowMinutes, - validWindow.ResetsAt, - horizonEnd, - workdays, - horizon, - pointsPerWorkday, - pointsPerHour, - resetsBeforeDesiredEnd, - horizonMessage, - forecast, - evidence, - backtest); - } - - private static UsagePlanningForecast CreateForecast( - RateLimitWindow window, - IReadOnlyList history, - DateTimeOffset now, - TimeSpan maximumSampleAge) - { - if (!TryGetWindowStart(window, out var windowStart)) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the window duration is out of range."); - } - - try - { - var analysis = UsageTrendAnalyzer.Analyze( - history ?? Array.Empty(), - windowStart, - window.ResetsAt, - now, - dataAvailable: true, - maximumSampleAge, - adaptiveWeeklyForecastEnabled: false, - adaptiveWeeklyHistory: null); - if (analysis.Forecast is null) - { - return UsagePlanningForecast.Unavailable(analysis.ForecastStatus); - } - - return new UsagePlanningForecast(true, analysis.Forecast, analysis.ForecastStatus); - } - catch (ArgumentException) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); - } - catch (InvalidOperationException) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the measurements are not usable."); - } - catch (OverflowException) - { - return UsagePlanningForecast.Unavailable("Forecast unavailable: the timestamps are out of range."); - } - } - - private static UsagePlanningEvidence CreateEvidence( - RateLimitWindow window, - IReadOnlyList history, - DateTimeOffset now, - TimeSpan maximumSampleAge, - int adaptiveCycleCount) - { - var samples = GetWindowSamples(history, window, now); - var segment = GetLatestSegment(history, window, now, maximumSampleAge); - var measurementSpan = GetSpan(samples); - var segmentSpan = GetSpan(segment); - var summary = $"{samples.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(measurementSpan)}; " - + $"latest continuous segment: {segment.Length.ToString(CultureInfo.InvariantCulture)} measurements over {FormatDuration(segmentSpan)}; " - + $"adaptive weekly cycles: {adaptiveCycleCount.ToString(CultureInfo.InvariantCulture)}. " - + "This is descriptive evidence, not a calibrated reliability probability."; - return new UsagePlanningEvidence( - samples.Length, - measurementSpan, - segment.Length, - segmentSpan, - adaptiveCycleCount, - summary); - } - - private static UsagePlanningBacktest CreateBacktest( - RateLimitWindow window, - IReadOnlyList history, - DateTimeOffset now, - TimeSpan maximumSampleAge) - { - var segment = GetLatestSegment(history, window, now, maximumSampleAge); - if (segment.Length < 3) - { - return UsagePlanningBacktest.Unavailable( - "Backtest unavailable: at least three continuous measurements are required to hold out the latest one."); - } - - var heldOut = segment[^1]; - var training = segment[..^1]; - var first = training[0]; - var last = training[^1]; - var trainingSpan = last.RecordedAt - first.RecordedAt; - var holdoutInterval = heldOut.RecordedAt - last.RecordedAt; - if (training.Length < 2 - || trainingSpan <= TimeSpan.Zero - || holdoutInterval <= TimeSpan.Zero - || holdoutInterval > MaximumGap(maximumSampleAge)) - { - return UsagePlanningBacktest.Unavailable( - "Backtest unavailable: the held-out measurement does not follow a meaningful continuous pace."); - } - - var consumed = first.RemainingPercent - last.RemainingPercent; - var rate = consumed / trainingSpan.TotalMinutes; - if (!double.IsFinite(rate) || rate <= 0) - { - return UsagePlanningBacktest.Unavailable( - "Backtest unavailable: preceding measurements do not show a usable downward pace."); - } - - var predicted = Math.Clamp( - last.RemainingPercent - rate * holdoutInterval.TotalMinutes, - 0, - 100); - var error = Math.Abs(predicted - heldOut.RemainingPercent); - var status = $"Holdout at {FormatUtc(heldOut.RecordedAt)}: predicted {FormatPercent(predicted)}%, " - + $"actual {FormatPercent(heldOut.RemainingPercent)}%, absolute error {FormatPercent(error)} percentage points; " - + "the latest measurement was excluded from the preceding-pace calculation."; - return new UsagePlanningBacktest( - true, - training.Length, - heldOut.RecordedAt, - predicted, - heldOut.RemainingPercent, - error, - status); - } - - private static UsageHistoryEntry[] GetLatestSegment( - IReadOnlyList history, - RateLimitWindow window, - DateTimeOffset now, - TimeSpan maximumSampleAge) - { - if (!TryGetWindowStart(window, out var windowStart)) - { - return []; - } - - try - { - return UsageTrendHistory.LatestSegment( - history ?? Array.Empty(), - windowStart, - window.ResetsAt, - now, - MaximumGap(maximumSampleAge)); - } - catch (ArgumentException) - { - return []; - } - catch (InvalidOperationException) - { - return []; - } - catch (OverflowException) - { - return []; - } - } - - private static UsageHistoryEntry[] GetWindowSamples( - IReadOnlyList history, - RateLimitWindow window, - DateTimeOffset now) - { - if (!TryGetWindowStart(window, out var windowStart) || history is null) - { - return []; - } - - try - { - return history - .Where(sample => sample is not null - && double.IsFinite(sample.RemainingPercent) - && sample.RemainingPercent is >= 0 and <= 100 - && sample.RecordedAt >= windowStart - && sample.RecordedAt <= window.ResetsAt - && sample.RecordedAt <= now) - .OrderBy(sample => sample.RecordedAt) - .GroupBy(sample => sample.RecordedAt) - .Select(group => group.Last()) - .ToArray(); - } - catch (ArgumentException) - { - return []; - } - catch (InvalidOperationException) - { - return []; - } - catch (OverflowException) - { - return []; - } - } - - private static TimeSpan? GetSpan(UsageHistoryEntry[] samples) - { - if (samples.Length < 2) - { - return null; - } - - try - { - return samples[^1].RecordedAt - samples[0].RecordedAt; - } - catch (ArgumentOutOfRangeException) - { - return null; - } - } - - private static int CountAdaptiveCycles(UsagePresentation presentation) - { - var history = presentation.AdaptiveWeeklyHistory; - if (history is null) - { - return 0; - } - - var completed = history.CompletedCycles?.Count(cycle => cycle is not null) ?? 0; - return completed + (history.ActiveCycle is null ? 0 : 1); - } - - private static int CalendarDaysUntilReset(DateTimeOffset resetsAt, DateTimeOffset now, TimeZoneInfo timeZone) - { - var days = (TimeZoneInfo.ConvertTime(resetsAt, timeZone).Date - TimeZoneInfo.ConvertTime(now, timeZone).Date).Days; - if (days <= 1) - { - return 1; - } - - return days; - } - - private static TimeSpan MaximumGap(TimeSpan maximumSampleAge) - { - if (maximumSampleAge <= TimeSpan.Zero) - { - return TimeSpan.FromMinutes(15); - } - - return maximumSampleAge.Ticks > TimeSpan.MaxValue.Ticks / 3 - ? TimeSpan.MaxValue - : TimeSpan.FromTicks(maximumSampleAge.Ticks * 3); - } - - private static bool TryGetWindowStart(RateLimitWindow window, out DateTimeOffset start) - { - try - { - start = window.ResetsAt - TimeSpan.FromMinutes(window.WindowMinutes); - return true; - } - catch (ArgumentOutOfRangeException) - { - start = default; - return false; - } - catch (OverflowException) - { - start = default; - return false; - } - } - - private static string FormatDuration(TimeSpan? duration) - { - if (duration is not { } value) - { - return "unknown span"; - } - - if (value.TotalMinutes < 1) - { - return $"{Math.Max(1, (int)Math.Round(value.TotalSeconds))} seconds"; - } - - if (value.TotalHours < 1) - { - return $"{Math.Round(value.TotalMinutes, 1).ToString("0.#", CultureInfo.InvariantCulture)} minutes"; - } - - if (value.TotalDays < 1) - { - return $"{Math.Round(value.TotalHours, 1).ToString("0.#", CultureInfo.InvariantCulture)} hours"; - } - - return $"{Math.Round(value.TotalDays, 1).ToString("0.#", CultureInfo.InvariantCulture)} days"; - } - - private static string FormatPercent(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); - - private static string FormatWindowDuration(int minutes) - { - if (minutes == (int)TimeSpan.FromHours(5).TotalMinutes) - { - return "5-hour"; - } - - if (minutes == (int)TimeSpan.FromDays(7).TotalMinutes) - { - return "weekly"; - } - - if (minutes > 0 && minutes % (int)TimeSpan.FromDays(1).TotalMinutes == 0) - { - return $"{minutes / (int)TimeSpan.FromDays(1).TotalMinutes}-day"; - } - - if (minutes > 0 && minutes % 60 == 0) - { - return $"{minutes / 60}-hour"; - } - - return $"{minutes}-minute"; - } - - private static string FormatUtc(DateTimeOffset value) => - value.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'", CultureInfo.InvariantCulture); - - private static UsagePlanningResult Unavailable( - DateTimeOffset desiredEnd, - string status, - int? remainingWorkdays) => - new(false, status, desiredEnd, remainingWorkdays, null, null, Disclaimer); -} - -internal sealed record UsagePlanningResult( - bool IsAvailable, - string Status, - DateTimeOffset DesiredEnd, - int? RequestedWorkdays, - UsagePlanningWindow? Primary, - UsagePlanningWindow? Weekly, - string Disclaimer) -{ - internal UsagePlanningWindow? FiveHour => Primary; - - internal IReadOnlyList Windows => - new[] { Primary, Weekly }.Where(window => window is not null).Cast().ToArray(); - - internal IReadOnlyList Evidence => - Windows.Select(window => window.Evidence).ToArray(); - - internal IReadOnlyList Backtests => - Windows.Select(window => window.Backtest).ToArray(); -} - -internal sealed record UsagePlanningWindow( - string Label, - double RemainingPercent, - int WindowMinutes, - DateTimeOffset ResetsAt, - DateTimeOffset HorizonEnd, - int Workdays, - TimeSpan Horizon, - double AvailablePointsPerWorkday, - double AvailablePointsPerHour, - bool ResetsBeforeDesiredEnd, - string HorizonMessage, - UsagePlanningForecast Forecast, - UsagePlanningEvidence Evidence, - UsagePlanningBacktest Backtest) -{ - internal double Remaining => RemainingPercent; - internal double QuotaPointsPerWorkday => AvailablePointsPerWorkday; - internal double QuotaPointsPerHour => AvailablePointsPerHour; -} - -internal sealed record UsagePlanningForecast( - bool IsAvailable, - UsageTrendForecast? Projection, - string Status) -{ - internal UsageTrendForecast? Forecast => Projection; - internal DateTimeOffset? EndsAt => Projection?.EndsAt; - internal bool ReachesLimitBeforeReset => Projection?.ReachesLimitBeforeReset ?? false; - - internal static UsagePlanningForecast Unavailable(string status) => new(false, null, status); -} - -internal sealed record UsagePlanningEvidence( - int MeasurementCount, - TimeSpan? MeasurementSpan, - int SegmentMeasurementCount, - TimeSpan? SegmentSpan, - int AdaptiveCycleCount, - string Summary) -{ - internal string Description => Summary; -} - -internal sealed record UsagePlanningBacktest( - bool IsAvailable, - int TrainingSampleCount, - DateTimeOffset? HeldOutAt, - double? PredictedRemainingPercent, - double? ActualRemainingPercent, - double? AbsoluteErrorPercentagePoints, - string Status) -{ - internal string Message => Status; - internal static UsagePlanningBacktest Unavailable(string status) => new(false, 0, null, null, null, null, status); -} diff --git a/PRIVACY.md b/PRIVACY.md index 4bb3445..244ea7a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -16,7 +16,7 @@ Communication initiated by the extension is limited to the local Codex app-serve ## Data storage -Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, planning, retention, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. +Settings are saved explicitly in `CodexUsageDock/settings.json` under the current Windows user's local application data directory, alongside the local history files. This file contains display, refresh, retention, and forecasting preferences. Failed writes are reported without logging file contents, personal paths, or credentials. Learned-history deletion is confirmed only after its empty state has been saved successfully. The extension does not create an external user account or remote database. Settings and temporary runtime state remain on the user's Windows device. Daily token totals and per-file read positions are kept only in memory and are rebuilt from the current weekly window after a restart. To keep the weekly usage trend available after Command Palette restarts, it stores a rolling maximum of seven days of local timestamps and remaining weekly-percentage measurements. When the adaptive weekly forecast is enabled, it also stores at most eight aggregated quota-cycle profiles: total observed duration and consumption, plus six-hour usage buckets relative to the reset. These files contain no account, session, prompt, or message content and are never transmitted. Users can pause learning while keeping those profiles; measurements collected while paused are not added later. Users can also delete the learned profiles from Codex Usage settings. diff --git a/README.md b/README.md index 8f0ab81..ab7dc91 100644 --- a/README.md +++ b/README.md @@ -72,11 +72,11 @@ Saved source paths, source labels, and Claude preferences from earlier developme **Codex usage in text**, also available from Details, provides quota tables, reset times, recent measured weekly points, and local daily token totals without relying on charts or color. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. -## History, planning, and optional account actions +## History and optional account actions **Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. -**Codex workday planner** uses today's local **Workday end** and your chosen **Workdays remaining before weekly reset**. It divides remaining weekly allowance across those days and today's remaining hours; the five-hour allowance is budgeted independently. Planning stops at an earlier reset and pauses after today's chosen end. It requires fresh, identified live data. The page explains the measurement span, continuous segment, learned-cycle count, and a held-out latest-observation check where sufficient data exists. This is descriptive evidence, not a calibrated confidence percentage or a guarantee. Its recent-pace estimate is separate from the dashboard's optional adaptive weekly forecast. +The workday planner and its end-time and remaining-workdays settings have been removed. Forecasts use observed usage without requiring a work schedule. Saved planner preferences from earlier development builds are ignored and omitted the next time settings are saved; other preferences and usage history are preserved. **Codex task usage and earned resets** accepts an explicit task ID for `account/usage/read` on compatible CLI versions. It shows server-estimated credits and optional USD, plus model/effort/speed and available input/cached/output token groups. These estimates are not invoices or conversions of quota percentages. Task reads verify account identity before and after, keep the most recently requested task, and retain results only in memory. diff --git a/SPRINTS.md b/SPRINTS.md index 179e67c..60e1765 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -2,7 +2,7 @@ This series implements a Codex-only usage roadmap. Source work uses separate feature branches and pull requests. Merge, Store publication, and installation are separate steps. -The sprint records below describe the original PRs and their historical verification. The current implementation removes the Claude pilot, manual source-path settings, and named source profiles, while retaining automatic Codex detection, bounded fallback reads, and accessible text views. See [CHANGELOG.md](CHANGELOG.md) for the current scope. +The sprint records below describe the original PRs and their historical verification. The current implementation removes the Claude pilot, manual source-path settings, named source profiles, and workday planner, while retaining automatic Codex detection, bounded fallback reads, accessible text views, and usage-based forecasts. See [CHANGELOG.md](CHANGELOG.md) for the current scope. | Sprint | Feature branch | Scope | Status | | --- | --- | --- | --- | From c8d1785f0013bdc94cbae5335b1303a1ef6756c3 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:10:13 +0200 Subject: [PATCH 20/21] Pin planner removal changelog to implementation commit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45364d..becd2df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ Each entry links to the commit or pull request that introduced the change. ### Removed -- Remove the workday planner and its workday-end and remaining-workdays settings. Existing planner preferences are ignored and dropped on the next settings save; usage displays, reset times, forecasts, and history remain available. ([implementation](https://github.com/TheBeems/CodexUsageDock/commit/codex/codex-only-settings)) +- Remove the workday planner and its workday-end and remaining-workdays settings. Existing planner preferences are ignored and dropped on the next settings save; usage displays, reset times, forecasts, and history remain available. ([commit 263c22a](https://github.com/TheBeems/CodexUsageDock/commit/263c22a650f2b7062515f94e983023e337dc7610)) - Remove the experimental Claude integration and capture script, manual Codex path settings, and source profiles to keep the extension focused on automatically detected Codex usage. Older source preferences are ignored while other saved Codex choices are preserved. ([commit 39bf74c](https://github.com/TheBeems/CodexUsageDock/commit/39bf74cd476920441146f37737ad1216a9c0b8ad)) ## [0.6.1] - 2026-09-09 From b9f9383f812252360710c956c271bb07d16fc2a4 Mon Sep 17 00:00:00 2001 From: Mathijs Beemsterboer <15211332+TheBeems@users.noreply.github.com> Date: Sun, 13 Sep 2026 08:42:10 +0200 Subject: [PATCH 21/21] Remove obsolete unreleased planner changelog entries --- CHANGELOG.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index becd2df..2e8827c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,6 @@ Each entry links to the commit or pull request that introduced the change. - A text alternative for Codex quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) -- A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Optional quiet usage alerts from fresh, identified accounts, compact Dock labels, and separate pinnable quota and credit entries with stable identifiers. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) - Account-wide daily token activity on compatible Codex versions, with independent refresh and account-identity verification. ([PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19)) @@ -25,7 +24,6 @@ Each entry links to the commit or pull request that introduced the change. - Switching Dock modes or hiding a metric no longer restores inactive saved bands. Existing band objects are retained, and usage refreshes update their items without reloading the whole provider. ([PR #22](https://github.com/TheBeems/CodexUsageDock/pull/22)) - Skip inaccessible session subdirectories during local fallback discovery. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Serialize source-sensitive presentation changes so delayed updates cannot restore old account values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) -- Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18))