diff --git a/CHANGELOG.md b/CHANGELOG.md index c8132f6..021a482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Each entry links to the commit or pull request that introduced the change. ### Added +- An opt-in Claude pilot with an independent Dock band and a local statusline capture script that preserves an existing formatter or supplies a standalone quota line. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- Named local/Windows-accessible WSL source profiles and a text alternative for quota, reset, trend, and local token data. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Optional account-scoped quota history with 7/30/90-day retention, explicit CSV/JSON exports, and confirmed deletion. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - A workday planner with per-day and per-hour quota budgets, measurement evidence, and held-out recent-pace checks. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Task-level server usage estimates and explicitly confirmed earned resets with account verification and persistent request IDs for safe retries. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) @@ -21,12 +23,15 @@ Each entry links to the commit or pull request that introduced the change. ### Fixed +- Skip inaccessible session subdirectories during local fallback discovery and recheck Claude capture freshness when the refresh interval changes. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) +- Keep provider updates independent and serialize source-sensitive presentation changes so delayed updates cannot restore old account or Claude values. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Calculate planner workday budgets from reset and current dates in the same local time zone. ([PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20)) - Keep the last confirmed live measurement during outages, without resetting its age or continuing projections and learning. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) - Apply one freshness policy across the Dock, details, and forecasts, and keep account/category history isolated. Unidentified legacy history is no longer imported into verified accounts. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) ### Changed +- Cache local quota fallback read positions with bounded content reads and memory, while reporting incomplete scans and preserving event-time selection. ([PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21)) - Expanded the release skill to cover scoped commit/push, Store submission, resumable certification tracking, and verified installation, with a repository-local Codex entry point. ([commit e54709c](https://github.com/TheBeems/CodexUsageDock/commit/e54709ce6e26b9aaa072d6f88625a9a3aa067494)) - Distinguish source releases, the running extension build, and Microsoft Store rollout in installation guidance. ([PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18)) diff --git a/CodexUsageDock.Tests/CachedSessionReaderTests.cs b/CodexUsageDock.Tests/CachedSessionReaderTests.cs new file mode 100644 index 0000000..08ec044 --- /dev/null +++ b/CodexUsageDock.Tests/CachedSessionReaderTests.cs @@ -0,0 +1,419 @@ +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CachedSessionReaderTests : IDisposable +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private readonly TestEnvironment _environment = new(); + + private string HomePath => _environment.PathFor("cached-codex-home"); + + public void Dispose() => _environment.Dispose(); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void InaccessibleSubdirectoryDoesNotHideLaterReadableSessions(bool archived) + { + var root = Path.Combine(HomePath, archived ? "archived_sessions" : "sessions"); + var blocked = Directory.CreateDirectory(Path.Combine(root, "000-blocked")); + var readable = Directory.CreateDirectory(Path.Combine(root, "zzz-readable")); + File.WriteAllText(Path.Combine(readable.FullName, "rollout-valid.jsonl"), QuotaLine(Now, 25)); + var originalAccess = blocked.GetAccessControl(); + var deniedAccess = blocked.GetAccessControl(); + using var identity = WindowsIdentity.GetCurrent(); + deniedAccess.AddAccessRule(new FileSystemAccessRule(identity.User!, FileSystemRights.ListDirectory, AccessControlType.Deny)); + try + { + blocked.SetAccessControl(deniedAccess); + Assert.Throws(() => Directory.GetFiles(blocked.FullName)); + var reader = CreateReader(); + + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + Assert.Equal(0, reader.BytesReadLastScan); + } + finally + { + deniedAccess.SetSecurityDescriptorBinaryForm(originalAccess.GetSecurityDescriptorBinaryForm(), AccessControlSections.Access); + blocked.SetAccessControl(deniedAccess); + } + } + + [Fact] + public void SelectionUsesEventTimeAcrossActiveAndArchivedFilesAndClearsInactiveWindows() + { + WriteSession("rollout-active.jsonl", QuotaLine(Now.AddHours(-2), 70, 40)); + var archived = WriteSession("rollout-archived.jsonl", + QuotaLine(Now.AddMinutes(-10), null, null) + QuotaLine(Now.AddHours(-3), 80, 60), archived: true); + File.SetLastWriteTimeUtc(archived, Now.AddDays(-10).UtcDateTime); + var reader = CreateReader(); + + var result = reader.ReadLatest(); + + Assert.Equal(Now.AddMinutes(-10), result.UpdatedAt); + Assert.Null(result.Primary); + Assert.Null(result.Secondary); + Assert.Null(result.AccountKey); + Assert.Equal(UsageDataSource.LocalSession, result.Source); + Assert.True(reader.LastScanComplete); + Assert.Equal(2, reader.CachedFileCount); + } + + [Fact] + public void UnchangedCachedFilesReadZeroContentBytesEvenWhenTheirContentCannotBeOpened() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now, 25)); + var reader = CreateReader(); + var first = reader.ReadLatest(); + Assert.Equal(new FileInfo(path).Length, reader.BytesReadLastScan); + using var held = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); + + var unchanged = reader.ReadLatest(); + + Assert.Equal(first, unchanged); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void AppendingReadsOnlyTheNewBytesAndPreservesTheMeasurementTimestamp() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = QuotaLine(Now.AddMinutes(-5), 45); + File.AppendAllText(path, appended); + + var result = reader.ReadLatest(); + + Assert.Equal(Encoding.UTF8.GetByteCount(appended), reader.BytesReadLastScan); + Assert.Equal(Now.AddMinutes(-5), result.UpdatedAt); + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + reader.ReadLatest(); + Assert.Equal(0, reader.BytesReadLastScan); + } + + [Fact] + public void ACompleteJsonObjectWithoutItsNewlineIsNotPublishedUntilTheLineCompletes() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = QuotaLine(Now, 45).TrimEnd('\n'); + File.AppendAllText(path, appended); + + var unfinished = reader.ReadLatest(); + + Assert.Equal(25, unfinished.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + Assert.Contains("scan incomplete", unfinished.Error, StringComparison.Ordinal); + reader.ReadLatest(); + Assert.Equal(0, reader.BytesReadLastScan); + File.AppendAllText(path, "\n"); + + var completed = reader.ReadLatest(); + + Assert.Equal(1, reader.BytesReadLastScan); + Assert.Equal(45, completed.Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + Assert.Null(completed.Error); + } + + [Fact] + public void Utf8CharactersSplitAcrossAppendsAreParsedOnlyAfterTheRecordCompletes() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = Encoding.UTF8.GetBytes(QuotaLine(Now, 40).Replace("\"pro\"", "\"café\"", StringComparison.Ordinal)); + var split = Array.IndexOf(appended, (byte)0xC3) + 1; + Assert.True(split > 0); + using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write)) stream.Write(appended.AsSpan(0, split)); + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + using (var stream = new FileStream(path, FileMode.Append, FileAccess.Write)) stream.Write(appended.AsSpan(split)); + + var completed = reader.ReadLatest(); + + Assert.Equal("café", completed.PlanType); + Assert.Equal(appended.Length - split, reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void TruncatingAFileInvalidatesItsPreviousMeasurementAndPartialLine() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now, 95) + new string('x', 4096)); + var reader = CreateReader(); + Assert.Equal(95, reader.ReadLatest().Primary!.UsedPercent); + var replacement = QuotaLine(Now.AddHours(-1), 10); + File.WriteAllText(path, replacement); + + var result = reader.ReadLatest(); + + Assert.Equal(10, result.Primary!.UsedPercent); + Assert.Equal(Now.AddHours(-1), result.UpdatedAt); + Assert.Equal(Encoding.UTF8.GetByteCount(replacement), reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void RewritingTheSameLengthWithANewModificationTimeInvalidatesTheCheckpoint() + { + var initial = QuotaLine(Now, 95); + var replacement = QuotaLine(Now, 10); + Assert.Equal(initial.Length, replacement.Length); + var path = WriteSession("rollout-one.jsonl", initial); + var reader = CreateReader(); + reader.ReadLatest(); + File.WriteAllText(path, replacement); + File.SetLastWriteTimeUtc(path, Now.AddMinutes(1).UtcDateTime); + + var result = reader.ReadLatest(); + + Assert.Equal(10, result.Primary!.UsedPercent); + Assert.Equal(Encoding.UTF8.GetByteCount(replacement), reader.BytesReadLastScan); + } + + [Fact] + public void AChangedCreationTimeInvalidatesAReplacedFileEvenWhenItGrew() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now, 95)); + File.SetCreationTimeUtc(path, Now.AddDays(-2).UtcDateTime); + var reader = CreateReader(); + reader.ReadLatest(); + var replacement = QuotaLine(Now.AddHours(-1), 10) + "{\"message\":\"replacement\"}\n"; + File.WriteAllText(path, replacement); + File.SetCreationTimeUtc(path, Now.AddDays(-1).UtcDateTime); + + var result = reader.ReadLatest(); + + Assert.Equal(10, result.Primary!.UsedPercent); + Assert.Equal(Now.AddHours(-1), result.UpdatedAt); + Assert.Equal(Encoding.UTF8.GetByteCount(replacement), reader.BytesReadLastScan); + } + + [Fact] + public void DeletingTheLatestFileFallsBackToAnotherCachedMeasurementWithoutRereadingIt() + { + WriteSession("rollout-older.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var newest = WriteSession("rollout-newest.jsonl", QuotaLine(Now, 45)); + var reader = CreateReader(); + Assert.Equal(45, reader.ReadLatest().Primary!.UsedPercent); + File.Delete(newest); + + var result = reader.ReadLatest(); + + Assert.Equal(25, result.Primary!.UsedPercent); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.Equal(1, reader.CachedFileCount); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void UnreadableFilesAreRetriedAndMalformedOrFutureRecordsDoNotReplaceValidUsage() + { + WriteSession("rollout-readable.jsonl", QuotaLine(Now.AddHours(-1), 25) + + "[\"rate_limits\"]\n{\"rate_limits\":invalid}\n" + QuotaLine(Now.AddHours(1), 99)); + var locked = WriteSession("rollout-locked.jsonl", QuotaLine(Now, 45)); + var reader = CreateReader(); + using (var held = new FileStream(locked, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var result = reader.ReadLatest(); + Assert.Equal(25, result.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + Assert.Contains("scan incomplete", result.Error, StringComparison.Ordinal); + } + + var recovered = reader.ReadLatest(); + + Assert.Equal(45, recovered.Primary!.UsedPercent); + Assert.Equal(new FileInfo(locked).Length, reader.BytesReadLastScan); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void InitialBudgetExhaustionReturnsObservedUsageAndContinuesFromItsCheckpoint() + { + const int budget = 512; + var path = WriteSession("rollout-large.jsonl", QuotaLine(Now.AddHours(-1), 25) + + new string('x', 4096) + "\n" + QuotaLine(Now, 45)); + var reader = CreateReader(maxBytesPerScan: budget, maxLineBytes: 1024, fileReadQuantum: 256); + + var result = reader.ReadLatest(); + long totalBytes = reader.BytesReadLastScan; + + Assert.Equal(25, result.Primary!.UsedPercent); + Assert.Equal(budget, reader.BytesReadLastScan); + Assert.False(reader.LastScanComplete); + Assert.Contains("scan incomplete", result.Error, StringComparison.Ordinal); + for (var scan = 0; scan < 20 && !reader.LastScanComplete; scan++) + { + result = reader.ReadLatest(); + Assert.InRange(reader.BytesReadLastScan, 0, budget); + totalBytes += reader.BytesReadLastScan; + } + Assert.True(reader.LastScanComplete); + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.Equal(new FileInfo(path).Length, totalBytes); + Assert.Null(result.Error); + } + + [Fact] + public void ANewFileReceivesAReadTurnWhileAnOlderLargeFileIsStillPending() + { + WriteSession("rollout-large.jsonl", QuotaLine(Now.AddHours(-1), 25) + new string('x', 50_000) + "\n"); + var reader = CreateReader(maxBytesPerScan: 1024, fileReadQuantum: 512); + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + WriteSession("rollout-newest.jsonl", QuotaLine(Now, 45)); + + var result = reader.ReadLatest(); + + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + Assert.InRange(reader.BytesReadLastScan, 0, 1024); + } + + [Fact] + public void CacheOverflowRotatesFilesAndKeepsTheNewestObservedEventWhenItsCheckpointIsEvicted() + { + for (var index = 0; index < 5; index++) + WriteSession($"rollout-{index}.jsonl", QuotaLine(Now.AddMinutes(index - 5), 20 + index)); + var reader = CreateReader(maxCachedFiles: 2); + CodexUsageSnapshot? result = null; + for (var scan = 0; scan < 8; scan++) + { + result = reader.ReadLatest(); + Assert.InRange(reader.CachedFileCount, 1, 2); + Assert.False(reader.LastScanComplete); + } + + Assert.Equal(24, result!.Primary!.UsedPercent); + Assert.Contains("scan incomplete", result.Error, StringComparison.Ordinal); + Assert.Null(result.AccountKey); + } + + [Fact] + public void OverflowDoesNotEvictEveryPartialRecordBeforeAnyRecordCanFinish() + { + for (var index = 0; index < 3; index++) + WriteSession($"rollout-{index}.jsonl", QuotaLine(Now.AddMinutes(index - 3), 20 + index)); + var reader = CreateReader(maxBytesPerScan: 128, maxCachedFiles: 2, fileReadQuantum: 128); + CodexUsageSnapshot? result = null; + for (var scan = 0; scan < 30; scan++) + { + try { result = reader.ReadLatest(); } + catch (InvalidOperationException) { /* The first complete measurement may require multiple bounded reads. */ } + Assert.InRange(reader.CachedFileCount, 1, 2); + Assert.InRange(reader.BytesReadLastScan, 0, 128); + } + + Assert.NotNull(result); + Assert.Equal(22, result.Primary!.UsedPercent); + } + + [Fact] + public void AnOversizedUnfinishedLineIsDiscardedAndTheNextCompleteRecordCanStillBeRead() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(maxLineBytes: 1024); + reader.ReadLatest(); + File.AppendAllText(path, new string('x', 50_000)); + var unfinished = reader.ReadLatest(); + Assert.Equal(25, unfinished.Primary!.UsedPercent); + Assert.False(reader.LastScanComplete); + File.AppendAllText(path, "\n" + QuotaLine(Now, 45)); + + var result = reader.ReadLatest(); + + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void PreCancelledReadDoesNotTouchContentOrLoseAnExistingCheckpoint() + { + var path = WriteSession("rollout-one.jsonl", QuotaLine(Now.AddHours(-1), 25)); + var reader = CreateReader(); + reader.ReadLatest(); + var appended = QuotaLine(Now, 45); + File.AppendAllText(path, appended); + + Assert.Throws(() => reader.ReadLatest(new CancellationToken(canceled: true))); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.False(reader.LastScanComplete); + + var result = reader.ReadLatest(); + + Assert.Equal(45, result.Primary!.UsedPercent); + Assert.Equal(Encoding.UTF8.GetByteCount(appended), reader.BytesReadLastScan); + } + + [Fact] + public void CancellationDuringDiscoveryStopsBeforeContentReadsAndCanBeRetried() + { + WriteSession("rollout-one.jsonl", QuotaLine(Now, 25)); + using var cancellation = new CancellationTokenSource(); + var cancelAtClock = true; + var reader = new CachedCodexSessionReader(HomePath, clock: () => + { + if (cancelAtClock) cancellation.Cancel(); + return Now; + }); + + Assert.Throws(() => reader.ReadLatest(cancellation.Token)); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.Equal(0, reader.CachedFileCount); + cancelAtClock = false; + + Assert.Equal(25, reader.ReadLatest().Primary!.UsedPercent); + Assert.True(reader.LastScanComplete); + } + + [Fact] + public void MissingSourcesRemainUnavailableWithoutCreatingAnyDirectories() + { + var reader = CreateReader(); + + Assert.Throws(() => reader.ReadLatest()); + + Assert.False(Directory.Exists(HomePath)); + Assert.Equal(0, reader.BytesReadLastScan); + Assert.Equal(0, reader.CachedFileCount); + } + + private CachedCodexSessionReader CreateReader(long maxBytesPerScan = 8 * 1024 * 1024, + int maxCachedFiles = 512, int maxLineBytes = 128 * 1024, int fileReadQuantum = 64 * 1024) => + new(HomePath, maxBytesPerScan, maxCachedFiles, maxLineBytes, fileReadQuantum, () => Now); + + private string WriteSession(string name, string content, bool archived = false) + { + var directory = Path.Combine(HomePath, archived ? "archived_sessions" : "sessions", "2026", "09", "09"); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, name); + File.WriteAllText(path, content); + File.SetLastWriteTimeUtc(path, Now.UtcDateTime); + return path; + } + + private static string QuotaLine(DateTimeOffset recordedAt, int? primary, int? secondary = 40) => + JsonSerializer.Serialize(new + { + timestamp = recordedAt, + payload = new + { + rate_limits = new + { + primary = primary is { } shortUsed ? new { used_percent = shortUsed, window_minutes = 300, resets_at = Now.AddHours(4).ToUnixTimeSeconds() } : null, + secondary = secondary is { } weeklyUsed ? new { used_percent = weeklyUsed, window_minutes = 10080, resets_at = Now.AddDays(3).ToUnixTimeSeconds() } : null, + plan_type = "pro", + }, + }, + }) + "\n"; +} diff --git a/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs b/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs new file mode 100644 index 0000000..32d1dbc --- /dev/null +++ b/CodexUsageDock.Tests/ClaudeUsageReaderTests.cs @@ -0,0 +1,367 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class ClaudeUsageReaderTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(1); + + [Fact] + public void ReaderParsesDocumentedWindowsAndIgnoresUnrelatedFields() + { + var snapshot = ReadJson( + CaptureJson( + Now, + WindowJson(23.5, Now.AddHours(1)), + WindowJson(41.25, Now.AddDays(3)), + extra: "\"private\":{\"account\":\"secret\",\"model\":\"private-model\"}")); + + Assert.Equal(ClaudeUsageReadStatus.Available, snapshot.Status); + Assert.True(snapshot.IsAvailable); + Assert.Equal(23.5, snapshot.Primary!.UsedPercent); + Assert.Equal(300, snapshot.Primary!.WindowMinutes); + Assert.Equal(76.5, snapshot.Primary!.RemainingPercent); + Assert.Equal(Now.AddHours(1), snapshot.Primary!.ResetsAt); + Assert.Equal(41.25, snapshot.Weekly!.UsedPercent); + Assert.Equal(10080, snapshot.Weekly!.WindowMinutes); + Assert.Equal(Now, snapshot.ObservedAt); + } + + [Fact] + public void ReaderRequiresTheBridgeSchemaAndDoesNotAcceptUnspecifiedAliases() + { + var aliases = """ + { + "schemaVersion": 1, + "provider": "claude", + "observedAtUTC": "2026-09-09T12:00:00.0000000Z", + "fiveHour": { "usedPercentage": 20, "resetsAt": 1788958800 }, + "sevenDay": { "usedPercentage": 30, "resetsAt": 1789214400 } + } + """; + + var snapshot = ReadJson(aliases); + + Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); + Assert.Null(snapshot.Primary); + Assert.Null(snapshot.Weekly); + } + + [Fact] + public void ReaderKeepsWindowsIndependentWhenOneIsMissingOrInvalid() + { + var missingWeekly = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddHours(1)), null)); + var invalidPrimary = ReadJson(CaptureJson(Now, WindowJson(-1, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + + Assert.Equal(ClaudeUsageReadStatus.Partial, missingWeekly.Status); + Assert.NotNull(missingWeekly.Primary); + Assert.Null(missingWeekly.Weekly); + Assert.Equal(ClaudeUsageReadStatus.Partial, invalidPrimary.Status); + Assert.Null(invalidPrimary.Primary); + Assert.NotNull(invalidPrimary.Weekly); + } + + [Fact] + public void ReaderMarksFutureAndStaleObservationsWithoutCallingThemAvailable() + { + var future = ReadJson(CaptureJson(Now.AddSeconds(1), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + var stale = ReadJson(CaptureJson(Now.AddMinutes(-6), WindowJson(20, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + + Assert.Equal(ClaudeUsageReadStatus.Future, future.Status); + Assert.False(future.IsAvailable); + Assert.NotNull(future.Primary); + Assert.Equal(ClaudeUsageReadStatus.Stale, stale.Status); + Assert.False(stale.IsAvailable); + Assert.NotNull(stale.Weekly); + } + + [Fact] + public void ReaderRejectsZoneLessObservationTimes() + { + var zoneLess = """ + { + "schemaVersion": 1, + "provider": "claude", + "observedAtUTC": "2026-09-09T12:00:00", + "rate_limits": { + "five_hour": { "used_percentage": 20, "resets_at": 1788958800 }, + "seven_day": { "used_percentage": 30, "resets_at": 1789214400 } + } + } + """; + + Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson(zoneLess).Status); + } + + [Fact] + public void ReaderRejectsExpiredAndOutOfRangeWindowValues() + { + var expiredPrimary = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddDays(3)))); + var expiredBoth = ReadJson(CaptureJson(Now, WindowJson(20, Now.AddSeconds(-1)), WindowJson(30, Now.AddSeconds(-1)))); + var outOfRange = ReadJson(CaptureJson(Now, WindowJson(101, Now.AddHours(1)), WindowJson(30, Now.AddDays(3)))); + + Assert.Equal(ClaudeUsageReadStatus.Partial, expiredPrimary.Status); + Assert.Null(expiredPrimary.Primary); + Assert.NotNull(expiredPrimary.Weekly); + Assert.Equal(ClaudeUsageReadStatus.Unavailable, expiredBoth.Status); + Assert.Equal(ClaudeUsageReadStatus.Partial, outOfRange.Status); + Assert.Null(outOfRange.Primary); + } + + [Fact] + public void ReaderRejectsMissingMalformedAndOversizedFilesSafely() + { + Assert.Equal( + ClaudeUsageReadStatus.Unavailable, + ClaudeUsageReader.Read( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.json"), + Now, + RefreshInterval).Status); + Assert.Equal(ClaudeUsageReadStatus.Unavailable, ReadJson("not-json").Status); + + var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); + try + { + File.WriteAllBytes(path, new byte[ClaudeUsageReader.MaximumFileBytes + 1]); + var snapshot = ClaudeUsageReader.Read(path, Now, RefreshInterval); + Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void ReaderRequiresAQualifiedCapturePath() + { + var snapshot = ClaudeUsageReader.Read("relative-claude-usage.json", Now, RefreshInterval); + + Assert.Equal(ClaudeUsageReadStatus.Unavailable, snapshot.Status); + Assert.Contains("fully qualified", snapshot.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ScriptPassesThroughStatuslineAndWritesOnlyAggregateWindows() + { + var input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}},\"workspace\":{\"path\":\"private\"}}"; + var result = RunCaptureScript(input); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + Assert.DoesNotContain("do-not-copy", result.SnapshotJson, StringComparison.Ordinal); + using var document = JsonDocument.Parse(result.SnapshotJson); + var root = document.RootElement; + Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32()); + Assert.Equal("claude", root.GetProperty("provider").GetString()); + Assert.EndsWith("+00:00", root.GetProperty("observedAtUTC").GetString()!, StringComparison.Ordinal); + var limits = root.GetProperty("rate_limits"); + Assert.Equal(23.5, limits.GetProperty("five_hour").GetProperty("used_percentage").GetDouble()); + Assert.Equal(41, limits.GetProperty("seven_day").GetProperty("used_percentage").GetDouble()); + Assert.DoesNotContain("model", result.SnapshotJson, StringComparison.Ordinal); + Assert.DoesNotContain("workspace", result.SnapshotJson, StringComparison.Ordinal); + } + + [Fact] + public void ScriptStandaloneModeSuppressesRawStatuslineMetadata() + { + const string input = "{\"model\":\"private-model\",\"api_key\":\"do-not-copy\",\"rate_limits\":{\"five_hour\":{\"used_percentage\":23.5,\"resets_at\":4102444800},\"seven_day\":{\"used_percentage\":41,\"resets_at\":4102444800}}}"; + var result = RunCaptureScript(input, standalone: true); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("Claude 5h 76.5% / week 59%", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("private-model", result.StandardOutput, StringComparison.Ordinal); + Assert.DoesNotContain("do-not-copy", result.StandardOutput, StringComparison.Ordinal); + } + + [Fact] + public void ScriptDrainsAndPassesThroughInputWhenDestinationIsRelative() + { + const string input = "{\"rate_limits\":{},\"private\":\"unchanged\"}"; + var result = RunCaptureScript(input, "relative-claude-capture.json"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ScriptWritesUnavailableSnapshotForMissingOrInvalidFields() + { + var input = "{\"rate_limits\":{\"five_hour\":{\"used_percentage\":\"75\",\"resets_at\":0},\"seven_day\":{\"used_percentage\":101,\"resets_at\":0}},\"secret\":\"keep-out\"}"; + var result = RunCaptureScript(input); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + using var document = JsonDocument.Parse(result.SnapshotJson); + var root = document.RootElement; + Assert.Equal("unavailable", root.GetProperty("status").GetString()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("five_hour").ValueKind); + Assert.Equal(JsonValueKind.Null, root.GetProperty("rate_limits").GetProperty("seven_day").ValueKind); + Assert.DoesNotContain("keep-out", result.SnapshotJson, StringComparison.Ordinal); + } + + [Fact] + public void ScriptBoundsCapturedInputButStillPassesThroughOversizedStatuslineData() + { + var input = new string('x', 256 * 1024 + 1); + var result = RunCaptureScript(input); + + Assert.Equal(0, result.ExitCode); + Assert.Equal(input, result.StandardOutput); + using var document = JsonDocument.Parse(result.SnapshotJson); + Assert.Equal("unavailable", document.RootElement.GetProperty("status").GetString()); + } + + [Fact] + public void ScriptRequiresAnAbsoluteDestinationAndReportsWriteFailuresGenerically() + { + var script = FindScript(); + var destinationDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(destinationDirectory); + try + { + var result = RunCaptureScript("{}", destinationDirectory); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("could not write the snapshot", result.StandardError, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("{}", result.StandardError, StringComparison.Ordinal); + Assert.NotEmpty(script); + } + finally + { + Directory.Delete(destinationDirectory, recursive: true); + } + } + + private static ClaudeUsageSnapshot ReadJson( + string json, + DateTimeOffset? now = null, + TimeSpan? refreshInterval = null) + { + var path = Path.Combine(Path.GetTempPath(), $"claude-{Guid.NewGuid():N}.json"); + try + { + File.WriteAllText(path, json, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + return ClaudeUsageReader.Read(path, now ?? Now, refreshInterval ?? RefreshInterval); + } + finally + { + File.Delete(path); + } + } + + private static string CaptureJson( + DateTimeOffset observedAt, + string? primary, + string? weekly, + string? extra = null) + { + var primaryText = primary ?? "null"; + var weeklyText = weekly ?? "null"; + var suffix = string.IsNullOrWhiteSpace(extra) ? string.Empty : $",{extra}"; + return "{\"schemaVersion\":1,\"provider\":\"claude\",\"observedAtUTC\":\"" + + observedAt.ToString("O", CultureInfo.InvariantCulture) + + "\",\"rate_limits\":{\"five_hour\":" + + primaryText + + ",\"seven_day\":" + + weeklyText + + "}" + + suffix + + "}"; + } + + private static string WindowJson(double usedPercent, DateTimeOffset resetsAt) => + "{\"used_percentage\":" + + usedPercent.ToString(CultureInfo.InvariantCulture) + + ",\"resets_at\":" + + Unix(resetsAt) + + "}"; + + private static long Unix(DateTimeOffset timestamp) => timestamp.ToUnixTimeSeconds(); + + private static ScriptResult RunCaptureScript(string input, string? outputPath = null, bool standalone = false) + { + var destination = outputPath ?? Path.Combine(Path.GetTempPath(), $"claude-capture-{Guid.NewGuid():N}.json"); + try + { + var startInfo = new ProcessStartInfo + { + FileName = FindPowerShell(), + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardOutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + StandardErrorEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + }; + startInfo.ArgumentList.Add("-NoLogo"); + startInfo.ArgumentList.Add("-NoProfile"); + startInfo.ArgumentList.Add("-NonInteractive"); + startInfo.ArgumentList.Add("-File"); + startInfo.ArgumentList.Add(FindScript()); + startInfo.ArgumentList.Add("-OutputPath"); + startInfo.ArgumentList.Add(destination); + if (standalone) + { + startInfo.ArgumentList.Add("-Standalone"); + } + + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("PowerShell did not start."); + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + process.StandardInput.Write(input); + process.StandardInput.Close(); + if (!process.WaitForExit(30_000)) + { + process.Kill(entireProcessTree: true); + throw new TimeoutException("The capture fixture did not finish."); + } + + var stdout = stdoutTask.GetAwaiter().GetResult(); + var stderr = stderrTask.GetAwaiter().GetResult(); + var snapshot = File.Exists(destination) ? File.ReadAllText(destination, Encoding.UTF8) : string.Empty; + return new ScriptResult(process.ExitCode, stdout, stderr, snapshot); + } + finally + { + if (File.Exists(destination)) + { + File.Delete(destination); + } + } + } + + private static string FindScript() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, "scripts", "capture-claude-usage.ps1"); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + throw new FileNotFoundException("The Claude capture fixture was not found."); + } + + private static string FindPowerShell() + { + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var candidate = Path.Combine(windows, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"); + return File.Exists(candidate) ? candidate : "pwsh"; + } + + private sealed record ScriptResult(int ExitCode, string StandardOutput, string StandardError, string SnapshotJson); +} diff --git a/CodexUsageDock.Tests/CodexProfileStoreTests.cs b/CodexUsageDock.Tests/CodexProfileStoreTests.cs new file mode 100644 index 0000000..ddbf137 --- /dev/null +++ b/CodexUsageDock.Tests/CodexProfileStoreTests.cs @@ -0,0 +1,226 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class CodexProfileStoreTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + + public void Dispose() => _environment.Dispose(); + + [Fact] + public void UpsertPersistsAndReplacesNamesCaseInsensitively() + { + var executable = _environment.PathFor("codex.exe"); + var home = _environment.PathFor("codex-home"); + File.WriteAllText(executable, string.Empty); + Directory.CreateDirectory(home); + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + + Assert.True(store.TryUpsert("Work", executable, home, out var first, out var firstError), firstError); + Assert.NotNull(first); + Assert.Equal(32, first!.Id.ToString("N").Length); + Assert.Equal(executable, first.SourceOptions.ExecutablePath); + Assert.Equal(home, first.SourceOptions.HomePath); + + Assert.True(store.TryUpsert("work", null, null, out var replacement, out var replacementError), replacementError); + Assert.NotNull(replacement); + Assert.Equal(first.Id, replacement!.Id); + Assert.Equal("work", replacement.DisplayName); + Assert.Null(replacement.SourceOptions.ExecutablePath); + Assert.Null(replacement.SourceOptions.HomePath); + + var reloaded = new CodexProfileStore(_environment.PathFor("profiles.json")); + var saved = Assert.Single(reloaded.Profiles); + Assert.Equal(replacement.Id, saved.Id); + Assert.Equal("work", saved.DisplayName); + Assert.Null(saved.SourceOptions.ExecutablePath); + Assert.Null(saved.SourceOptions.HomePath); + } + + [Fact] + public void InvalidNamesAndPathsAreRejectedAndTheProfileCountIsBounded() + { + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + + Assert.False(store.TryUpsert(string.Empty, null, null, out _, out var emptyNameError)); + Assert.Contains("1 to 40", emptyNameError, StringComparison.Ordinal); + Assert.False(store.TryUpsert(new string('x', 41), null, null, out _, out _)); + Assert.False(store.TryUpsert("bad\u0001name", null, null, out _, out _)); + + var relativeExecutable = Path.Combine("relative", "codex.exe"); + Assert.False(store.TryUpsert("Relative", relativeExecutable, null, out _, out var relativeError)); + Assert.DoesNotContain(relativeExecutable, relativeError, StringComparison.Ordinal); + + var missingHome = Path.Combine(_environment.PathFor("missing"), "home"); + Assert.False(store.TryUpsert("Missing home", null, missingHome, out _, out var missingHomeError)); + Assert.DoesNotContain(missingHome, missingHomeError, StringComparison.Ordinal); + + for (var index = 0; index < CodexProfileStore.MaximumProfiles; index++) + { + Assert.True(store.TryUpsert($"Profile {index}", null, null, out _, out var error), error); + } + + Assert.False(store.TryUpsert("Profile 8", null, null, out _, out var limitError)); + Assert.Contains("eight", limitError, StringComparison.OrdinalIgnoreCase); + Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Count); + Assert.Equal(CodexProfileStore.MaximumProfiles, store.Profiles.Select(profile => profile.Id).Distinct().Count()); + } + + [Fact] + public void ReloadKeepsOfflinePathsButPersistsOnlyProfileFields() + { + var unavailableExecutable = Path.Combine(_environment.PathFor("offline"), "codex.exe"); + var unavailableHome = _environment.PathFor("offline-home"); + var path = _environment.PathFor("profiles.json"); + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = CodexProfileStore.SchemaVersion, + profiles = new[] + { + new + { + id = Guid.NewGuid().ToString("N"), + displayName = "Offline WSL", + executablePath = unavailableExecutable, + homePath = unavailableHome, + accountEmail = "private@example.com", + }, + }, + })); + + var store = new CodexProfileStore(path); + var loaded = Assert.Single(store.Profiles); + Assert.Equal(unavailableExecutable, loaded.SourceOptions.ExecutablePath); + Assert.Equal(unavailableHome, loaded.SourceOptions.HomePath); + + Assert.True(store.TryUpsert("Offline WSL", null, null, out _, out var error), error); + var saved = File.ReadAllText(path); + Assert.DoesNotContain("private@example.com", saved, StringComparison.Ordinal); + Assert.DoesNotContain("accountEmail", saved, StringComparison.Ordinal); + } + + [Fact] + public void MalformedAndOversizedProfileDocumentsFailSafelyAndLoadAtMostEight() + { + var path = _environment.PathFor("profiles.json"); + File.WriteAllText(path, "null"); + + var malformed = new CodexProfileStore(path); + Assert.Empty(malformed.Profiles); + Assert.Contains("could not be read", malformed.StorageError, StringComparison.Ordinal); + Assert.DoesNotContain("null", malformed.StorageError, StringComparison.OrdinalIgnoreCase); + + File.WriteAllText(path, JsonSerializer.Serialize(new + { + schemaVersion = CodexProfileStore.SchemaVersion, + profiles = Enumerable.Range(0, CodexProfileStore.MaximumProfiles + 4) + .Select(index => new + { + id = Guid.NewGuid().ToString("N"), + displayName = $"Profile {index}", + executablePath = (string?)null, + homePath = (string?)null, + }) + .ToArray(), + })); + + var bounded = new CodexProfileStore(path); + Assert.Equal(CodexProfileStore.MaximumProfiles, bounded.Profiles.Count); + } + + [Fact] + public void RemoveUsesStableIdentityAndPersistsTheDeletion() + { + var path = _environment.PathFor("profiles.json"); + var store = new CodexProfileStore(path); + Assert.True(store.TryUpsert("Work", null, null, out var profile, out var error), error); + Assert.NotNull(profile); + Assert.True(store.TryGet(profile!.Id, out var found)); + Assert.Equal(profile, found); + + Assert.False(store.TryRemove(Guid.Empty, out var invalidIdError)); + Assert.Contains("identifier", invalidIdError, StringComparison.OrdinalIgnoreCase); + Assert.True(store.TryRemove(profile.Id, out var removeError), removeError); + Assert.Empty(store.Profiles); + Assert.Empty(new CodexProfileStore(path).Profiles); + } + + [Fact] + public void ProfilesPageOffersFormUseAndConfirmedRemoval() + { + var executable = _environment.PathFor("codex.exe"); + var home = _environment.PathFor("codex-home"); + File.WriteAllText(executable, string.Empty); + Directory.CreateDirectory(home); + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + Assert.True(store.TryUpsert("Work", executable, home, out _, out var error), error); + + using var page = new CodexProfilesPage(store); + var items = page.GetItems(); + Assert.Equal(2, items.Length); + var add = Assert.Single(items, item => item.Title == "Add or replace profile"); + var formPage = Assert.IsType(add.Command); + var form = Assert.IsAssignableFrom(Assert.Single(formPage.GetContent().OfType())); + Assert.Contains("\"id\":\"name\"", form.TemplateJson, StringComparison.Ordinal); + Assert.Contains("\"id\":\"executablePath\"", form.TemplateJson, StringComparison.Ordinal); + Assert.Contains("\"id\":\"homePath\"", form.TemplateJson, StringComparison.Ordinal); + + var selected = new List(); + page.ProfileSelected += (_, args) => selected.Add(args); + var profileItem = Assert.Single(items, item => item.Title == "Work"); + var use = Assert.IsAssignableFrom(profileItem.Command); + use.Invoke(page); + var selection = Assert.Single(selected); + Assert.Equal("Work", selection.Name); + Assert.Equal(executable, selection.Options.ExecutablePath); + Assert.Equal(home, selection.Options.HomePath); + + var deleteContext = Assert.IsType(Assert.Single(profileItem.MoreCommands)); + var confirmation = Assert.IsType(deleteContext.Command); + Assert.Equal("Delete profile", deleteContext.Title); + confirmation.Command.Invoke(page); + Assert.Empty(store.Profiles); + Assert.Single(page.GetItems()); + } + + [Fact] + public void NewProfileFormUpsertsAProfileWithoutApplyingIt() + { + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + using var page = new NewProfileFormPage(store); + var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); + var result = form.SubmitForm(JsonSerializer.Serialize(new + { + name = "Work", + executablePath = string.Empty, + homePath = string.Empty, + }), "{}"); + + Assert.NotNull(result); + var profile = Assert.Single(store.Profiles); + Assert.Equal("Work", profile.DisplayName); + Assert.Null(profile.SourceOptions.ExecutablePath); + Assert.Null(profile.SourceOptions.HomePath); + } + + [Theory] + [InlineData("{\"name\":\"Work\",\"executablePath\":42}")] + [InlineData("{\"name\":\"Work\",\"homePath\":false}")] + [InlineData("{\"name\":\"Work\",\"homePath\":{}}")] + public void NewProfileFormRejectsNonStringPathValues(string payload) + { + var store = new CodexProfileStore(_environment.PathFor("profiles.json")); + using var page = new NewProfileFormPage(store); + var form = Assert.IsAssignableFrom(Assert.Single(page.GetContent().OfType())); + + var result = form.SubmitForm(payload, "{}"); + + Assert.NotNull(result); + Assert.Empty(store.Profiles); + } +} diff --git a/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs new file mode 100644 index 0000000..9af260a --- /dev/null +++ b/CodexUsageDock.Tests/ProviderPilotIntegrationTests.cs @@ -0,0 +1,140 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.CommandPalette.Extensions; +using Xunit; + +namespace CodexUsageDock.Tests; + +public sealed class ProviderPilotIntegrationTests : IDisposable +{ + private readonly TestEnvironment _environment = new(); + private static readonly DateTimeOffset Now = new(2026, 9, 9, 12, 0, 0, TimeSpan.Zero); + public void Dispose() => _environment.Dispose(); + + [Theory] + [InlineData(1, 15, "Stale", "Available")] + [InlineData(15, 1, "Available", "Stale")] + public async Task ChangingOnlyTheRefreshIntervalReclassifiesClaudeAndNotifiesPresentation( + int previousMinutes, int nextMinutes, string before, string after) + { + var capture = WriteCapture(); + using var service = _environment.CreateService( + _ => Task.FromResult(CodexUsageSnapshot.Loading), () => CodexUsageSnapshot.Loading, + clock: () => Now.AddMinutes(6)); + service.SetRefreshInterval(TimeSpan.FromMinutes(previousMinutes)); + service.ConfigureClaude(true, capture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(before, service.GetClaudeUsage().Status.ToString()); + var updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + service.ClaudeUpdated += (_, _) => + { + if (service.GetClaudeUsage().Status.ToString() == after) updated.TrySetResult(); + }; + + service.SetRefreshInterval(TimeSpan.FromMinutes(nextMinutes)); + service.ConfigureClaude(true, capture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(after, service.GetClaudeUsage().Status.ToString()); + await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void ApplyingAProfilePersistsPathsAndLabelBeforeTheNextStart() + { + var executable = _environment.PathFor("codex.exe"); + File.WriteAllText(executable, string.Empty); + var source = new CodexSourceOptions(executable, Path.GetDirectoryName(executable)); + var settings = _environment.CreateSettings(); + var changes = 0; + settings.Changed += (_, _) => changes++; + settings.ApplySourceProfile("Local work", source); + var restored = _environment.CreateSettings(); + Assert.Equal(source.ExecutablePath, restored.CodexExecutablePath); + Assert.Equal(source.HomePath, restored.CodexHomePath); + Assert.Equal("Local work", restored.SourceLabel); + Assert.Equal(1, changes); + } + + [Fact] + public async Task ClaudeCaptureCompletesIndependentlyOfABlockedCodexReadAndDisablingClearsIt() + { + var capture = WriteCapture(); + var pending = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var service = _environment.CreateService(_ => pending.Task, () => CodexUsageSnapshot.Loading, clock: () => Now); + var codexRead = service.RefreshAsync(); + service.ConfigureClaude(true, capture); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(codexRead.IsCompleted); + Assert.Equal(ClaudeUsageReadStatus.Available, service.GetClaudeUsage().Status); + Assert.Equal(75, service.GetClaudeUsage().Primary!.RemainingPercent); + var changed = JsonNode.Parse(File.ReadAllText(capture))!.AsObject(); + changed["rate_limits"]!["five_hour"]!["used_percentage"] = 50; + File.WriteAllText(capture, changed.ToJsonString()); + Assert.Same(codexRead, service.RefreshAsync()); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(50, service.GetClaudeUsage().Primary!.RemainingPercent); + Assert.False(codexRead.IsCompleted); + service.ConfigureClaude(false, capture); + Assert.Null(service.GetClaudeUsage().Primary); + Assert.Contains("disabled", service.GetClaudeUsage().Message, StringComparison.Ordinal); + pending.SetResult(CodexUsageSnapshot.Loading); + await codexRead; + } + + [Fact] + public async Task ClaudeDockHasItsOwnRestorableBandAndDoesNotAlterCodexQuotas() + { + var capture = WriteCapture(); + File.WriteAllText(_environment.PathFor("settings.json"), new JsonObject + { ["enableClaude"] = "true", ["claudeBridgePath"] = capture }.ToJsonString()); + var quota = new CodexUsageSnapshot(new(10, 300, Now.AddHours(4)), null, null, null, null, + Now, UsageDataSource.AppServer, null, AccountKey: "a"); + using var service = _environment.CreateService(_ => Task.FromResult(quota), () => quota, clock: () => Now); + using var provider = new CodexUsageDockCommandsProvider(service, _environment.CreateSettings(), _ => { }, () => Now); + await service.RefreshAsync(); + await service.ClaudeRefreshTask.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(90, service.Current.Primary!.RemainingPercent); + Assert.Equal(2, provider.GetDockBands()!.Length); + var band = provider.GetCommandItem("nl.mathijs.codexusage.dock.claude")!; + var items = Assert.IsAssignableFrom(band.Command).GetItems(); + Assert.Equal(2, items.Length); + Assert.Equal("Claude 5h 75%", items[0].Title); + Assert.Equal("Claude week 60%", items[1].Title); + Assert.Contains(provider.TopLevelCommands(), item => item.Command.Id == "nl.mathijs.codexusage.table"); + } + + [Fact] + public void TextViewKeepsUnknownZeroExpiredAndUnconfirmedStatesDistinct() + { + var quota = new CodexUsageSnapshot(new(100, 300, Now.AddHours(-1)), null, null, null, new(0, null), + Now.AddHours(-2), UsageDataSource.LastConfirmed, null, + [new("extra", "Extra | quota", new(0, 60, Now.AddHours(1)), null)], DefaultBucketId: "codex"); + var view = new UsagePresentation(quota, [], [], new([], null), LocalTokenUsageSnapshot.Unavailable, false); + var body = CodexUsageTablePage.Format(view, Now, TimeSpan.FromMinutes(1)); + Assert.Contains("LastConfirmed", body, StringComparison.Ordinal); + Assert.Contains("Reset passed; refresh required", body, StringComparison.Ordinal); + Assert.Contains("Not reported", body, StringComparison.Ordinal); + Assert.Contains("0%", body, StringComparison.Ordinal); + Assert.Contains("100%", body, StringComparison.Ordinal); + Assert.Contains("Extra \\| quota", body, StringComparison.Ordinal); + Assert.DoesNotContain("![", body, StringComparison.Ordinal); + } + + private string WriteCapture() + { + var path = _environment.PathFor("claude.json"); + File.WriteAllText(path, new JsonObject + { + ["schemaVersion"] = 1, + ["provider"] = "claude", + ["observedAtUTC"] = Now.ToString("O", CultureInfo.InvariantCulture), + ["rate_limits"] = new JsonObject + { + ["five_hour"] = new JsonObject { ["used_percentage"] = 25, ["resets_at"] = Now.AddHours(4).ToUnixTimeSeconds() }, + ["seven_day"] = new JsonObject { ["used_percentage"] = 40, ["resets_at"] = Now.AddDays(4).ToUnixTimeSeconds() }, + }, + }.ToJsonString()); + return path; + } +} diff --git a/CodexUsageDock/CachedCodexSessionReader.cs b/CodexUsageDock/CachedCodexSessionReader.cs new file mode 100644 index 0000000..2b9fb7c --- /dev/null +++ b/CodexUsageDock/CachedCodexSessionReader.cs @@ -0,0 +1,330 @@ +using System.Text.Json; + +namespace CodexUsageDock; + +internal sealed class CachedCodexSessionReader +{ + private readonly string _homePath; + private readonly long _maxBytesPerScan; + private readonly int _maxCachedFiles; + private readonly int _maxLineBytes; + private readonly int _fileReadQuantum; + private readonly Func _clock; + private readonly object _sync = new(); + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly LinkedList _pending = new(); + private FileObservation? _latest; + private string? _discoveryAfter; + private long _scan; + private long _visit; + + internal CachedCodexSessionReader( + string? homePath = null, + long maxBytesPerScan = 8 * 1024 * 1024, + int maxCachedFiles = 512, + int maxLineBytes = 128 * 1024, + int fileReadQuantum = 64 * 1024, + Func? clock = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytesPerScan); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCachedFiles); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLineBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(fileReadQuantum); + _homePath = Path.GetFullPath(homePath ?? LocalStorage.GetCodexHome()); + _maxBytesPerScan = maxBytesPerScan; + _maxCachedFiles = maxCachedFiles; + _maxLineBytes = maxLineBytes; + _fileReadQuantum = fileReadQuantum; + _clock = clock ?? (() => DateTimeOffset.UtcNow); + } + + internal long BytesReadLastScan { get; private set; } + internal int CachedFileCount => _files.Count; + internal bool LastScanComplete { get; private set; } + + internal CodexUsageSnapshot ReadLatest(CancellationToken cancellationToken = default) + { + lock (_sync) + { + BytesReadLastScan = 0; + LastScanComplete = false; + cancellationToken.ThrowIfCancellationRequested(); + _scan++; + var now = _clock(); + var inventoryComplete = DiscoverFiles(cancellationToken); + var buffer = new byte[Math.Min(16 * 1024, _fileReadQuantum)]; + var readFailed = false; + while (_pending.First is { } next && BytesReadLastScan < _maxBytesPerScan) + { + cancellationToken.ThrowIfCancellationRequested(); + var file = next.Value; + _pending.RemoveFirst(); + file.QueueNode = null; + file.LastVisit = ++_visit; + if (!ReadFile(file, buffer, now, cancellationToken)) + { + // Retry failed files on the next refresh, rather than spinning without consuming the budget. + readFailed = true; + continue; + } + QueueIfNeeded(file); + } + + foreach (var file in _files.Values) + { + RememberLatest(file); + } + cancellationToken.ThrowIfCancellationRequested(); + LastScanComplete = inventoryComplete && !readFailed && _pending.Count == 0 + && _files.Values.All(file => file.PartialLength == 0 && !file.DiscardingLine); + var latest = _latest?.Snapshot ?? throw new InvalidOperationException("No usable Codex usage measurement was found."); + return latest with { Error = LastScanComplete ? null : "Local session scan incomplete." }; + } + } + + private bool DiscoverFiles(CancellationToken cancellationToken) + { + var comparer = new DiscoveryOrder(_discoveryAfter); + var candidates = new SortedSet(comparer); + var complete = true; + var found = 0; + var latestSeen = false; + foreach (var directory in new[] { Path.Combine(_homePath, "sessions"), Path.Combine(_homePath, "archived_sessions") }) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.Exists(directory)) continue; + var options = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + }; + try + { + foreach (var path in Directory.EnumerateFiles(directory, "rollout-*.jsonl", options)) + { + cancellationToken.ThrowIfCancellationRequested(); + found++; + if (_files.TryGetValue(path, out var file)) file.SeenAt = _scan; + if (StringComparer.OrdinalIgnoreCase.Equals(_latest?.Stamp.Path, path)) latestSeen = true; + FileStamp stamp; + try { stamp = ReadStamp(path); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("inspect fallback session", error); + complete = false; + continue; + } + if (_latest is { } latest && StringComparer.OrdinalIgnoreCase.Equals(latest.Stamp.Path, path)) + _latest = NeedsReset(latest.Stamp, stamp, latest.Offset) ? null : latest with { Stamp = stamp }; + if (file is not null) + { + Observe(file, stamp); + QueueIfNeeded(file); + } + else + { + candidates.Add(stamp); + if (candidates.Count > _maxCachedFiles) candidates.Remove(candidates.Max!); + } + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("enumerate fallback sessions", error); + complete = false; + } + } + + if (complete) + { + foreach (var file in _files.Values.Where(file => file.SeenAt != _scan).ToArray()) Remove(file); + if (!latestSeen) _latest = null; + } + var evictedPending = false; + foreach (var stamp in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_files.Count == _maxCachedFiles) + { + var eligible = _files.Values.Where(file => file.AddedAt != _scan); + var evicted = eligible.Where(file => file.QueueNode is null).MinBy(file => file.LastVisit); + if (evicted is null && !evictedPending) + { + evicted = eligible.Where(file => file.LastVisit > 0 && file.PartialLength == 0).MinBy(file => file.LastVisit); + evictedPending = evicted is not null; + } + if (evicted is null) break; + RememberLatest(evicted); + Remove(evicted); + } + var added = new FileState(stamp, _scan); + _files.Add(stamp.Path, added); + QueueIfNeeded(added); + _discoveryAfter = stamp.Path; + } + // An overflow can require rereading evicted checkpoints; never label that bounded view a complete inventory. + return complete && found <= _maxCachedFiles; + } + + private bool ReadFile(FileState file, byte[] buffer, DateTimeOffset now, CancellationToken cancellationToken) + { + try + { + Observe(file, ReadStamp(file.Stamp.Path)); + using var stream = new FileStream(file.Stamp.Path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, bufferSize: 1); + if (stream.Length < file.Stamp.Length) + { + Reset(file); + file.Stamp = file.Stamp with { Length = stream.Length }; + } + stream.Position = file.Offset; + var remaining = Math.Min(Math.Min(_fileReadQuantum, _maxBytesPerScan - BytesReadLastScan), file.Stamp.Length - file.Offset); + while (remaining > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = stream.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining)); + if (read == 0) return false; + ProcessBytes(file, buffer.AsSpan(0, read), now); + file.Offset += read; + BytesReadLastScan += read; + remaining -= read; + RememberLatest(file); + } + return true; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + LocalStorage.TraceFailure("read fallback session", error); + return false; + } + } + + private void ProcessBytes(FileState file, ReadOnlySpan bytes, DateTimeOffset now) + { + while (!bytes.IsEmpty) + { + var newline = bytes.IndexOf((byte)'\n'); + var count = newline < 0 ? bytes.Length : newline; + if (!file.DiscardingLine) + { + if (count > _maxLineBytes - file.PartialLength) + { + file.ClearPartial(); + file.DiscardingLine = true; + } + else + { + var needed = file.PartialLength + count; + if (needed > file.Partial.Length) + Array.Resize(ref file.Partial, Math.Min(_maxLineBytes, Math.Max(needed, 1024))); + bytes[..count].CopyTo(file.Partial.AsSpan(file.PartialLength)); + file.PartialLength = needed; + } + } + if (newline < 0) break; + if (!file.DiscardingLine) ParseLine(file, now); + file.ClearPartial(); + file.DiscardingLine = false; + bytes = bytes[(newline + 1)..]; + } + } + + private static void ParseLine(FileState file, DateTimeOffset now) + { + var line = file.Partial.AsMemory(0, file.PartialLength); + if (line.Span.IndexOf("\"rate_limits\""u8) < 0) return; + if (line.Span.StartsWith("\uFEFF"u8)) line = line[3..]; + try + { + using var document = JsonDocument.Parse(line, new JsonDocumentOptions { MaxDepth = 32 }); + var snapshot = LocalCodexSessionReader.ParseSnapshot(document.RootElement, now); + if (snapshot is not null && (file.Latest is null || snapshot.UpdatedAt >= file.Latest.UpdatedAt)) + file.Latest = snapshot; + } + catch (JsonException) + { + // Malformed complete records are ignored; an unfinished record remains bounded until its newline arrives. + } + } + + private void Observe(FileState file, FileStamp stamp) + { + if (NeedsReset(file.Stamp, stamp, file.Offset)) Reset(file); + file.Stamp = stamp; + } + + private static bool NeedsReset(FileStamp previous, FileStamp current, long offset) => previous.CreatedAt != current.CreatedAt + || current.Length < previous.Length || current.Length < offset + || (current.Length == previous.Length && current.WrittenAt != previous.WrittenAt); + + private void Reset(FileState file) + { + if (StringComparer.OrdinalIgnoreCase.Equals(_latest?.Stamp.Path, file.Stamp.Path)) _latest = null; + file.Offset = 0; + file.Latest = null; + file.ClearPartial(); + file.DiscardingLine = false; + } + + private void RememberLatest(FileState file) + { + if (file.Latest is { } snapshot && (_latest is null || snapshot.UpdatedAt > _latest.Snapshot.UpdatedAt)) + _latest = new(file.Stamp, file.Offset, snapshot); + } + + private void QueueIfNeeded(FileState file) + { + if (file.QueueNode is null && file.Offset < file.Stamp.Length) file.QueueNode = _pending.AddLast(file); + } + + private void Remove(FileState file) + { + if (file.QueueNode is { } node) _pending.Remove(node); + file.ClearPartial(); + _files.Remove(file.Stamp.Path); + } + + private static FileStamp ReadStamp(string path) + { + var info = new FileInfo(path); + return new(path, info.Length, info.LastWriteTimeUtc, info.CreationTimeUtc); + } + + private sealed record FileStamp(string Path, long Length, DateTime WrittenAt, DateTime CreatedAt); + private sealed record FileObservation(FileStamp Stamp, long Offset, CodexUsageSnapshot Snapshot); + + private sealed class FileState(FileStamp stamp, long scan) + { + internal FileStamp Stamp = stamp; + internal long Offset; + internal long SeenAt = scan; + internal long AddedAt = scan; + internal long LastVisit; + internal byte[] Partial = []; + internal int PartialLength; + internal bool DiscardingLine; + internal CodexUsageSnapshot? Latest; + internal LinkedListNode? QueueNode; + + internal void ClearPartial() + { + Partial.AsSpan(0, PartialLength).Clear(); + PartialLength = 0; + } + } + + private sealed class DiscoveryOrder(string? after) : IComparer + { + public int Compare(FileStamp? left, FileStamp? right) + { + if (ReferenceEquals(left, right)) return 0; + if (left is null) return -1; + if (right is null) return 1; + var leftAfter = after is null || StringComparer.OrdinalIgnoreCase.Compare(left.Path, after) > 0; + var rightAfter = after is null || StringComparer.OrdinalIgnoreCase.Compare(right.Path, after) > 0; + return leftAfter == rightAfter ? StringComparer.OrdinalIgnoreCase.Compare(left.Path, right.Path) : leftAfter ? -1 : 1; + } + } +} diff --git a/CodexUsageDock/ClaudeUsageReader.cs b/CodexUsageDock/ClaudeUsageReader.cs new file mode 100644 index 0000000..bd6d452 --- /dev/null +++ b/CodexUsageDock/ClaudeUsageReader.cs @@ -0,0 +1,335 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace CodexUsageDock; + +internal enum ClaudeUsageReadStatus +{ + Available, + Partial, + Unavailable, + Stale, + Future, +} + +internal sealed record ClaudeUsageWindow( + double UsedPercent, + int WindowMinutes, + DateTimeOffset ResetsAt) +{ + internal double RemainingPercent => Math.Clamp(100 - UsedPercent, 0, 100); +} + +internal sealed record ClaudeUsageSnapshot( + ClaudeUsageWindow? Primary, + ClaudeUsageWindow? Weekly, + DateTimeOffset ObservedAt, + ClaudeUsageReadStatus Status, + string Message) +{ + internal bool IsAvailable => Status is ClaudeUsageReadStatus.Available or ClaudeUsageReadStatus.Partial; + + internal static ClaudeUsageSnapshot Unavailable(string message) => + new(null, null, DateTimeOffset.MinValue, ClaudeUsageReadStatus.Unavailable, message); +} + +internal static class ClaudeUsageReader +{ + internal const int MaximumFileBytes = 64 * 1024; + private const int PrimaryWindowMinutes = 5 * 60; + private const int WeeklyWindowMinutes = 7 * 24 * 60; + + internal static ClaudeUsageSnapshot Read( + string path, + DateTimeOffset now, + TimeSpan refreshInterval, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(path) || !IsFullyQualifiedPath(path)) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture path must be a fully qualified file path."); + } + + byte[] bytes; + try + { + bytes = ReadBounded(path, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException + or NotSupportedException or PathTooLongException or InvalidDataException) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture could not be read."); + } + + JsonDocument document; + try + { + var encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + var json = encoding.GetString(bytes); + document = JsonDocument.Parse(json, new JsonDocumentOptions { MaxDepth = 16 }); + } + catch (Exception error) when (error is JsonException or DecoderFallbackException or ArgumentException) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture is not valid JSON."); + } + + using (document) + { + return Parse(document.RootElement, now, refreshInterval); + } + } + + internal static ClaudeUsageSnapshot Read(string path, DateTimeOffset now) => + Read(path, now, UsageFreshness.MinimumAge); + + internal static ClaudeUsageSnapshot Read(string path) => + Read(path, DateTimeOffset.UtcNow, UsageFreshness.MinimumAge); + + private static ClaudeUsageSnapshot Parse( + JsonElement root, + DateTimeOffset now, + TimeSpan refreshInterval) + { + if (root.ValueKind != JsonValueKind.Object + || !TryGetSchemaVersion(root, out var schemaVersion) + || schemaVersion != 1 + || !TryGetString(root, "provider", out var provider) + || !string.Equals(provider, "claude", StringComparison.OrdinalIgnoreCase) + || !TryGetObservedAt(root, out var observedAt)) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture has an unsupported schema or provider."); + } + + if (!TryGetObject(root, "rate_limits", out var rateLimits)) + { + return ClaudeUsageSnapshot.Unavailable("Claude usage capture has no rate-limit data."); + } + + var primary = ParseWindow(rateLimits, "five_hour", PrimaryWindowMinutes, now); + var weekly = ParseWindow(rateLimits, "seven_day", WeeklyWindowMinutes, now); + var freshness = UsageFreshness.Classify(observedAt, now, refreshInterval); + if (freshness == UsageFreshnessState.Future) + { + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Future, + "Claude usage capture timestamp is in the future."); + } + + if (freshness == UsageFreshnessState.Stale) + { + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Stale, + "Claude usage capture is stale."); + } + + if (freshness != UsageFreshnessState.Fresh) + { + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, ClaudeUsageReadStatus.Unavailable, + "Claude usage capture freshness is unavailable."); + } + + var validCount = (primary.Window is null ? 0 : 1) + (weekly.Window is null ? 0 : 1); + var status = validCount switch + { + 2 => ClaudeUsageReadStatus.Available, + 1 => ClaudeUsageReadStatus.Partial, + _ => ClaudeUsageReadStatus.Unavailable, + }; + var message = validCount switch + { + 2 => "Claude rate-limit windows are available.", + 1 => "One Claude rate-limit window is unavailable; windows remain independent.", + _ => "No valid Claude rate-limit windows were provided.", + }; + return new ClaudeUsageSnapshot(primary.Window, weekly.Window, observedAt, status, message); + } + + private static WindowParseResult ParseWindow( + JsonElement parent, + string propertyName, + int windowMinutes, + DateTimeOffset now) + { + if (!parent.TryGetProperty(propertyName, out var value) + || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) + { + return new WindowParseResult(null); + } + + if (value.ValueKind != JsonValueKind.Object + || !TryGetNumber(value, "used_percentage", out var usedPercent) + || !TryGetReset(value, "resets_at", out var resetsAt)) + { + return new WindowParseResult(null); + } + + var rateWindow = new RateLimitWindow(usedPercent, windowMinutes, resetsAt); + return UsageFreshness.IsValidWindow(rateWindow, now) + ? new WindowParseResult(new ClaudeUsageWindow(usedPercent, windowMinutes, resetsAt.ToUniversalTime())) + : new WindowParseResult(null); + } + + private static bool TryGetSchemaVersion(JsonElement root, out int version) + { + version = 0; + return root.TryGetProperty("schemaVersion", out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt32(out version); + } + + private static bool TryGetString(JsonElement parent, string propertyName, out string value) + { + value = string.Empty; + return parent.TryGetProperty(propertyName, out var element) + && element.ValueKind == JsonValueKind.String + && (value = element.GetString() ?? string.Empty).Length > 0; + } + + private static bool TryGetObservedAt(JsonElement root, out DateTimeOffset observedAt) + { + observedAt = default; + if (!TryGetString(root, "observedAtUTC", out var value) + || !DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out observedAt) + || !HasExplicitOffset(value)) + { + observedAt = default; + return false; + } + + observedAt = observedAt.ToUniversalTime(); + return true; + } + + private static bool TryGetObject(JsonElement parent, string propertyName, out JsonElement value) + { + value = default; + return parent.ValueKind == JsonValueKind.Object + && parent.TryGetProperty(propertyName, out value) + && value.ValueKind == JsonValueKind.Object; + } + + private static bool TryGetNumber( + JsonElement parent, + string propertyName, + out double number) + { + number = 0; + if (parent.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetDouble(out number) + && double.IsFinite(number) + && number is >= 0 and <= 100) + { + return true; + } + + number = 0; + return false; + } + + private static bool TryGetReset( + JsonElement parent, + string propertyName, + out DateTimeOffset resetsAt) + { + resetsAt = default; + if (parent.TryGetProperty(propertyName, out var value) + && value.ValueKind == JsonValueKind.Number + && value.TryGetInt64(out var seconds)) + { + try + { + resetsAt = DateTimeOffset.FromUnixTimeSeconds(seconds); + return true; + } + catch (ArgumentOutOfRangeException) + { + return false; + } + } + + return false; + } + + private static byte[] ReadBounded(string path, CancellationToken cancellationToken) + { + using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete, + bufferSize: 8192, + options: FileOptions.SequentialScan); + if (stream.Length > MaximumFileBytes) + { + throw new InvalidDataException("Claude usage capture is oversized."); + } + + using var memory = new MemoryStream((int)stream.Length); + var buffer = new byte[8192]; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) + { + break; + } + + if (memory.Length + read > MaximumFileBytes) + { + throw new InvalidDataException("Claude usage capture is oversized."); + } + + memory.Write(buffer, 0, read); + } + + return memory.ToArray(); + } + + private static bool IsFullyQualifiedPath(string path) + { + try + { + return Path.IsPathFullyQualified(path); + } + catch (Exception error) when (error is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + + private static bool HasExplicitOffset(string value) + { + var text = value.Trim(); + if (text.EndsWith("Z", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var timeSeparator = text.IndexOf('T'); + if (timeSeparator < 0) + { + timeSeparator = text.IndexOf(' '); + } + + if (timeSeparator < 0 || timeSeparator == text.Length - 1) + { + return false; + } + + var time = text[(timeSeparator + 1)..]; + return time.Contains('+', StringComparison.Ordinal) + || time.LastIndexOf('-') > 0; + } + + private readonly record struct WindowParseResult(ClaudeUsageWindow? Window); +} diff --git a/CodexUsageDock/CodexProfileStore.cs b/CodexUsageDock/CodexProfileStore.cs new file mode 100644 index 0000000..7fcd52b --- /dev/null +++ b/CodexUsageDock/CodexProfileStore.cs @@ -0,0 +1,348 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodexUsageDock; + +internal sealed record CodexProfile( + Guid Id, + string DisplayName, + CodexSourceOptions SourceOptions); + +internal sealed class CodexProfileStore +{ + internal const int SchemaVersion = 1; + internal const int MaximumProfiles = 8; + internal const int MaximumDisplayNameLength = 40; + internal const int MaximumPathLength = 1024; + internal const int MaximumDocumentBytes = 512 * 1024; + + private const string LoadErrorMessage = "Saved Codex profiles could not be read. No profiles were loaded."; + private const string SaveErrorMessage = "The Codex profile could not be saved. Try again."; + private const string InvalidNameMessage = "Profile names must contain 1 to 40 characters without control characters."; + private const string InvalidSourceMessage = "The Codex executable or home path is invalid or unavailable."; + private const string MaximumProfilesMessage = "You can save up to eight Codex profiles."; + private const string ProfileNotFoundMessage = "The Codex profile was not found."; + private const string InvalidProfileIdMessage = "The Codex profile identifier is invalid."; + private readonly object _gate = new(); + private readonly string _path; + private List _profiles; + private string? _storageError; + + internal CodexProfileStore(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = Path.GetFullPath(path); + _profiles = Load(); + } + + internal static CodexProfileStore CreateDefault() => + new(LocalStorage.GetPath("profiles.json")); + + internal IReadOnlyList Profiles + { + get + { + lock (_gate) + { + return _profiles.ToArray(); + } + } + } + + internal string? StorageError + { + get + { + lock (_gate) + { + return _storageError; + } + } + } + + internal bool TryGet(Guid id, out CodexProfile? profile) + { + lock (_gate) + { + profile = id != Guid.Empty + ? _profiles.FirstOrDefault(candidate => candidate.Id == id) + : null; + return profile is not null; + } + } + + internal bool TryUpsert( + string? displayName, + string? executablePath, + string? homePath, + out CodexProfile? profile, + out string? error) + { + profile = null; + error = null; + if (!TryNormalizeDisplayName(displayName, out var normalizedName)) + { + error = InvalidNameMessage; + return false; + } + + if (!CodexSourceOptions.TryCreate(executablePath, homePath, out var options, out _)) + { + error = InvalidSourceMessage; + return false; + } + + lock (_gate) + { + var existingIndex = _profiles.FindIndex(candidate => + string.Equals(candidate.DisplayName, normalizedName, StringComparison.OrdinalIgnoreCase)); + if (existingIndex < 0 && _profiles.Count >= MaximumProfiles) + { + error = MaximumProfilesMessage; + return false; + } + + var candidate = new CodexProfile( + existingIndex >= 0 ? _profiles[existingIndex].Id : Guid.NewGuid(), + normalizedName, + options); + var updated = _profiles.ToList(); + if (existingIndex >= 0) + { + updated[existingIndex] = candidate; + } + else + { + updated.Add(candidate); + } + + if (!TrySave(updated, out error)) + { + return false; + } + + _profiles = updated; + profile = candidate; + return true; + } + } + + internal bool TryRemove(Guid id, out string? error) + { + error = null; + if (id == Guid.Empty) + { + error = InvalidProfileIdMessage; + return false; + } + + lock (_gate) + { + var existingIndex = _profiles.FindIndex(candidate => candidate.Id == id); + if (existingIndex < 0) + { + error = ProfileNotFoundMessage; + return false; + } + + var updated = _profiles.ToList(); + updated.RemoveAt(existingIndex); + if (!TrySave(updated, out error)) + { + return false; + } + + _profiles = updated; + return true; + } + } + + private List Load() + { + try + { + if (!File.Exists(_path)) + { + return []; + } + + var fileInfo = new FileInfo(_path); + if (fileInfo.Length > MaximumDocumentBytes) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var document = JsonSerializer.Deserialize( + File.ReadAllText(_path), + CodexProfileStoreJsonContext.Default.CodexProfileDocument); + if (document is null || document.SchemaVersion != SchemaVersion || document.Profiles is null) + { + SetStorageError(LoadErrorMessage); + return []; + } + + var profiles = new List(Math.Min(document.Profiles.Length, MaximumProfiles)); + var ids = new HashSet(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in document.Profiles) + { + if (entry is null + || !Guid.TryParseExact(entry.Id, "N", out var id) + || id == Guid.Empty + || !TryNormalizeDisplayName(entry.DisplayName, out var displayName) + || !TryNormalizePathShape(entry.ExecutablePath, executable: true, out var executablePath) + || !TryNormalizePathShape(entry.HomePath, executable: false, out var homePath) + || !ids.Add(id) + || !names.Add(displayName)) + { + continue; + } + + profiles.Add(new CodexProfile(id, displayName, new(executablePath, homePath))); + if (profiles.Count == MaximumProfiles) + { + break; + } + } + + return profiles; + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or InvalidOperationException + or ArgumentException) + { + LocalStorage.TraceFailure("load Codex profiles", exception); + SetStorageError(LoadErrorMessage); + return []; + } + } + + private bool TrySave(IReadOnlyList profiles, out string? error) + { + error = null; + try + { + var document = new CodexProfileDocument( + SchemaVersion, + profiles.Select(profile => new CodexProfileEntry( + profile.Id.ToString("N"), + profile.DisplayName, + profile.SourceOptions.ExecutablePath, + profile.SourceOptions.HomePath)).ToArray()); + var content = JsonSerializer.Serialize( + document, + CodexProfileStoreJsonContext.Default.CodexProfileDocument); + if (!LocalStorage.TryWrite(_path, content)) + { + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + + SetStorageError(null); + return true; + } + catch (Exception exception) when (exception is IOException + or UnauthorizedAccessException + or JsonException + or NotSupportedException + or InvalidOperationException + or ArgumentException) + { + LocalStorage.TraceFailure("save Codex profiles", exception); + error = SaveErrorMessage; + SetStorageError(error); + return false; + } + } + + private static bool TryNormalizeDisplayName(string? value, out string normalized) + { + normalized = string.Empty; + if (value is null || value.Any(char.IsControl)) + { + return false; + } + + normalized = value.Trim(); + return normalized.Length is >= 1 and <= MaximumDisplayNameLength; + } + + // Loading deliberately checks only path shape. A temporarily unavailable + // WSL or network directory remains selectable until use-time validation. + private static bool TryNormalizePathShape(string? value, bool executable, out string? normalized) + { + normalized = null; + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + var trimmed = value.Trim(); + if (trimmed.Length > MaximumPathLength + || trimmed.Any(char.IsControl) + || !Path.IsPathFullyQualified(trimmed)) + { + return false; + } + + try + { + normalized = Path.TrimEndingDirectorySeparator(Path.GetFullPath(trimmed)); + if (normalized.Length == 0) + { + return false; + } + + if (executable) + { + var fileName = Path.GetFileName(normalized); + if (!(fileName.Equals("codex.exe", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("codex.cmd", StringComparison.OrdinalIgnoreCase)) + || CodexAppServerReader.IsWindowsAppsPath(normalized)) + { + normalized = null; + return false; + } + } + + return true; + } + catch (Exception exception) when (exception is ArgumentException + or NotSupportedException + or PathTooLongException) + { + normalized = null; + return false; + } + } + + private void SetStorageError(string? error) + { + lock (_gate) + { + _storageError = error; + } + } +} + +internal sealed record CodexProfileDocument( + [property: JsonPropertyName("schemaVersion")] int SchemaVersion, + [property: JsonPropertyName("profiles")] CodexProfileEntry?[]? Profiles); + +internal sealed record CodexProfileEntry( + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("displayName")] string? DisplayName, + [property: JsonPropertyName("executablePath")] string? ExecutablePath, + [property: JsonPropertyName("homePath")] string? HomePath); + +[JsonSourceGenerationOptions(PropertyNameCaseInsensitive = true)] +[JsonSerializable(typeof(CodexProfileDocument))] +[JsonSerializable(typeof(CodexProfileEntry))] +internal sealed partial class CodexProfileStoreJsonContext : JsonSerializerContext +{ +} diff --git a/CodexUsageDock/CodexUsageDockCommandsProvider.cs b/CodexUsageDock/CodexUsageDockCommandsProvider.cs index 5fe42e5..2688702 100644 --- a/CodexUsageDock/CodexUsageDockCommandsProvider.cs +++ b/CodexUsageDock/CodexUsageDockCommandsProvider.cs @@ -17,6 +17,12 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private readonly CodexPlanningPage _planner; private readonly CodexHistoryPage _history; private readonly CodexActionsPage _actions; + private readonly CodexUsageTablePage _textUsage; + private readonly CodexProfilesPage _profiles; + private readonly ClaudeUsagePage _claude; + private readonly ListItem _claudeFiveHour; + private readonly ListItem _claudeWeekly; + private readonly object _claudePresentationLock = new(); private readonly UsageAlertEvaluator _alerts = new(); private readonly Action _notify; private readonly Func _clock; @@ -24,6 +30,7 @@ public partial class CodexUsageDockCommandsProvider : CommandProvider private const string FiveHourDockId = "nl.mathijs.codexusage.dock.five-hour"; private const string WeeklyDockId = "nl.mathijs.codexusage.dock.weekly"; private const string CreditsDockId = "nl.mathijs.codexusage.dock.credits"; + private const string ClaudeDockId = "nl.mathijs.codexusage.dock.claude"; private ICommandItem[] _dockBands = []; public CodexUsageDockCommandsProvider() @@ -49,6 +56,7 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); + _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); var details = _details = new CodexUsageDockPage(_usage, _settings); _diagnostics = new CodexUsageDiagnosticsPage(_usage); _diagnostics.Id = "nl.mathijs.codexusage.diagnostics"; @@ -56,6 +64,13 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS _planner = new CodexPlanningPage(_usage, _settings, _clock); _history = new CodexHistoryPage(_usage); _actions = new CodexActionsPage(_usage); + _textUsage = new CodexUsageTablePage(_usage, _clock); + _profiles = new CodexProfilesPage(new CodexProfileStore(_settings.ProfileStoragePath)); + _profiles.ProfileSelected += OnProfileSelected; + _claude = new ClaudeUsagePage(_usage); + _claudeFiveHour = new ListItem(_claude); + _claudeWeekly = new ListItem(_claude); + _details.Commands = [.. _details.Commands, new CommandContextItem(_textUsage) { Title = "Read usage in text" }]; _fiveHour = new UsageDockItem(_usage, UsageDockItemKind.FiveHour, details, _settings); _weekly = new UsageDockItem(_usage, UsageDockItemKind.Weekly, details, _settings); _resetsAndCredits = new UsageDockItem(_usage, UsageDockItemKind.ResetsAndCredits, details); @@ -86,11 +101,16 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS new CommandItem(_planner) { Title = "Codex workday planner", Subtitle = "Daily quota budget, recent pace, and forecast evidence" }, new CommandItem(_history) { Title = "Codex usage history", Subtitle = "Retained quota observations, CSV/JSON export, and deletion" }, new CommandItem(_actions) { Title = "Codex task usage and earned resets", Subtitle = "Request a task estimate or explicitly use an earned reset" }, + new CommandItem(_textUsage) { Title = "Codex usage in text", Subtitle = "Quota tables and measured values without charts or color cues" }, + new CommandItem(_profiles) { Title = "Codex source profiles", Subtitle = "Save and select named local or Windows-accessible WSL sources" }, + new CommandItem(_claude) { Title = "Claude usage pilot", Subtitle = "Optional local statusline capture; independent Claude quotas" }, ]; _settings.Changed += OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested += OnClearAdaptiveHistoryRequested; _usage.Updated += OnUsageUpdated; + _usage.ClaudeUpdated += OnClaudeUpdated; + RefreshClaudeItems(); RebuildDockBands(); _usage.Start(); @@ -111,6 +131,7 @@ internal CodexUsageDockCommandsProvider(CodexUsageService usage, CodexUsageDockS FiveHourDockId => new WrappedDockItem([_fiveHour], FiveHourDockId, "Codex five-hour usage"), WeeklyDockId => new WrappedDockItem([_weekly], WeeklyDockId, "Codex weekly usage"), CreditsDockId => new WrappedDockItem([_resetsAndCredits], CreditsDockId, "Codex resets and credits"), + ClaudeDockId => new WrappedDockItem(_settings.EnableClaude ? [_claudeFiveHour, _claudeWeekly] : [], ClaudeDockId, "Claude usage"), _ => null, }; } @@ -127,6 +148,7 @@ private void OnSettingsChanged(object? sender, EventArgs e) var sourceChanged = ApplySourceSettings(); _usage.SetAccountActivityEnabled(_settings.ShowAccountActivity); _usage.SetAggregateRetentionDays(_settings.HistoryRetentionDays); + _usage.ConfigureClaude(_settings.EnableClaude, _settings.ClaudeBridgePath); _fiveHour.Refresh(); _weekly.Refresh(); _details.Refresh(); @@ -144,6 +166,32 @@ private bool ApplySourceSettings() return _usage.ConfigureSource(options, error); } + private void OnProfileSelected(object? sender, CodexProfileSelectedEventArgs args) => _settings.ApplySourceProfile(args.Name, args.Options); + + private void OnClaudeUpdated(object? sender, EventArgs args) + { + RefreshClaudeItems(); + RebuildDockBands(); + RaiseItemsChanged(); + } + + private void RefreshClaudeItems() + { + lock (_claudePresentationLock) + { + var snapshot = _usage.GetClaudeUsage(); + var now = _clock(); + var fresh = snapshot.IsAvailable && UsageFreshness.IsFresh(snapshot.ObservedAt, now, _usage.RefreshInterval); + _claudeFiveHour.Title = "Claude 5h " + FormatClaudeRemaining(snapshot.Primary, fresh, now); + _claudeWeekly.Title = "Claude week " + FormatClaudeRemaining(snapshot.Weekly, fresh, now); + _claudeFiveHour.Subtitle = snapshot.Message; + _claudeWeekly.Subtitle = snapshot.Message; + } + } + + private static string FormatClaudeRemaining(ClaudeUsageWindow? window, bool fresh, DateTimeOffset now) => fresh && window is not null && window.ResetsAt > now + ? window.RemainingPercent.ToString("0", System.Globalization.CultureInfo.InvariantCulture) + "%" : "--"; + private void OnClearAdaptiveHistoryRequested(object? sender, EventArgs e) { var cleared = _usage.ClearAdaptiveWeeklyHistory(); @@ -179,18 +227,22 @@ private void OnUsageUpdated(object? sender, EventArgs e) private void RebuildDockBands() { var items = GetVisibleDockItems(); + ICommandItem[] bands; if (_settings.SeparateDockItems) { - _dockBands = items.Select(item => new WrappedDockItem([item], + bands = items.Select(item => new WrappedDockItem([item], ReferenceEquals(item, _fiveHour) ? FiveHourDockId : ReferenceEquals(item, _weekly) ? WeeklyDockId : CreditsDockId, ReferenceEquals(item, _fiveHour) ? "Codex five-hour usage" : ReferenceEquals(item, _weekly) ? "Codex weekly usage" : "Codex resets and credits")) .Cast().ToArray(); - return; } - var dockBand = items.Length == 0 - ? null - : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); - _dockBands = dockBand is null ? [] : [dockBand]; + else + { + var dockBand = items.Length == 0 ? null : new WrappedDockItem(items, "nl.mathijs.codexusage.dock", DisplayName); + bands = dockBand is null ? [] : [dockBand]; + } + if (_settings.EnableClaude) + bands = [.. bands, new WrappedDockItem([_claudeFiveHour, _claudeWeekly], ClaudeDockId, "Claude usage")]; + _dockBands = bands; } private IListItem[] GetVisibleDockItems() @@ -219,6 +271,8 @@ public override void Dispose() _settings.Changed -= OnSettingsChanged; _settings.ClearAdaptiveHistoryRequested -= OnClearAdaptiveHistoryRequested; _usage.Updated -= OnUsageUpdated; + _usage.ClaudeUpdated -= OnClaudeUpdated; + _profiles.ProfileSelected -= OnProfileSelected; _fiveHour.Dispose(); _weekly.Dispose(); _resetsAndCredits.Dispose(); @@ -228,6 +282,9 @@ public override void Dispose() _planner.Dispose(); _history.Dispose(); _actions.Dispose(); + _textUsage.Dispose(); + _profiles.Dispose(); + _claude.Dispose(); _usage.Dispose(); base.Dispose(); GC.SuppressFinalize(this); diff --git a/CodexUsageDock/CodexUsageService.Claude.cs b/CodexUsageDock/CodexUsageService.Claude.cs new file mode 100644 index 0000000..5c1d051 --- /dev/null +++ b/CodexUsageDock/CodexUsageService.Claude.cs @@ -0,0 +1,72 @@ +namespace CodexUsageDock; + +internal sealed partial class CodexUsageService +{ + private bool _claudeEnabled; + private string _claudePath = string.Empty; + private TimeSpan _claudeRefreshInterval; + private long _claudeGeneration; + private Task? _claudeReadTask; + private ClaudeUsageSnapshot _claudeUsage = ClaudeUsageSnapshot.Unavailable("The Claude pilot is disabled."); + internal event EventHandler? ClaudeUpdated; + + internal ClaudeUsageSnapshot GetClaudeUsage() { lock (_refreshStateLock) { return _claudeUsage; } } + internal Task ClaudeRefreshTask { get { lock (_refreshStateLock) { return _claudeReadTask ?? Task.CompletedTask; } } } + + internal void ConfigureClaude(bool enabled, string path) + { + lock (_refreshStateLock) + { + var interval = RefreshInterval; + if (_disposed || _claudeEnabled == enabled && _claudePath == path && _claudeRefreshInterval == interval) return; + _claudeEnabled = enabled; + _claudePath = path; + _claudeRefreshInterval = interval; + _claudeGeneration++; + _claudeUsage = ClaudeUsageSnapshot.Unavailable(enabled ? "Waiting for a local Claude usage capture." : "The Claude pilot is disabled."); + } + RaiseClaudeUpdated(); + StartClaudeRefresh(); + } + + private void StartClaudeRefresh() + { + lock (_refreshStateLock) + { + if (_disposed || !_claudeEnabled || _claudeReadTask is { IsCompleted: false }) return; + var path = _claudePath; + var generation = _claudeGeneration; + var interval = RefreshInterval; + var cancellationToken = _lifetimeCancellation.Token; + _claudeReadTask = Task.Run(() => + { + ClaudeUsageSnapshot result; + try { result = ClaudeUsageReader.Read(path, _clock(), interval, cancellationToken); } + catch (Exception error) + { + if (error is not OperationCanceledException) LocalStorage.TraceFailure("read Claude capture", error); + result = ClaudeUsageSnapshot.Unavailable("The Claude usage capture could not be read."); + } + lock (_refreshStateLock) + { + if (!_disposed && generation == _claudeGeneration && _claudeEnabled) _claudeUsage = result; + } + RaiseClaudeUpdated(); + bool restart; + lock (_refreshStateLock) + { + _claudeReadTask = null; + restart = !_disposed && generation != _claudeGeneration && _claudeEnabled; + } + if (restart) StartClaudeRefresh(); + }); + } + } + + private void RaiseClaudeUpdated() + { + lock (_refreshStateLock) { if (_disposed) return; } + try { ClaudeUpdated?.Invoke(this, EventArgs.Empty); } + catch (Exception error) { LocalStorage.TraceFailure("update Claude presentation", error); } + } +} diff --git a/CodexUsageDock/CodexUsageService.cs b/CodexUsageDock/CodexUsageService.cs index 56deedd..f9f5f32 100644 --- a/CodexUsageDock/CodexUsageService.cs +++ b/CodexUsageDock/CodexUsageService.cs @@ -25,6 +25,7 @@ internal sealed partial class CodexUsageService : IDisposable private readonly Func _localSessionReader; private Func>? _localTokenUsageReader; private readonly bool _usesConfiguredSources; + private CachedCodexSessionReader? _configuredSessionReader; private CodexSourceOptions _sourceOptions = CodexSourceOptions.Default; private long _sourceGeneration; private string? _sourceConfigurationError; @@ -47,6 +48,7 @@ public CodexUsageService() localTokenUsageReader: new LocalCodexTokenUsageReader().ReadAsync) { _usesConfiguredSources = true; + _configuredSessionReader = new CachedCodexSessionReader(); InitializeOptionalFeatures(LocalStorage.GetPath("aggregates.json"), LocalStorage.GetPath("reset-attempt.json")); } @@ -175,6 +177,7 @@ internal bool ConfigureSource(CodexSourceOptions options, string? error = null) if (_usesConfiguredSources) { _localTokenUsageReader = new LocalCodexTokenUsageReader(options.HomePath).ReadAsync; + _configuredSessionReader = new CachedCodexSessionReader(options.HomePath); } lock (_historyLock) { @@ -258,6 +261,7 @@ internal string? HistoryStorageError public Task RefreshAsync() { + StartClaudeRefresh(); TaskCompletionSource completion; CancellationToken cancellationToken; CodexSourceOptions options; @@ -421,10 +425,12 @@ private async Task ExecuteRefreshAsync(TaskCompletionSource completion, CodexSou private async Task ReadSnapshotAsync(CodexSourceOptions options, long generation, CancellationToken cancellationToken) { var attemptedAt = _clock(); + CachedCodexSessionReader? localCache; lock (_refreshStateLock) { - if (_sourceConfigurationError is not null) + if (_sourceConfigurationError is not null || generation != _sourceGeneration) return CreateUnavailableSnapshot() with { LastAttemptAt = attemptedAt }; + localCache = _configuredSessionReader; } try { @@ -450,7 +456,7 @@ private async Task ReadSnapshotAsync(CodexSourceOptions opti try { var fallback = await Task.Run(() => _usesConfiguredSources - ? LocalCodexSessionReader.ReadLatest(options.HomePath ?? LocalStorage.GetCodexHome(), _clock(), cancellationToken) + ? localCache!.ReadLatest(cancellationToken) : _localSessionReader(cancellationToken), cancellationToken).ConfigureAwait(false); // Session logs do not normally identify the signed-in account. A newer // unverified log must not replace a confirmed, account-scoped measurement. @@ -460,7 +466,13 @@ private async Task ReadSnapshotAsync(CodexSourceOptions opti { return LastConfirmedSnapshot(confirmed, attemptedAt); } - return fallback with { Error = LiveDataUnavailableMessage, LastAttemptAt = attemptedAt }; + return fallback with + { + Error = localCache is { LastScanComplete: false } + ? LiveDataUnavailableMessage + " The local session scan is incomplete; more data will be checked on the next refresh." + : LiveDataUnavailableMessage, + LastAttemptAt = attemptedAt, + }; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -672,7 +684,8 @@ public void Dispose() _disposed = true; refreshTask = Task.WhenAll(_refreshTask ?? Task.CompletedTask, _tokenRefreshTask, _accountRefreshTask, - (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask); + (Task?)_resetActionTask ?? Task.CompletedTask, _threadActionTask ?? Task.CompletedTask, + _claudeReadTask ?? Task.CompletedTask); } _timer.Stop(); diff --git a/CodexUsageDock/LocalCodexSessionReader.cs b/CodexUsageDock/LocalCodexSessionReader.cs index e80c882..14f8b84 100644 --- a/CodexUsageDock/LocalCodexSessionReader.cs +++ b/CodexUsageDock/LocalCodexSessionReader.cs @@ -62,32 +62,10 @@ internal static CodexUsageSnapshot ReadLatest(string codexHome, DateTimeOffset n try { using var document = JsonDocument.Parse(line); - var root = document.RootElement; - if (root.ValueKind != JsonValueKind.Object - || !root.TryGetProperty("timestamp", out var timestamp) - || timestamp.ValueKind != JsonValueKind.String - || !DateTimeOffset.TryParse(timestamp.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var recordedAt) - || recordedAt > now - || !root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object - || !payload.TryGetProperty("rate_limits", out var limits) || limits.ValueKind != JsonValueKind.Object - || !TryReadWindow(limits, "primary", out var primary) - || !TryReadWindow(limits, "secondary", out var secondary) - || (!limits.TryGetProperty("primary", out _) && !limits.TryGetProperty("secondary", out _))) + var snapshot = ParseSnapshot(document.RootElement, now); + if (snapshot is not null && (latest is null || snapshot.UpdatedAt >= latest.UpdatedAt)) { - continue; - } - - var windows = RateLimitWindowParser.Classify(primary, secondary); - if ((primary is not null || secondary is not null) && windows.FiveHour is null && windows.Weekly is null) - { - continue; - } - - var plan = limits.TryGetProperty("plan_type", out var planType) && planType.ValueKind == JsonValueKind.String - ? UsageText.SanitizeExternal(planType.GetString(), 32) : null; - if (latest is null || recordedAt >= latest.UpdatedAt) - { - latest = new CodexUsageSnapshot(windows.FiveHour, windows.Weekly, plan, null, null, recordedAt, UsageDataSource.LocalSession, null); + latest = snapshot; } } catch (JsonException) @@ -104,9 +82,36 @@ internal static CodexUsageSnapshot ReadLatest(string codexHome, DateTimeOffset n return latest; } + internal static CodexUsageSnapshot? ParseSnapshot(JsonElement root, DateTimeOffset now) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("timestamp", out var timestamp) + || timestamp.ValueKind != JsonValueKind.String + || !DateTimeOffset.TryParse(timestamp.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var recordedAt) + || recordedAt > now + || !root.TryGetProperty("payload", out var payload) || payload.ValueKind != JsonValueKind.Object + || !payload.TryGetProperty("rate_limits", out var limits) || limits.ValueKind != JsonValueKind.Object + || !TryReadWindow(limits, "primary", out var primary) + || !TryReadWindow(limits, "secondary", out var secondary) + || (!limits.TryGetProperty("primary", out _) && !limits.TryGetProperty("secondary", out _))) + { + return null; + } + + var windows = RateLimitWindowParser.Classify(primary, secondary); + if ((primary is not null || secondary is not null) && windows.FiveHour is null && windows.Weekly is null) + { + return null; + } + + var plan = limits.TryGetProperty("plan_type", out var planType) && planType.ValueKind == JsonValueKind.String + ? UsageText.SanitizeExternal(planType.GetString(), 32) : null; + return new CodexUsageSnapshot(windows.FiveHour, windows.Weekly, plan, null, null, recordedAt, UsageDataSource.LocalSession, null); + } + private static bool TryReadWindow(JsonElement limits, string name, out RateLimitWindow? window) { window = RateLimitWindowParser.TryParse(limits, name, "used_percent", "window_minutes", "resets_at"); return window is not null || !limits.TryGetProperty(name, out var value) || value.ValueKind == JsonValueKind.Null; } -} \ No newline at end of file +} diff --git a/CodexUsageDock/Pages/ClaudeUsagePage.cs b/CodexUsageDock/Pages/ClaudeUsagePage.cs new file mode 100644 index 0000000..c7b5b81 --- /dev/null +++ b/CodexUsageDock/Pages/ClaudeUsagePage.cs @@ -0,0 +1,64 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class ClaudeUsagePage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + internal ClaudeUsagePage(CodexUsageService service) + { + _service = service; + Id = "nl.mathijs.codexusage.claude"; + Name = "Open"; + Title = "Claude usage pilot"; + Icon = new IconInfo("\uE943"); + service.ClaudeUpdated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + internal static string Format(ClaudeUsageSnapshot snapshot) + { + var body = new StringBuilder("# Claude usage pilot\n\n").Append(snapshot.Message).Append("\n\n") + .Append("These independent Claude quotas come from your explicitly selected local statusline capture. ") + .Append("The bridge does not verify the Claude account and its percentages are never added to Codex usage.\n\n"); + if (snapshot.ObservedAt != DateTimeOffset.MinValue) + body.Append("Observed UTC: ").Append(snapshot.ObservedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)) + .Append(". Status: ").Append(snapshot.Status).Append(".\n\n"); + body.Append("| Claude window | Remaining at observation | Reset UTC |\n| --- | ---: | --- |\n"); + AppendWindow(body, "Five-hour", snapshot.Primary); + AppendWindow(body, "Seven-day", snapshot.Weekly); + return body.Append("\nSetup: use the optional capture script described in the repository README, select its output file in settings, ") + .Append("then enable the pilot. The extension does not change Claude configuration or an existing statusline. ") + .Append("Missing or expired windows remain unavailable until Claude emits a new capture.").ToString(); + } + + private static void AppendWindow(StringBuilder body, string name, ClaudeUsageWindow? window) + { + body.Append("| ").Append(name).Append(" | ") + .Append(window is null ? "Not reported" : window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%") + .Append(" | ").Append(window?.ResetsAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) ?? "Not reported").Append(" |\n"); + } + + private void Refresh() + { + lock (_gate) + { + if (_disposed) return; + var body = Format(_service.GetClaudeUsage()); + _content = new MarkdownContent(body); + Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh captures" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy Claude usage" }]; + } + RaiseItemsChanged(0); + } + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.ClaudeUpdated -= OnUpdated; } } +} diff --git a/CodexUsageDock/Pages/CodexActionsPage.cs b/CodexUsageDock/Pages/CodexActionsPage.cs index dc204bc..a5ec9ad 100644 --- a/CodexUsageDock/Pages/CodexActionsPage.cs +++ b/CodexUsageDock/Pages/CodexActionsPage.cs @@ -31,30 +31,30 @@ internal CodexActionsPage(CodexUsageService service) private void Refresh() { - var presentation = _service.GetActionPresentation(); - var body = new StringBuilder("# Task usage\n\n").Append(presentation.TaskStatus).Append("\n\n"); - if (presentation.Task is { } task) body.Append(FormatTask(task)); - body.Append("\n# Earned resets\n\nReported available: ") - .Append(presentation.Usage.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Unknown") - .Append(".\n\n").Append(presentation.ResetStatus) - .Append("\n\nUse the confirmed reset action only when you want to redeem one existing earned reset. ") - .Append("After an unknown outcome, retrying uses the same saved request ID. The backend decides eligibility.\n"); - var expectedAccount = presentation.Usage.AccountKey; - var commands = new List - { - new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, - }; - if (expectedAccount is not null) - { - commands.Add(new CommandContextItem(new ConfirmableCommand( - new AnonymousCommand(() => { _ = _service.ConsumeEarnedResetAsync(expectedAccount); }) { Result = CommandResult.KeepOpen() }, - "Use or retry an earned reset?", - "Redeem one existing earned reset for the currently identified Codex account. A retry of an uncertain request reuses its saved ID. No reset is purchased.", - () => true) { Name = "Use or retry an earned reset" }) { Title = "Use or retry an earned reset" }); - } lock (_gate) { if (_disposed) return; + var presentation = _service.GetActionPresentation(); + var body = new StringBuilder("# Task usage\n\n").Append(presentation.TaskStatus).Append("\n\n"); + if (presentation.Task is { } task) body.Append(FormatTask(task)); + body.Append("\n# Earned resets\n\nReported available: ") + .Append(presentation.Usage.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Unknown") + .Append(".\n\n").Append(presentation.ResetStatus) + .Append("\n\nUse the confirmed reset action only when you want to redeem one existing earned reset. ") + .Append("After an unknown outcome, retrying uses the same saved request ID. The backend decides eligibility.\n"); + var expectedAccount = presentation.Usage.AccountKey; + var commands = new List + { + new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + }; + if (expectedAccount is not null) + { + commands.Add(new CommandContextItem(new ConfirmableCommand( + new AnonymousCommand(() => { _ = _service.ConsumeEarnedResetAsync(expectedAccount); }) { Result = CommandResult.KeepOpen() }, + "Use or retry an earned reset?", + "Redeem one existing earned reset for the currently identified Codex account. A retry of an uncertain request reuses its saved ID. No reset is purchased.", + () => true) { Name = "Use or retry an earned reset" }) { Title = "Use or retry an earned reset" }); + } _content = new MarkdownContent(body.ToString()); Commands = commands.ToArray(); } diff --git a/CodexUsageDock/Pages/CodexHistoryPage.cs b/CodexUsageDock/Pages/CodexHistoryPage.cs index 27029cf..122d179 100644 --- a/CodexUsageDock/Pages/CodexHistoryPage.cs +++ b/CodexUsageDock/Pages/CodexHistoryPage.cs @@ -32,23 +32,23 @@ internal CodexHistoryPage(CodexUsageService service) internal void Refresh() { - var history = _service.GetAggregateHistory(); - var body = new StringBuilder("# Usage history\n\n"); - if (_operation is not null) body.Append(UsageText.EscapeMarkdown(_operation)).Append("\n\n"); - if (!history.Identified) body.Append("Waiting for an identified account. Histories are separated by account and quota category.\n\n"); - body.Append(history.RetentionDays == 0 ? "Collection paused. Choose 7, 30, or 90 days in settings to retain observations." - : $"Retention: {history.RetentionDays} days. Up to one observation per five minutes, with separate reset transitions.") - .Append("\n\n").Append(history.Points.Count.ToString(CultureInfo.InvariantCulture)).Append(" retained observations. ") - .Append("Exports contain UTC quota percentages and reset times, without account IDs, conversation content, tokens, or costs.\n\n"); - if (history.Error is not null) body.Append(history.Error).Append("\n\n"); - body.Append("The most recent 30 observations are shown. The exports include all retained rows for this context.\n\n") - .Append("| Observed UTC | Five-hour remaining | Weekly remaining |\n| --- | ---: | ---: |\n"); - foreach (var point in history.Points.TakeLast(30).Reverse()) - body.Append("| ").Append(point.RecordedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)).Append(" | ") - .Append(Percent(point.PrimaryRemainingPercent)).Append(" | ").Append(Percent(point.WeeklyRemainingPercent)).Append(" |\n"); lock (_gate) { if (_disposed) return; + var history = _service.GetAggregateHistory(); + var body = new StringBuilder("# Usage history\n\n"); + if (_operation is not null) body.Append(UsageText.EscapeMarkdown(_operation)).Append("\n\n"); + if (!history.Identified) body.Append("Waiting for an identified account. Histories are separated by account and quota category.\n\n"); + body.Append(history.RetentionDays == 0 ? "Collection paused. Choose 7, 30, or 90 days in settings to retain observations." + : $"Retention: {history.RetentionDays} days. Up to one observation per five minutes, with separate reset transitions.") + .Append("\n\n").Append(history.Points.Count.ToString(CultureInfo.InvariantCulture)).Append(" retained observations. ") + .Append("Exports contain UTC quota percentages and reset times, without account IDs, conversation content, tokens, or costs.\n\n"); + if (history.Error is not null) body.Append(history.Error).Append("\n\n"); + body.Append("The most recent 30 observations are shown. The exports include all retained rows for this context.\n\n") + .Append("| Observed UTC | Five-hour remaining | Weekly remaining |\n| --- | ---: | ---: |\n"); + foreach (var point in history.Points.TakeLast(30).Reverse()) + body.Append("| ").Append(point.RecordedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)).Append(" | ") + .Append(Percent(point.PrimaryRemainingPercent)).Append(" | ").Append(Percent(point.WeeklyRemainingPercent)).Append(" |\n"); _content = new MarkdownContent(body.ToString()); Commands = history.Context is not { } context ? [] : [ diff --git a/CodexUsageDock/Pages/CodexPlanningPage.cs b/CodexUsageDock/Pages/CodexPlanningPage.cs index a340e18..1429952 100644 --- a/CodexUsageDock/Pages/CodexPlanningPage.cs +++ b/CodexUsageDock/Pages/CodexPlanningPage.cs @@ -31,15 +31,15 @@ internal CodexPlanningPage(CodexUsageService service, CodexUsageDockSettingsPage internal void Refresh() { - var now = _clock(); - var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); - var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) - ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." - : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, - new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); lock (_gate) { if (_disposed) return; + var now = _clock(); + var localEnd = now.LocalDateTime.Date.Add(_settings.WorkdayEnd.ToTimeSpan()); + var body = TimeZoneInfo.Local.IsInvalidTime(localEnd) + ? "The selected workday end does not exist in today's local time zone. Choose another time in settings." + : FormatPlan(UsagePlanner.Plan(_service.GetPresentation(), now, _service.RefreshInterval, + new DateTimeOffset(localEnd, TimeZoneInfo.Local.GetUtcOffset(localEnd)), _settings.RemainingWorkdays)); _content = new MarkdownContent(body); Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, new CommandContextItem(_settings) { Title = "Change planning assumptions" }, diff --git a/CodexUsageDock/Pages/CodexProfilesPage.cs b/CodexUsageDock/Pages/CodexProfilesPage.cs new file mode 100644 index 0000000..b89b26e --- /dev/null +++ b/CodexUsageDock/Pages/CodexProfilesPage.cs @@ -0,0 +1,356 @@ +using System.Text.Json; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.CmdPal.Common.Commands; + +namespace CodexUsageDock; + +internal sealed class CodexProfileSelectedEventArgs : EventArgs +{ + internal CodexProfileSelectedEventArgs(string name, CodexSourceOptions options) + { + Name = name; + Options = options; + } + + internal string Name { get; } + + internal CodexSourceOptions Options { get; } +} + +internal sealed partial class CodexProfilesPage : ListPage, IDisposable +{ + private readonly object _gate = new(); + private readonly CodexProfileStore _store; + private readonly NewProfileFormPage _newProfilePage; + private bool _disposed; + + internal CodexProfilesPage(CodexProfileStore store) + { + _store = store; + _newProfilePage = new NewProfileFormPage(store, RefreshItems); + Id = "nl.mathijs.codexusage.profiles"; + Name = "Open"; + Title = "Codex source profiles"; + Icon = new IconInfo("\uE77B"); + PlaceholderText = "Search saved profiles"; + } + + internal event EventHandler? ProfileSelected; + + public override IListItem[] GetItems() + { + lock (_gate) + { + if (_disposed) + { + return []; + } + } + + var items = new List(); + if (_store.StorageError is { Length: > 0 }) + { + items.Add(new ListItem(new NoOpCommand()) + { + Title = "Saved profiles unavailable", + Subtitle = "Saved profiles could not be read. Saving a new profile will replace the unreadable file.", + Icon = new IconInfo("\uE783"), + }); + } + + items.Add(new ListItem(_newProfilePage) + { + Title = "Add or replace profile", + Subtitle = "Enter a name and optional Codex source paths", + Icon = new IconInfo("\uE710"), + TextToSuggest = "Add or replace profile", + }); + + foreach (var profile in _store.Profiles) + { + var id = profile.Id; + var deleteCommand = new AnonymousCommand(() => RemoveProfile(id)) + { + Name = "Delete profile", + Id = $"nl.mathijs.codexusage.profile.delete.{id:N}", + Result = CommandResult.KeepOpen(), + }; + var deleteConfirmation = new ConfirmableCommand( + deleteCommand, + "Delete this Codex profile?", + "This removes the saved profile only. It does not change the active source or Codex configuration.", + () => true) + { + Name = "Delete profile", + Id = $"nl.mathijs.codexusage.profile.confirm-delete.{id:N}", + }; + + items.Add(new ListItem(new UseProfileCommand(this, id)) + { + Title = profile.DisplayName, + Subtitle = DescribeSource(profile.SourceOptions), + TextToSuggest = profile.DisplayName, + MoreCommands = + [ + new CommandContextItem(deleteConfirmation) + { + Title = "Delete profile", + Icon = new IconInfo("\uE74D"), + }, + ], + }); + } + + return [.. items]; + } + + private CommandResult UseProfile(Guid id) + { + if (!_store.TryGet(id, out var profile) || profile is null) + { + return CommandResult.ShowToast("This Codex profile is no longer available."); + } + + // Store loading keeps structurally valid offline WSL/network paths. Use + // validates availability at activation so a stale profile fails closed. + if (!CodexSourceOptions.TryCreate( + profile.SourceOptions.ExecutablePath, + profile.SourceOptions.HomePath, + out var options, + out _)) + { + return CommandResult.ShowToast("The saved Codex source path is invalid or unavailable."); + } + + ProfileSelected?.Invoke(this, new CodexProfileSelectedEventArgs(profile.DisplayName, options)); + return CommandResult.KeepOpen(); + } + + private void RemoveProfile(Guid id) + { + _store.TryRemove(id, out _); + RefreshItems(); + } + + private void RefreshItems() + { + lock (_gate) + { + if (_disposed) + { + return; + } + } + + RaiseItemsChanged(0); + } + + private static string DescribeSource(CodexSourceOptions options) + { + if (options.ExecutablePath is null && options.HomePath is null) + { + return "Uses automatic Codex source discovery"; + } + + if (options.ExecutablePath is not null && options.HomePath is not null) + { + return "Custom executable and Codex home"; + } + + return options.ExecutablePath is not null ? "Custom executable" : "Custom Codex home"; + } + + private sealed partial class UseProfileCommand : InvokableCommand + { + private readonly CodexProfilesPage _owner; + private readonly Guid _id; + + internal UseProfileCommand(CodexProfilesPage owner, Guid id) + { + _owner = owner; + _id = id; + Id = $"nl.mathijs.codexusage.profile.use.{id:N}"; + } + + public override string Name => "Use profile"; + + public override ICommandResult Invoke() => _owner.UseProfile(_id); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + _newProfilePage.Dispose(); + GC.SuppressFinalize(this); + } +} + +internal sealed partial class NewProfileFormPage : ContentPage, IDisposable +{ + private readonly object _gate = new(); + private readonly ProfileFormContent _form; + private MarkdownContent _message; + private bool _disposed; + + internal NewProfileFormPage(CodexProfileStore store, Action? saved = null) + { + _form = new ProfileFormContent(store, HandleSubmit); + _message = new MarkdownContent("# Add or replace a Codex profile\n\nSave a named source for later use. Paths are checked before they are saved."); + Id = "nl.mathijs.codexusage.profile.new"; + Name = "Open"; + Title = "Add or replace Codex profile"; + Icon = new IconInfo("\uE710"); + Saved = saved; + } + + private Action? Saved { get; } + + public override IContent[] GetContent() + { + lock (_gate) + { + return _disposed ? [] : [_message, _form]; + } + } + + private CommandResult HandleSubmit( + string? displayName, + string? executablePath, + string? homePath) + { + if (_disposed) + { + return CommandResult.KeepOpen(); + } + + if (!_form.Store.TryUpsert(displayName, executablePath, homePath, out _, out var error)) + { + SetMessage($"# Add or replace a Codex profile\n\n**Could not save the profile:** {UsageText.EscapeMarkdown(error ?? "The profile is invalid.")}"); + return CommandResult.KeepOpen(); + } + + Saved?.Invoke(); + SetMessage("# Profile saved\n\nThe profile is ready to select from the list."); + return CommandResult.GoBack(); + } + + private void SetMessage(string message) + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _message = new MarkdownContent(message); + } + + RaiseItemsChanged(0); + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + } + + GC.SuppressFinalize(this); + } + + private sealed partial class ProfileFormContent : FormContent + { + private const string InvalidPathValue = ""; + private readonly Func _submit; + + internal ProfileFormContent(CodexProfileStore store, Func submit) + { + Store = store; + _submit = submit; + TemplateJson = """ + {"type":"AdaptiveCard","version":"1.5","body":[ + {"type":"Input.Text","id":"name","label":"Profile name","placeholder":"Work","maxLength":40,"isRequired":true}, + {"type":"Input.Text","id":"executablePath","label":"Codex executable path (optional)","placeholder":"C:\\Path\\to\\codex.exe","maxLength":1024}, + {"type":"Input.Text","id":"homePath","label":"Codex home path (optional)","placeholder":"C:\\Users\\you\\.codex","maxLength":1024}, + {"type":"TextBlock","text":"Use a full Windows path. A Windows-accessible WSL directory is allowed when available to Windows; this extension does not launch WSL or modify Codex configuration.","wrap":true} + ],"actions":[{"type":"Action.Submit","title":"Save profile"}]} + """; + } + + internal CodexProfileStore Store { get; } + + public override CommandResult SubmitForm(string payload) + { + if (string.IsNullOrWhiteSpace(payload) || payload.Length > 8192) + { + return _submit(null, null, null); + } + + try + { + using var document = JsonDocument.Parse(payload, new JsonDocumentOptions { MaxDepth = 8 }); + if (document.RootElement.ValueKind != JsonValueKind.Object) + { + return _submit(null, null, null); + } + + if (!TryReadOptionalString(document.RootElement, "executablePath", out var executablePath) + || !TryReadOptionalString(document.RootElement, "homePath", out var homePath)) + { + // A malformed optional path must not silently select the + // default source. The marker is deliberately invalid and + // never leaves this form or reaches storage. + return _submit(ReadString(document.RootElement, "name"), InvalidPathValue, null); + } + + return _submit( + ReadString(document.RootElement, "name"), + executablePath, + homePath); + } + catch (JsonException) + { + return _submit(null, null, null); + } + } + + private static bool TryReadOptionalString(JsonElement root, string propertyName, out string? value) + { + value = null; + if (!root.TryGetProperty(propertyName, out var property) + || property.ValueKind == JsonValueKind.Null) + { + return true; + } + + if (property.ValueKind != JsonValueKind.String) + { + return false; + } + + value = property.GetString(); + return true; + } + + private static string? ReadString(JsonElement root, string propertyName) => + root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } +} diff --git a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs index 9dd522a..6ef4aae 100644 --- a/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs +++ b/CodexUsageDock/Pages/CodexUsageDockSettingsPage.cs @@ -25,6 +25,9 @@ internal sealed partial class CodexUsageDockSettingsPage : ContentPage private const string HistoryRetentionKey = "historyRetentionDays"; private const string WorkdayEndKey = "workdayEnd"; private const string RemainingWorkdaysKey = "remainingWorkdays"; + private const string SourceLabelKey = "sourceLabel"; + private const string EnableClaudeKey = "enableClaude"; + private const string ClaudeBridgePathKey = "claudeBridgePath"; private readonly Settings _settings = new(); private readonly string _path; private readonly FormContent _statusContent = new() @@ -105,6 +108,23 @@ internal CodexUsageDockSettingsPage(string path) Placeholder = @"C:\Users\you\.codex", Multiline = false, }); + _settings.Add(new TextSetting(SourceLabelKey, "Default") + { + Label = "Source label", + Description = "An optional name for these source paths. A label does not verify the signed-in account.", + Multiline = false, + }); + _settings.Add(new ToggleSetting(EnableClaudeKey, false) + { + Label = "Enable Claude usage pilot", + Description = "Read only the local quota snapshot produced by your optional Claude statusline bridge.", + }); + _settings.Add(new TextSetting(ClaudeBridgePathKey, string.Empty) + { + Label = "Claude bridge file", + Description = "The full path to your bridge JSON file. Configure the optional statusline bridge before enabling this pilot.", + Multiline = false, + }); _settings.Add(new ChoiceSetSetting( RefreshIntervalKey, [ @@ -183,6 +203,22 @@ internal CodexUsageDockSettingsPage(string path) public string CodexHomePath => GetPathSetting(CodexHomePathKey); + internal string SourceLabel => UsageText.SanitizeExternal(_settings.GetSetting(SourceLabelKey), 40) ?? "Default"; + internal bool EnableClaude => _settings.GetSetting(EnableClaudeKey); + internal string ClaudeBridgePath => GetPathSetting(ClaudeBridgePathKey); + internal string ProfileStoragePath => Path.Combine(Path.GetDirectoryName(_path)!, "profiles.json"); + + internal void ApplySourceProfile(string label, CodexSourceOptions options) + { + _settings.Update(new JsonObject + { + [SourceLabelKey] = UsageText.SanitizeExternal(label, 40) ?? "Custom", + [CodexExecutablePathKey] = options.ExecutablePath ?? string.Empty, + [CodexHomePathKey] = options.HomePath ?? string.Empty, + }.ToJsonString()); + OnSettingsChanged(_settings, _settings); + } + public TimeSpan RefreshInterval => ParseRefreshInterval(_settings.GetSetting(RefreshIntervalKey)); internal int HistoryRetentionDays => _settings.GetSetting(HistoryRetentionKey) switch @@ -230,7 +266,7 @@ private void Load() var valid = new JsonObject(); foreach (var property in document.RootElement.EnumerateObject()) { - if (property.Name is CodexExecutablePathKey or CodexHomePathKey) + if (property.Name is CodexExecutablePathKey or CodexHomePathKey or ClaudeBridgePathKey) { valid[property.Name] = property.Value.ValueKind == JsonValueKind.String && IsValidPathSetting(property.Value.GetString()) ? property.Value.GetString() : InvalidSourcePath; @@ -249,7 +285,11 @@ private void Load() } var value = property.Value.GetString(); - if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") + if (property.Name == SourceLabelKey) + { + valid[property.Name] = UsageText.SanitizeExternal(value, 40) ?? "Default"; + } + else if (property.Name == RefreshIntervalKey && value is "1" or "5" or "15") { valid[property.Name] = value; } @@ -278,7 +318,7 @@ private void Load() private static bool IsBooleanSetting(string name) => name is ShowFiveHourLimitKey or ShowWeeklyLimitKey or ShowResetsAndCreditsKey or ShowResetTimeKey or UseAdaptiveWeeklyForecastKey or EnableUsageAlertsKey or CompactDockKey or SeparateDockItemsKey or - ShowAccountActivityKey; + ShowAccountActivityKey or EnableClaudeKey; private static bool IsValidPathSetting(string? value) { diff --git a/CodexUsageDock/Pages/CodexUsageTablePage.cs b/CodexUsageDock/Pages/CodexUsageTablePage.cs new file mode 100644 index 0000000..1b39943 --- /dev/null +++ b/CodexUsageDock/Pages/CodexUsageTablePage.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using System.Text; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace CodexUsageDock; + +internal sealed partial class CodexUsageTablePage : ContentPage, IDisposable +{ + private readonly CodexUsageService _service; + private readonly Func _clock; + private readonly object _gate = new(); + private MarkdownContent _content = new(string.Empty); + private bool _disposed; + + internal CodexUsageTablePage(CodexUsageService service, Func? clock = null) + { + _service = service; + _clock = clock ?? (() => DateTimeOffset.Now); + Id = "nl.mathijs.codexusage.table"; + Name = "Open"; + Title = "Codex usage in text"; + Icon = new IconInfo("\uE8A5"); + service.Updated += OnUpdated; + Refresh(); + } + + public override IContent[] GetContent() { lock (_gate) { return [_content]; } } + + internal static string Format(UsagePresentation view, DateTimeOffset now, TimeSpan interval) + { + var snapshot = view.Usage; + var freshness = snapshot.Source is UsageDataSource.Initializing or UsageDataSource.Unavailable + ? "Unavailable" : UsageFreshness.Classify(snapshot.UpdatedAt, now, interval, snapshot.Source == UsageDataSource.LastConfirmed).ToString(); + var body = new StringBuilder("# Codex usage in text\n\nA text alternative to the dashboard charts.\n\n") + .Append("Status: ").Append(freshness).Append(view.IsLoading ? "; refreshing" : string.Empty) + .Append(". Source: ").Append(snapshot.SourceDisplayName).Append(".\n\n"); + if (snapshot.Source is not (UsageDataSource.Initializing or UsageDataSource.Unavailable)) + body.Append("Observed UTC: ").Append(Utc(snapshot.UpdatedAt)).Append(". Percentages below describe that observation.\n\n"); + if (snapshot.OrdinaryUsageAllowed == false) body.Append("**Ordinary usage was reported blocked.**\n\n"); + body.Append("| Quota category / window | Remaining at observation | Reset UTC | Window state now |\n| --- | ---: | --- | --- |\n"); + AppendWindow(body, "Default five-hour", snapshot.Primary, now); + AppendWindow(body, "Default weekly", snapshot.Secondary, now); + foreach (var bucket in snapshot.Buckets?.Take(32) ?? []) + { + var label = UsageText.SanitizeExternal(bucket.Name, 70) ?? UsageText.SanitizeExternal(bucket.Id, 70) ?? "Additional category"; + if (bucket.Id != snapshot.DefaultBucketId || bucket.Primary != snapshot.Primary) AppendWindow(body, label + " primary", bucket.Primary, now); + if (bucket.Id != snapshot.DefaultBucketId || bucket.Secondary != snapshot.Secondary) AppendWindow(body, label + " secondary", bucket.Secondary, now); + } + body.Append("\nAvailable earned resets at observation: ").Append(snapshot.ResetCredits?.AvailableCount.ToString(CultureInfo.InvariantCulture) ?? "Not reported") + .Append(".\n\n## Recent weekly observations\n\nUp to 20 measured points, without projected values.\n\n| Observed UTC | Remaining |\n| --- | ---: |\n"); + foreach (var point in view.WeeklyHistory.TakeLast(20)) + body.Append("| ").Append(Utc(point.RecordedAt)).Append(" | ").Append(point.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture)).Append("% |\n"); + body.Append("\n## Locally observed daily tokens\n\nThese are local activity totals, not account-wide billing or quota percentages.\n\n"); + body.Append("Availability: ").Append(view.TokenUsage.Status).Append(".\n\n| Local calendar date | Tokens |\n| --- | ---: |\n"); + foreach (var day in view.TokenUsage.Days.TakeLast(8)) + body.Append("| ").Append(day.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)).Append(" | ") + .Append(day.TotalTokens.ToString("N0", CultureInfo.InvariantCulture)).Append(" |\n"); + return body.ToString(); + } + + private static void AppendWindow(StringBuilder body, string label, RateLimitWindow? window, DateTimeOffset now) + { + body.Append("| ").Append(UsageText.EscapeMarkdown(label)); + if (window is null) { body.Append(" | Not reported | Not reported | Unknown |\n"); return; } + var valid = double.IsFinite(window.UsedPercent) && window.UsedPercent is >= 0 and <= 100 && window.WindowMinutes > 0; + body.Append(" (").Append(window.WindowMinutes.ToString(CultureInfo.InvariantCulture)).Append(" minutes) | ") + .Append(valid ? window.RemainingPercent.ToString("0.#", CultureInfo.InvariantCulture) + "%" : "Invalid") + .Append(" | ").Append(Utc(window.ResetsAt)).Append(" | ") + .Append(!valid ? "Invalid" : window.ResetsAt <= now ? "Reset passed; refresh required" : "Active window").Append(" |\n"); + } + + private static string Utc(DateTimeOffset time) => time.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); + private void Refresh() + { + lock (_gate) + { + if (_disposed) return; + var body = Format(_service.GetPresentation(), _clock(), _service.RefreshInterval); + _content = new MarkdownContent(body); + Commands = [new CommandContextItem(new RefreshUsageCommand(_service)) { Title = "Refresh usage" }, + new CommandContextItem(new CopyTextCommand(body)) { Title = "Copy usage text" }]; + } + RaiseItemsChanged(0); + } + private void OnUpdated(object? sender, EventArgs args) => Refresh(); + public void Dispose() { lock (_gate) { _disposed = true; _service.Updated -= OnUpdated; } } +} diff --git a/PRIVACY.md b/PRIVACY.md index ac7cd4b..7549117 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -28,6 +28,10 @@ Optional retained history stores at most 27,000 aggregate quota observations wit An explicitly requested task-usage read passes the user-entered task ID through the local Codex app-server and keeps the resulting aggregate estimates only in memory. A confirmed earned-reset action sends a mutation through that server. Before sending, the extension stores a random request ID in the account's hashed local context. An unresolved ID is retained across restarts so a retry cannot accidentally become a separate redemption. Recovery records contain no authentication credentials or raw account identifiers and are separate from history deletion. +Optional source profiles store up to eight display names and executable/home paths in the local `profiles.json` beside settings. Profile selection changes only the extension's source preferences; it does not copy authentication or alter Codex configuration. Deleting a preset does not delete a source directory or change the active source. The quota fallback keeps bounded file metadata, read positions, partial lines, and its latest parsed quota event only in memory. + +The optional Claude pilot reads only the capture file explicitly selected in settings. Its separately configured companion script receives Claude statusline input and retains at most 256 KiB in memory for parsing. Default mode forwards that input to the user's existing formatter; standalone mode emits only a compact quota line. The saved capture contains only a schema version, provider label, UTC timestamp, validated five-hour/seven-day usage percentages and reset times, and generic status messages. It does not copy workspace paths, model details, account identifiers, credentials, prompts, or responses into the capture. The extension does not request Claude credentials, contact Claude services, edit Claude configuration, or upload the capture. Users manage the script and its output file separately; disabling the pilot clears its displayed state and stops new reads but does not delete the file. + ## Permissions The Windows `runFullTrust` capability is required to run the packaged Command Palette COM server and to communicate with the locally installed Codex process. It is not used to bypass Windows security controls or access unrelated user data. diff --git a/README.md b/README.md index a333e1c..72117b0 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,29 @@ Settings accepts an optional full path to a standalone `codex.exe` or `codex.cmd **Codex account activity** shows account-wide token summaries and up to 30 recent server-calendar days when `account/usage/read` is supported. It updates independently after quota data, at most every five minutes automatically; unsupported versions retry after 30 minutes. **Refresh now** on that page requests an immediate retry. Disable **Show account activity** to stop these optional reads. Account identity must match before and after the request. Missing days and fields are not zero usage, and the server's unspecified calendar time zone is kept separate from local calendar-day chart bars. No account activity is written to disk by this feature. +**Codex source profiles** saves up to eight named sets of executable and home paths. Add a profile, then choose **Use profile** to apply it and persist it for the next start. Reusing a name replaces that preset. Profiles contain no copied credentials; a name does not verify the signed-in account. Saved WSL or network directories can remain listed while offline, but paths must be accessible before use. Deleting a preset asks for confirmation and leaves the active source settings unchanged. Only one Codex source is active at a time. + +**Codex usage in text**, also available from Details, provides quota tables, reset times, recent measured weekly points, and local daily token totals without relying on charts or color. Missing values, reported zero, expired windows, and last-confirmed observations have distinct text labels. + +## Optional Claude usage pilot + +The pilot displays separate Claude five-hour and seven-day limits from an explicitly selected local capture file. It is off by default and adds its own Dock band when enabled. It does not verify the Claude account, combine Claude percentages with Codex, or infer costs. Claude reads run independently of a slow Codex refresh. Changing the refresh interval immediately rereads the capture and updates its freshness status. + +The bridge uses Claude Code's documented `rate_limits.five_hour` and `rate_limits.seven_day` statusline fields. These may be absent independently, appear only after a session receives an API response, and require an eligible subscription. The pilot does not support gateway spend-limit fields. See the [official statusline field documentation](https://code.claude.com/docs/en/statusline#available-data). + +1. Copy [capture-claude-usage.ps1](scripts/capture-claude-usage.ps1) from this repository to a permanent location you control. The companion script is not bundled into the MSIX application. +2. Configure the command in your Claude statusline settings using the official instructions. If you have no existing formatter, use the script's **standalone** mode; replace both example paths with your own absolute paths: + + ```text + powershell.exe -NoProfile -NonInteractive -File "C:/Tools/capture-claude-usage.ps1" -OutputPath "C:/UsageCaptures/claude-usage.json" -Standalone + ``` + + Standalone mode displays a compact remaining-quota line. To retain an existing formatter, omit `-Standalone`, launch the capture script as a separate PowerShell process, and pipe that process's stdout into your existing formatter command. Default mode forwards the original stdin bytes unchanged, including when the capture destination fails. Calling the script inside the same PowerShell process is not a supported pipeline arrangement. The extension does not edit your Claude settings or replace a statusline automatically. +3. In **Codex Usage settings**, set **Claude bridge file** to the same absolute JSON file path and turn on **Enable Claude usage pilot**. The script creates the output directory when needed. +4. Open **Claude usage pilot** to inspect capture status, observation time, and each independent window. Add its Claude Dock band through Dock customization. + +The bridge retains at most 256 KiB of input for parsing and writes only the schema, provider, UTC capture time, validated quota windows, and generic availability messages. Writes replace the snapshot atomically. Missing, malformed, or oversized input writes an unavailable snapshot; it never refreshes the timestamp on old quota values. The extension reads at most 64 KiB per capture. Stale, future-dated, missing, and expired data do not appear as available Dock quota. **Refresh captures** rereads the file; it does not make Claude emit new data. After a quiet session, wait for a new Claude statusline update. + ## History, planning, and optional account actions **Codex usage history** retains quota observations only when **Retain usage observations** is set to 7, 30, or 90 days. Observations are scoped to the identified account and default quota category, sampled in five-minute buckets, and capped at 27,000 rows. Reset changes within a bucket remain separate observations. Pausing collection keeps retained data; the history page offers confirmed deletion for the selected context. CSV and JSON export actions write files to the extension's local application data `exports` folder and show the resulting path. Exports contain quota percentages and UTC observation/reset times, without account IDs or conversation content. Exported copies are not deleted when retained history is cleared. @@ -110,6 +133,8 @@ Freshness uses the greater of five minutes and the configured refresh interval t When Codex supplies an account identity, weekly history and learned profiles are stored separately for that account and default quota category using opaque hashed directory names. History appears only after the account is identified. Older history files have no identity and are not imported into an account. Without a verified account identity, recent observations remain in memory and adaptive learning is paused. +The local quota fallback caches read positions and the latest valid quota event in memory. Unchanged files still in its cache are not reread for content; appended, replaced, truncated, and deleted files are handled on later refreshes. Each scan reads at most 8 MiB of file content, tracks up to 512 files, and bounds partial lines to 128 KiB. It still enumerates session file metadata and skips inaccessible subdirectories; these limits do not promise constant scan time for large directories. Incomplete scans are reported, and later refreshes continue discovery. No session payload or read-position cache is saved to disk by this quota fallback. + ## Development Build, test, Store packaging, and release instructions are in [DEVELOPMENT.md](DEVELOPMENT.md). diff --git a/SPRINTS.md b/SPRINTS.md index f8ad822..230c8fe 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -6,8 +6,8 @@ This series implements the recommended Codex-first roadmap, followed by a small, | --- | --- | --- | --- | | 1 | `codex/sprint-1-reliable-usage` | Modern quota categories, consistent freshness, last confirmed data, account-scoped history, safe diagnostics, version communication | [PR #18](https://github.com/TheBeems/CodexUsageDock/pull/18); 186 native ARM64 tests passed; x64/ARM64 Debug builds passed | | 2 | `codex/sprint-2-attention-controls` | Quiet alerts, compact and individually pinnable Dock entries, account activity where supported, explicit source configuration | [PR #19](https://github.com/TheBeems/CodexUsageDock/pull/19); 235 x64 tests, both architecture builds, and package validation passed in CI | -| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests and both architecture builds passed | -| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | Planned | +| 3 | `codex/sprint-3-history-planning` | Retained aggregates and export, workday planning, forecast explanation and validation, supported task analysis, explicit earned-reset action | [PR #20](https://github.com/TheBeems/CodexUsageDock/pull/20); 314 native ARM64 tests; x64 tests, both builds, and package validation passed in CI | +| 4 | `codex/sprint-4-provider-pilot` | Optional Claude statusline bridge, explicit local profiles/WSL paths, efficient fallback reads, accessible text alternatives | [PR #21](https://github.com/TheBeems/CodexUsageDock/pull/21); 360 native ARM64 tests and both architecture builds passed; final GitHub validation recorded in the PR | Later sprints build on the previous feature branch so each PR can show only its own increment. Merge in sprint order and retarget dependent PRs to `main` after their base is merged. No merge is performed as part of this implementation request. @@ -34,6 +34,14 @@ The x64 and ARM64 application code compiles without warnings. Local ARM64 test e ### Sprint 3 verification -Native ARM64 test execution was available again and passed 314 tests. Both application architecture builds passed without warnings. Tests cover retention/export scope, planner assumptions and held-out observations, optional protocol support, task-request ordering, and persistent reset idempotency across ambiguous results and restarts. No real reset was redeemed. Command Palette forms, confirmations, and real-account compatibility still need live verification after an authorized installation. The GitHub workflow validates the pushed branch separately. +Native ARM64 test execution was available again and passed 314 tests. Both application architecture builds passed without warnings. Tests cover retention/export scope, planner assumptions and held-out observations, optional protocol support, task-request ordering, and persistent reset idempotency across ambiguous results and restarts. No real reset was redeemed. Command Palette forms, confirmations, and real-account compatibility still need live verification after an authorized installation. The final head `ebe7efe` passed all 314 x64 tests, both builds, and package validation in [GitHub Actions](https://github.com/TheBeems/CodexUsageDock/actions/runs/34385299160). Both integration preflights passed source/generated manifest, identity, asset, and self-contained runtime checks. The registered ARM64 Store package was healthy and discoverable, but its process and registration do not point at the new Debug builds. These expected mismatches leave live verification of the new code open; no registration or installation was changed. + +### Sprint 4 scope + +The pilot uses only an explicit local Claude capture and keeps its two quota windows and refresh state independent of Codex. The optional script preserves a formatter's input or displays a standalone quota line; setup is manual and described in README. Profiles save at most eight names and validated source paths, including Windows-accessible WSL directories, without copying credentials or launching WSL. Text views expose measured values without chart or color dependence. The quota fallback has bounded content reads and caches read positions; filesystem metadata enumeration remains proportional to the session inventory. + +Execution used separate protocol/integration and implementation agents, with Luna at max effort for the bounded implementation tasks. Review covered provider concurrency and profile changes. Tests use synthetic local data; no real Claude configuration, account authentication, or reset redemption is part of verification. + +All 360 native ARM64 tests passed, including script execution on synthetic stdin, profile persistence and invalid input, independent provider refresh, and bounded incremental session reads. Both application builds passed with zero warnings. Only the pre-existing test-name analyzer warnings remain. The two integration preflights passed source/generated manifest, COM identity, asset, output-freshness, and self-contained runtime checks. Registration and process matching failed as expected because Command Palette runs the installed ARM64 Store package rather than either new Debug build; the x64 preflight also reports the installed architecture mismatch. No package was registered or installed. Live forms, accessibility, Dock pinning, and real-provider compatibility remain unverified; GitHub validation is recorded in the sprint PR. diff --git a/scripts/capture-claude-usage.ps1 b/scripts/capture-claude-usage.ps1 new file mode 100644 index 0000000..8e1e16b --- /dev/null +++ b/scripts/capture-claude-usage.ps1 @@ -0,0 +1,313 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [switch]$Standalone +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$MaximumInputBytes = 256 * 1024 + +function Test-FullyQualifiedPath { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + try { + return [IO.Path]::IsPathFullyQualified($Path) + } + catch { + # Windows PowerShell 5.1 does not expose IsPathFullyQualified. + return $Path -match '^(?:[A-Za-z]:[\\/]|\\\\)' + } +} + +function Get-JsonProperty { + param( + [AllowNull()] + [object]$Object, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + if ($null -eq $Object) { + return $null + } + + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property) { + return $null + } + + return $property.Value +} + +function Convert-RateLimitWindow { + param( + [AllowNull()] + [object]$RateLimits, + + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [DateTimeOffset]$CapturedAt + ) + + $sourceWindow = Get-JsonProperty -Object $RateLimits -Name $Name + if ($null -eq $sourceWindow -or $sourceWindow -is [string] -or $sourceWindow -is [System.Array]) { + return $null + } + + $used = Get-JsonProperty -Object $sourceWindow -Name "used_percentage" + if ($null -eq $used -or $used -is [string] -or $used -is [char] -or $used -is [bool]) { + return $null + } + + try { + $usedNumber = [double]$used + } + catch { + return $null + } + + if ([double]::IsNaN($usedNumber) -or [double]::IsInfinity($usedNumber) -or + $usedNumber -lt 0 -or $usedNumber -gt 100) { + return $null + } + + $reset = Get-JsonProperty -Object $sourceWindow -Name "resets_at" + if ($null -eq $reset -or $reset -is [string] -or $reset -is [char] -or $reset -is [bool]) { + return $null + } + + try { + $resetNumber = [double]$reset + if ([double]::IsNaN($resetNumber) -or [double]::IsInfinity($resetNumber) -or + $resetNumber -ne [Math]::Truncate($resetNumber)) { + return $null + } + + $resetSeconds = [long]$resetNumber + $resetAt = [DateTimeOffset]::FromUnixTimeSeconds($resetSeconds) + } + catch { + return $null + } + + if ($resetAt -le $CapturedAt) { + return $null + } + + return [pscustomobject][ordered]@{ + used_percentage = $usedNumber + resets_at = $resetSeconds + } +} + +function Read-StandardInputAndPassThrough { + param( + [switch]$SuppressOutput + ) + + $inputStream = [Console]::OpenStandardInput() + $outputStream = [Console]::OpenStandardOutput() + $retained = [IO.MemoryStream]::new() + $buffer = New-Object byte[] 8192 + $oversized = $false + + try { + while (($count = $inputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + # Keep the existing statusline contract unless standalone output was requested. + if (-not $SuppressOutput) { + $outputStream.Write($buffer, 0, $count) + } + + if (-not $oversized) { + $remaining = $MaximumInputBytes - [int]$retained.Length + if ($count -le $remaining) { + $retained.Write($buffer, 0, $count) + } + else { + if ($remaining -gt 0) { + $retained.Write($buffer, 0, $remaining) + } + + $oversized = $true + } + } + } + + if (-not $SuppressOutput) { + $outputStream.Flush() + } + return [pscustomobject]@{ + Bytes = $retained.ToArray() + Oversized = $oversized + } + } + finally { + $retained.Dispose() + $inputStream.Dispose() + } +} + +function Write-AtomicSnapshot { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Content + ) + + $directory = [IO.Path]::GetDirectoryName($Path) + if ([string]::IsNullOrWhiteSpace($directory)) { + throw [ArgumentException]::new("The output path has no directory.") + } + + $directory = [IO.Path]::GetFullPath($directory) + [IO.Directory]::CreateDirectory($directory) | Out-Null + $leaf = [IO.Path]::GetFileName($Path) + if ([string]::IsNullOrWhiteSpace($leaf)) { + throw [ArgumentException]::new("The output path has no file name.") + } + + $temporaryLeaf = "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".tmp" + $temporaryPath = [IO.Path]::Combine($directory, $temporaryLeaf) + $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Content) + $stream = $null + + try { + $stream = [IO.File]::Open( + $temporaryPath, + [IO.FileMode]::CreateNew, + [IO.FileAccess]::Write, + [IO.FileShare]::None) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush($true) + $stream.Dispose() + $stream = $null + + if ([IO.File]::Exists($Path)) { + $backupPath = [IO.Path]::Combine( + $directory, + "." + $leaf + "." + [Guid]::NewGuid().ToString("N") + ".bak") + try { + [IO.File]::Replace($temporaryPath, $Path, $backupPath, $true) + } + finally { + if ([IO.File]::Exists($backupPath)) { + [IO.File]::Delete($backupPath) + } + } + } + else { + [IO.File]::Move($temporaryPath, $Path) + } + } + finally { + if ($null -ne $stream) { + $stream.Dispose() + } + + if ([IO.File]::Exists($temporaryPath)) { + [IO.File]::Delete($temporaryPath) + } + } +} + +try { + $captured = Read-StandardInputAndPassThrough -SuppressOutput:$Standalone + + if ([string]::IsNullOrWhiteSpace($OutputPath) -or -not (Test-FullyQualifiedPath -Path $OutputPath)) { + throw [ArgumentException]::new("The output path must be fully qualified.") + } + + $capturedAt = [DateTimeOffset]::UtcNow + $primary = $null + $weekly = $null + $parseSucceeded = -not $captured.Oversized + + if ($parseSucceeded) { + try { + $json = [Text.UTF8Encoding]::new($false, $true).GetString($captured.Bytes) + $source = $json | ConvertFrom-Json + $rateLimits = Get-JsonProperty -Object $source -Name "rate_limits" + $primary = Convert-RateLimitWindow -RateLimits $rateLimits -Name "five_hour" -CapturedAt $capturedAt + $weekly = Convert-RateLimitWindow -RateLimits $rateLimits -Name "seven_day" -CapturedAt $capturedAt + } + catch { + $parseSucceeded = $false + $primary = $null + $weekly = $null + } + } + + $validWindows = @($primary, $weekly) | Where-Object { $null -ne $_ } + $validCount = @($validWindows).Count + $status = if (-not $parseSucceeded -or $validCount -eq 0) { + "unavailable" + } + elseif ($validCount -eq 2) { + "available" + } + else { + "partial" + } + $message = if ($validCount -eq 2) { + "Claude rate-limit windows are available." + } + elseif ($validCount -eq 1) { + "One Claude rate-limit window is unavailable; windows remain independent." + } + else { + "No valid Claude rate-limit windows were provided." + } + + $snapshot = [ordered]@{ + schemaVersion = 1 + provider = "claude" + observedAtUTC = $capturedAt.ToString("O", [Globalization.CultureInfo]::InvariantCulture) + rate_limits = [ordered]@{ + five_hour = $primary + seven_day = $weekly + } + status = $status + message = $message + } + $snapshotJson = $snapshot | ConvertTo-Json -Depth 8 -Compress + Write-AtomicSnapshot -Path $OutputPath -Content $snapshotJson + + if ($Standalone) { + $primaryRemaining = if ($null -eq $primary) { + "--" + } + else { + ([double](100 - [double](Get-JsonProperty -Object $primary -Name "used_percentage"))).ToString( + "0.##", + [Globalization.CultureInfo]::InvariantCulture) + "%" + } + $weeklyRemaining = if ($null -eq $weekly) { + "--" + } + else { + ([double](100 - [double](Get-JsonProperty -Object $weekly -Name "used_percentage"))).ToString( + "0.##", + [Globalization.CultureInfo]::InvariantCulture) + "%" + } + + [Console]::WriteLine("Claude 5h $primaryRemaining / week $weeklyRemaining") + } + + exit 0 +} +catch { + [Console]::Error.WriteLine("Claude usage capture could not write the snapshot.") + exit 1 +}