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 1/3] 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 2/3] 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 3/3] 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]