diff --git a/README.md b/README.md index 12647fc..68b4587 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,16 @@ [![Build](https://github.com/causercode/slash-status/actions/workflows/release.yml/badge.svg)](https://github.com/causercode/slash-status/actions/workflows/release.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -**/status** is a privacy-conscious Windows tray app for viewing Codex activity and quota, checking authoritative OpenCode Go subscription limits, receiving quota alerts, and temporarily keeping the PC awake. +**/status** is a privacy-conscious Windows tray app for viewing Codex activity and quota, receiving Claude Code subscription usage, checking authoritative OpenCode Go subscription limits, receiving quota alerts, and temporarily keeping the PC awake. It runs entirely as the current user. Codex authentication stays with the installed Codex CLI, and /status never reads Codex credential files. An optional OpenCode Go API key is stored in Windows Credential Manager rather than in the app's settings or logs. -> /status is an independent project. It is not affiliated with or endorsed by OpenAI or the OpenCode project. Product names and logos belong to their respective owners. +> /status is an independent project. It is not affiliated with or endorsed by OpenAI, Anthropic, or the OpenCode project. Product names and logos belong to their respective owners. ## Features - Codex account, rate-limit windows, reset times, and local activity totals +- Claude Code five-hour and weekly subscription usage, including sessions in T3 Code - OpenCode Go rolling five-hour, weekly, and monthly subscription usage - One-time notifications at 25% and 5% remaining, plus quota-reset notices - Keep-awake modes for the system or the system and display @@ -58,7 +59,17 @@ The command should print `True`. When the maintainer has configured Authenticode 2. The standard OpenAI Codex installation below `%LOCALAPPDATA%` 3. The current user's `PATH` -The app starts `codex app-server --listen stdio://` and reuses the CLI's existing authentication. If auto-detection fails, choose the exact `codex.exe` path in Settings. Shell shims such as `.cmd`, `.bat`, and `.ps1` files are deliberately not launched. +The app starts `codex app-server --listen stdio://` for a refresh batch, reuses the CLI's existing authentication, and closes the process as soon as the reads finish. It releases the executable between refreshes so Windows can update it. If auto-detection fails, choose the exact `codex.exe` path in Settings. Shell shims such as `.cmd`, `.bat`, and `.ps1` files are deliberately not launched. + +Codex quota refreshes default to five minutes (also the minimum); token activity defaults to thirty minutes. Existing faster quota intervals are raised to five minutes. Turn off **Refresh Codex automatically** in Settings for manual refresh only, including when you need an uninterrupted CLI update. **Refresh now** still works with automatic refresh disabled. + +### Claude Code + +Install the native Claude Code CLI and sign in to your subscription with `claude auth login`. /status detects `%USERPROFILE%\.local\bin\claude.exe` or the executable on PATH; you can also choose an explicit executable in **Settings**. Shell shims are not launched. + +Every five minutes, /status starts a short-lived Claude CLI and requests subscription usage through its read-only `get_usage` control request, using the same approach as [T3 Code](https://github.com/pingdotgg/t3code/blob/main/apps/server/src/provider/Layers/ClaudeProvider.ts). It sends no model prompt, disables tools, hooks, project settings, and MCP servers, and closes the CLI after each check. Authentication and network requests remain with Claude; /status never reads Claude credential files. This works while your sessions run in T3 and while Claude is closed. API-key billing does not provide subscription quota. + +The CLI control API is experimental and may change; this integration was verified with Claude Code 2.1.285. Failed or timed-out checks keep the previous quota with an explicit status. **Refresh now** queries immediately. Turn off **Refresh Claude automatically** for manual refresh only, including during CLI updates. A previous /status terminal bridge is automatically removed on startup, restoring the saved status-line setting only if it is still our bridge. Normal usage checks do not modify Claude settings or persist usage responses. ### OpenCode Go diff --git a/SECURITY.md b/SECURITY.md index ce331dc..13e67d3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -15,3 +15,5 @@ If private vulnerability reporting has not yet been enabled for the repository, ## Credential boundaries /status delegates Codex authentication to the installed Codex CLI. It does not read Codex credential files. OpenCode Go API keys are stored in Windows Credential Manager and are sent only to the fixed OpenCode usage endpoint over HTTPS. Logs are best-effort redacted, but users should still review diagnostic files before sharing them. + +Claude Code integration runs a short-lived native CLI for read-only subscription usage control requests. It sends no model prompt and disables tools, hooks, project settings, MCP servers, and IDE attachment. Claude owns authentication and network access; /status does not read Claude credential files or retain raw usage responses. Checks have a 30-second deadline and close the CLI on success, failure, or cancellation. A one-time migration restores a previous terminal status-line setting only when the current command exactly matches the bridge /status installed. diff --git a/src/TokenStatus.App/ApplicationContext.cs b/src/TokenStatus.App/ApplicationContext.cs index 0cb7dde..00579fe 100644 --- a/src/TokenStatus.App/ApplicationContext.cs +++ b/src/TokenStatus.App/ApplicationContext.cs @@ -7,6 +7,7 @@ using TokenStatus.Core.Services; using TokenStatus.Infrastructure.Awake; using TokenStatus.Infrastructure.Cli; +using TokenStatus.Infrastructure.Claude; using TokenStatus.Infrastructure.Codex; using TokenStatus.Infrastructure.Logging; using TokenStatus.Infrastructure.OpenCode; @@ -166,6 +167,14 @@ private async Task InitializeAsync() try { _settings = await _settingsStore.LoadAsync(CancellationToken.None).ConfigureAwait(true); + try + { + ClaudeStatusLineMigration.RestorePrevious(); + } + catch (Exception exception) + { + _log.Error("claude", "bridge_migration", "claude_bridge_restore_failed", exception); + } ReconcileStartupState(); _awake.Start(AwakeMode.Off, null); await RecreateProvidersAsync().ConfigureAwait(true); @@ -195,7 +204,8 @@ private async Task RecreateProvidersAsync() _snapshots, new SystemClock(), RefreshIntervals.FromSettings(_settings), - (code, exception) => _log.Error("refresh", "coordinator", code, exception)); + (code, exception) => _log.Error("refresh", "coordinator", code, exception), + new ClaudeCodeQuotaClient(_settings.ClaudeExecutablePath)); _coordinator.Start(); } @@ -329,7 +339,8 @@ private async Task RefreshNowAsync() var failed = IsRefreshFailure(snapshot.CodexAccount.Health) || IsRefreshFailure(snapshot.CodexRateLimits.Health) || IsRefreshFailure(snapshot.CodexTokenUsage.Health) || - IsRefreshFailure(snapshot.OpenCodeGoQuota.Health); + IsRefreshFailure(snapshot.OpenCodeGoQuota.Health) || + IsRefreshFailure(snapshot.ClaudeCodeQuota.Health); if (failed) { @@ -340,7 +351,7 @@ private async Task RefreshNowAsync() return new RefreshOutcome( true, notConfigured - ? "Updated just now. OpenCode Go needs configuration." + ? "Updated just now. Some providers need configuration." : "Updated just now."); } catch (Exception exception) diff --git a/src/TokenStatus.App/UI/SettingsForm.cs b/src/TokenStatus.App/UI/SettingsForm.cs index c049e10..fcfba6c 100644 --- a/src/TokenStatus.App/UI/SettingsForm.cs +++ b/src/TokenStatus.App/UI/SettingsForm.cs @@ -8,12 +8,16 @@ namespace TokenStatus.App.UI; public sealed class SettingsForm : Form { private readonly TextBox _codexPath; + private readonly TextBox _claudePath; + private readonly NumericUpDown _claudeSeconds; + private readonly CheckBox _claudeAutomaticRefresh; private PaddedTextBox _openCodeGoApiKey = null!; private CheckBox _showOpenCodeGoApiKey = null!; private Label _openCodeGoApiKeyState = null!; private readonly NumericUpDown _codexLimitsSeconds; private readonly NumericUpDown _codexUsageSeconds; private readonly NumericUpDown _openCodeSeconds; + private readonly CheckBox _codexAutomaticRefresh; private readonly CheckBox _quotaNotifications; private readonly CheckBox _startWithWindows; private readonly Func _save; @@ -82,11 +86,29 @@ public SettingsForm( AccessibleName = "Codex executable path", AccessibleDescription = "Leave blank to detect the Codex executable automatically." }; + _claudePath = new PaddedTextBox + { + Text = settings.ClaudeExecutablePath ?? string.Empty, + Dock = DockStyle.Top, + AccessibleName = "Claude Code executable path", + AccessibleDescription = "Leave blank to detect the native Claude executable automatically." + }; + _claudeSeconds = CreateSecondsControl(settings.ClaudeRefreshSeconds, + AppSettings.MinimumClaudeRefreshSeconds, "Claude subscription refresh interval"); + _claudeAutomaticRefresh = new CheckBox + { + Text = "Refresh Claude automatically (turn off during CLI updates)", + Checked = settings.ClaudeAutomaticRefreshEnabled, + AutoSize = true, + AccessibleName = "Automatic Claude refresh", + AccessibleDescription = "When off, Claude is queried only by Refresh now." + }; AddProviderSection(content); + AddClaudeCodeSection(content); _codexLimitsSeconds = CreateSecondsControl( settings.CodexRateLimitRefreshSeconds, - AppSettings.MinimumProviderRefreshSeconds, + AppSettings.MinimumCodexRefreshSeconds, "Codex quota refresh interval"); _codexUsageSeconds = CreateSecondsControl( settings.CodexUsageRefreshSeconds, @@ -96,6 +118,14 @@ public SettingsForm( settings.OpenCodeRefreshSeconds, AppSettings.MinimumProviderRefreshSeconds, "OpenCode Go quota refresh interval"); + _codexAutomaticRefresh = new CheckBox + { + Text = "Refresh Codex automatically (turn off during CLI updates)", + Checked = settings.CodexAutomaticRefreshEnabled, + AutoSize = true, + AccessibleName = "Automatic Codex refresh", + AccessibleDescription = "When off, Codex is queried only by Refresh now." + }; AddRefreshSection(content); _quotaNotifications = new CheckBox @@ -231,6 +261,31 @@ private void AddRefreshSection(TableLayoutPanel content) AddSecondsRow(layout, 0, "Codex quota refresh interval", _codexLimitsSeconds); AddSecondsRow(layout, 1, "Token activity refresh interval", _codexUsageSeconds); AddSecondsRow(layout, 2, "OpenCode Go quota refresh interval", _openCodeSeconds); + layout.Controls.Add(_codexAutomaticRefresh, 0, 3); + layout.SetColumnSpan(_codexAutomaticRefresh, 3); + section.Controls.Add(layout); + content.Controls.Add(section); + } + + private void AddClaudeCodeSection(TableLayoutPanel content) + { + var section = CreateSection("Claude Code subscription"); + var layout = CreateSectionLayout(); + var instructions = new Label + { + Text = "Checks your Claude subscription usage. Sign in with claude auth login.", + AutoSize = true, + MaximumSize = new Size(600, 0), + AccessibleName = "Claude Code subscription instructions" + }; + layout.Controls.Add(instructions, 0, 0); + layout.SetColumnSpan(instructions, 3); + AddFieldLabel(layout, 1, "Claude executable path"); + layout.Controls.Add(_claudePath, 1, 1); + layout.SetColumnSpan(_claudePath, 2); + AddSecondsRow(layout, 2, "Subscription refresh interval", _claudeSeconds); + layout.Controls.Add(_claudeAutomaticRefresh, 0, 3); + layout.SetColumnSpan(_claudeAutomaticRefresh, 3); section.Controls.Add(layout); content.Controls.Add(section); } @@ -594,12 +649,23 @@ private async Task SaveWorkflowAsync() return; } + var claudePath = NormalizePath(_claudePath.Text, "Claude executable"); + if (claudePath is null && !string.IsNullOrWhiteSpace(_claudePath.Text)) + { + _claudePath.Focus(); + Interlocked.Exchange(ref _saveInProgress, 0); + return; + } SetSaveInProgress(true); try { var settings = new AppSettings { CodexExecutablePath = codexPath, + ClaudeExecutablePath = claudePath, + ClaudeAutomaticRefreshEnabled = _claudeAutomaticRefresh.Checked, + ClaudeRefreshSeconds = (int)_claudeSeconds.Value, + CodexAutomaticRefreshEnabled = _codexAutomaticRefresh.Checked, CodexRateLimitRefreshSeconds = (int)_codexLimitsSeconds.Value, CodexUsageRefreshSeconds = (int)_codexUsageSeconds.Value, OpenCodeRefreshSeconds = (int)_openCodeSeconds.Value, @@ -650,9 +716,13 @@ private void SetTestInProgress(bool inProgress) private void SetSaveInProgress(bool inProgress) { _codexPath.Enabled = !inProgress; + _claudePath.Enabled = !inProgress; + _claudeSeconds.Enabled = !inProgress; + _claudeAutomaticRefresh.Enabled = !inProgress; _openCodeGoApiKey.Enabled = !inProgress; _showOpenCodeGoApiKey.Enabled = !inProgress; _codexLimitsSeconds.Enabled = !inProgress; + _codexAutomaticRefresh.Enabled = !inProgress; _codexUsageSeconds.Enabled = !inProgress; _openCodeSeconds.Enabled = !inProgress; _quotaNotifications.Enabled = !inProgress; diff --git a/src/TokenStatus.App/UI/StatusPopupForm.cs b/src/TokenStatus.App/UI/StatusPopupForm.cs index d70c459..38268f4 100644 --- a/src/TokenStatus.App/UI/StatusPopupForm.cs +++ b/src/TokenStatus.App/UI/StatusPopupForm.cs @@ -24,6 +24,10 @@ public sealed class StatusPopupForm : Form private readonly Label _todayTokensLabel; private readonly Label _lifetimeTokensLabel; private readonly Label _codexErrorLabel; + private readonly ProviderHealthIndicator _claudeHealthIndicator; + private readonly VerticalStackLayout _claudeLimitsPanel; + private readonly Label _claudeErrorLabel; + private readonly Label _claudeUpdatedLabel; private readonly ProviderHealthIndicator _openCodeHealthIndicator; private readonly VerticalStackLayout _openCodeLimitsPanel; private readonly Label _openCodeQuotaErrorLabel; @@ -140,6 +144,18 @@ public StatusPopupForm( codexCard.AddRow(_lifetimeTokensLabel); codexCard.AddRow(_codexErrorLabel); + _claudeLimitsPanel = CreateLimitsPanel(); + _claudeErrorLabel = CreateErrorLabel("Claude Code error details"); + _claudeUpdatedLabel = CreateLabel(string.Empty, 8, FontStyle.Regular); + _claudeUpdatedLabel.AccessibleName = "Claude Code last usage check"; + _claudeUpdatedLabel.Tag = "muted"; + var claudeCard = new SectionCard { AccessibleName = "Claude Code provider" }; + _content.AddRow(claudeCard); + _claudeHealthIndicator = AddProviderSectionHeader(claudeCard, "Claude Code"); + claudeCard.AddRow(_claudeLimitsPanel); + claudeCard.AddRow(_claudeUpdatedLabel); + claudeCard.AddRow(_claudeErrorLabel); + _openCodeLimitsPanel = CreateLimitsPanel(); _openCodeQuotaErrorLabel = CreateErrorLabel("OpenCode Go error details"); _openCodeSettingsButton = CreateButton("Open &provider settings", (_, _) => OpenSettings(), 168); @@ -564,6 +580,27 @@ public void UpdateSnapshot(AppSnapshot snapshot) UpdateRateLimitRows(snapshot, view.Now); } + _claudeHealthIndicator.SetHealth(snapshot.ClaudeCodeQuota.Health); + if (previous is null || !Equals(previous.ClaudeCodeQuota, snapshot.ClaudeCodeQuota)) + { + _claudeLimitsPanel.ClearRows(); + var quota = snapshot.ClaudeCodeQuota.Value; + if (quota?.FiveHour is { } fiveHour) + _claudeLimitsPanel.AddRow(new QuotaUsageView("Claude 5-hour limit", fiveHour.RemainingPercent, + FormatQuotaReset(fiveHour.ResetsAt, view.Now))); + if (quota?.SevenDay is { } sevenDay) + _claudeLimitsPanel.AddRow(new QuotaUsageView("Claude weekly limit", sevenDay.RemainingPercent, + FormatQuotaReset(sevenDay.ResetsAt, view.Now))); + if (quota is null) + _claudeLimitsPanel.AddRow(CreateLabel("Subscription quota: Not provided", 9, FontStyle.Regular)); + WindowsTheme.Apply(_claudeLimitsPanel); + } + _claudeErrorLabel.Text = snapshot.ClaudeCodeQuota.UserFacingError ?? string.Empty; + _claudeErrorLabel.AccessibleDescription = _claudeErrorLabel.Text; + _claudeErrorLabel.Visible = !string.IsNullOrWhiteSpace(_claudeErrorLabel.Text); + _claudeErrorLabel.Tag = snapshot.ClaudeCodeQuota.Health == ProviderHealth.NotConfigured ? "muted" : "error"; + WindowsTheme.Apply(_claudeErrorLabel); + _openCodeHealthIndicator.SetHealth(snapshot.OpenCodeGoQuota.Health); if (previous is null || !Equals(previous.OpenCodeGoQuota, snapshot.OpenCodeGoQuota)) { @@ -599,6 +636,10 @@ public void UpdateSnapshot(AppSnapshot snapshot) { SetLiveStatus($"Codex status: {StatusViewModel.DescribeHealth(snapshot.CodexRateLimits.Health)}."); } + else if (previous.ClaudeCodeQuota.Health != snapshot.ClaudeCodeQuota.Health) + { + SetLiveStatus($"Claude Code status: {StatusViewModel.DescribeHealth(snapshot.ClaudeCodeQuota.Health)}."); + } else if (previous.OpenCodeGoQuota.Health != snapshot.OpenCodeGoQuota.Health) { SetLiveStatus($"OpenCode Go status: {StatusViewModel.DescribeHealth(snapshot.OpenCodeGoQuota.Health)}."); @@ -700,7 +741,7 @@ private static void AddSectionHeader(SectionCard target, string text) private static ProviderHealthIndicator AddProviderSectionHeader( SectionCard target, string text, - ProviderIconKind iconKind) + ProviderIconKind? iconKind = null) { var label = CreateLabel(text, 10, FontStyle.Bold); label.Tag = "accent"; @@ -717,15 +758,14 @@ private static ProviderHealthIndicator AddProviderSectionHeader( AccessibleRole = AccessibleRole.Grouping, AccessibleName = $"{text} provider status" }; - var icon = new ProviderIcon(iconKind) - { - Margin = new Padding(0, 0, 6, 0) - }; var health = new ProviderHealthIndicator(text) { Margin = new Padding(6, 0, 0, 0) }; - header.Controls.Add(icon); + if (iconKind is { } kind) + { + header.Controls.Add(new ProviderIcon(kind) { Margin = new Padding(0, 0, 6, 0) }); + } header.Controls.Add(label); header.Controls.Add(health); target.AddRow(header); @@ -837,6 +877,10 @@ private void UpdateTimeLabels(DateTimeOffset now) var view = new StatusViewModel(_snapshot, now); _updatedLabel.Text = $"Updated {FormatAge(_snapshot.CapturedAt, now)}"; _updatedLabel.AccessibleDescription = _updatedLabel.Text; + _claudeUpdatedLabel.Text = _snapshot.ClaudeCodeQuota.Value is { } claude + ? $"Checked {FormatAge(claude.ObservedAt, now)} through Claude Code" + : "Subscription usage checked through Claude Code"; + _claudeUpdatedLabel.AccessibleDescription = _claudeUpdatedLabel.Text; _awakeLabel.Text = view.AwakeSummary; _awakeLabel.AccessibleDescription = view.AwakeSummary; _awakeLabel.Tag = _snapshot.Awake.UserFacingError is null ? null : "error"; diff --git a/src/TokenStatus.App/UI/StatusViewModel.cs b/src/TokenStatus.App/UI/StatusViewModel.cs index ce165c8..d66318e 100644 --- a/src/TokenStatus.App/UI/StatusViewModel.cs +++ b/src/TokenStatus.App/UI/StatusViewModel.cs @@ -47,7 +47,10 @@ public static string BuildTooltip(AppSnapshot snapshot) var awake = snapshot.Awake.GetRemaining(DateTimeOffset.UtcNow) is { } remaining ? FormatShortDuration(remaining) : snapshot.Awake.IsActive ? "on" : "off"; - var text = $"Codex {codex}/{secondary} left | OpenCode Go {openCode} left | Awake {awake}"; + var claude = snapshot.ClaudeCodeQuota.Value is { } claudeQuota + ? $"{claudeQuota.FiveHour?.RemainingPercent.ToString() ?? "--"}/{claudeQuota.SevenDay?.RemainingPercent.ToString() ?? "--"}%" + : "--/--"; + var text = $"Codex {codex}/{secondary} | Claude {claude} | Go {openCode} | Awake {awake}"; return text.Length <= 63 ? text : text[..63]; } diff --git a/src/TokenStatus.Core/Abstractions/IClaudeCodeQuotaClient.cs b/src/TokenStatus.Core/Abstractions/IClaudeCodeQuotaClient.cs new file mode 100644 index 0000000..b74f918 --- /dev/null +++ b/src/TokenStatus.Core/Abstractions/IClaudeCodeQuotaClient.cs @@ -0,0 +1,8 @@ +using TokenStatus.Core.Models; + +namespace TokenStatus.Core.Abstractions; + +public interface IClaudeCodeQuotaClient +{ + Task GetQuotaAsync(CancellationToken cancellationToken); +} diff --git a/src/TokenStatus.Core/Abstractions/ICodexUsageClient.cs b/src/TokenStatus.Core/Abstractions/ICodexUsageClient.cs index 6286a6b..fecc3ca 100644 --- a/src/TokenStatus.Core/Abstractions/ICodexUsageClient.cs +++ b/src/TokenStatus.Core/Abstractions/ICodexUsageClient.cs @@ -7,4 +7,7 @@ public interface ICodexUsageClient : IAsyncDisposable Task GetAccountAsync(CancellationToken cancellationToken); Task GetRateLimitsAsync(CancellationToken cancellationToken); Task GetTokenUsageAsync(CancellationToken cancellationToken); + + // Release the CLI executable between refresh batches so it can be updated. + ValueTask DisconnectAsync() => ValueTask.CompletedTask; } diff --git a/src/TokenStatus.Core/Models/AppSettings.cs b/src/TokenStatus.Core/Models/AppSettings.cs index 43a73c5..ff0556d 100644 --- a/src/TokenStatus.Core/Models/AppSettings.cs +++ b/src/TokenStatus.Core/Models/AppSettings.cs @@ -5,11 +5,17 @@ public sealed record AppSettings public const int CurrentSchemaVersion = 1; public const int MinimumProviderRefreshSeconds = 30; public const int MinimumTokenUsageRefreshSeconds = 300; + public const int MinimumCodexRefreshSeconds = 300; + public const int MinimumClaudeRefreshSeconds = 300; public int SchemaVersion { get; init; } = CurrentSchemaVersion; public string? CodexExecutablePath { get; init; } - public int CodexRateLimitRefreshSeconds { get; init; } = 120; - public int CodexUsageRefreshSeconds { get; init; } = 600; + public string? ClaudeExecutablePath { get; init; } + public bool ClaudeAutomaticRefreshEnabled { get; init; } = true; + public int ClaudeRefreshSeconds { get; init; } = 300; + public bool CodexAutomaticRefreshEnabled { get; init; } = true; + public int CodexRateLimitRefreshSeconds { get; init; } = 300; + public int CodexUsageRefreshSeconds { get; init; } = 1800; public int OpenCodeRefreshSeconds { get; init; } = 120; public bool QuotaNotificationsEnabled { get; init; } = true; public bool StartWithWindows { get; init; } @@ -19,7 +25,8 @@ public AppSettings Normalize() return this with { SchemaVersion = CurrentSchemaVersion, - CodexRateLimitRefreshSeconds = Math.Max(MinimumProviderRefreshSeconds, CodexRateLimitRefreshSeconds), + ClaudeRefreshSeconds = Math.Max(MinimumClaudeRefreshSeconds, ClaudeRefreshSeconds), + CodexRateLimitRefreshSeconds = Math.Max(MinimumCodexRefreshSeconds, CodexRateLimitRefreshSeconds), CodexUsageRefreshSeconds = Math.Max(MinimumTokenUsageRefreshSeconds, CodexUsageRefreshSeconds), OpenCodeRefreshSeconds = Math.Max(MinimumProviderRefreshSeconds, OpenCodeRefreshSeconds) }; diff --git a/src/TokenStatus.Core/Models/AppSnapshot.cs b/src/TokenStatus.Core/Models/AppSnapshot.cs index c7ee374..2b43881 100644 --- a/src/TokenStatus.Core/Models/AppSnapshot.cs +++ b/src/TokenStatus.Core/Models/AppSnapshot.cs @@ -8,6 +8,9 @@ public sealed record AppSnapshot( ProviderResult OpenCodeGoQuota, AwakeState Awake) { + public ProviderResult ClaudeCodeQuota { get; init; } = + ProviderResult.Loading(CapturedAt); + public static AppSnapshot Initial(DateTimeOffset now) => new( now, ProviderResult.Loading(now), diff --git a/src/TokenStatus.Core/Models/ClaudeCodeQuota.cs b/src/TokenStatus.Core/Models/ClaudeCodeQuota.cs new file mode 100644 index 0000000..bde81d1 --- /dev/null +++ b/src/TokenStatus.Core/Models/ClaudeCodeQuota.cs @@ -0,0 +1,6 @@ +namespace TokenStatus.Core.Models; + +public sealed record ClaudeCodeQuota( + DateTimeOffset ObservedAt, + RateLimitWindow? FiveHour, + RateLimitWindow? SevenDay); diff --git a/src/TokenStatus.Core/Services/OverallHealthCalculator.cs b/src/TokenStatus.Core/Services/OverallHealthCalculator.cs index c7ffb92..c2f10a5 100644 --- a/src/TokenStatus.Core/Services/OverallHealthCalculator.cs +++ b/src/TokenStatus.Core/Services/OverallHealthCalculator.cs @@ -26,7 +26,11 @@ public static TrayHealth Calculate(AppSnapshot snapshot) ? new[] { openCodeQuota.Rolling, openCodeQuota.Weekly, openCodeQuota.Monthly } .Max(window => (int)Math.Ceiling(Math.Clamp(window.UsedPercent, 0m, 100m))) : -1; - var maximumQuota = Math.Max(codexMaximumQuota, openCodeMaximumQuota); + var claudeMaximumQuota = snapshot.ClaudeCodeQuota.Value is { } claude + ? new[] { claude.FiveHour, claude.SevenDay }.OfType() + .Select(window => window.UsedPercent).DefaultIfEmpty(-1).Max() + : -1; + var maximumQuota = Math.Max(Math.Max(codexMaximumQuota, openCodeMaximumQuota), claudeMaximumQuota); var quotaReached = snapshot.CodexRateLimits.Value?.Buckets.Any(bucket => bucket.OrdinaryUsageAllowed == false || !string.IsNullOrWhiteSpace(bucket.ReachedType)) == true; @@ -43,7 +47,8 @@ public static TrayHealth Calculate(AppSnapshot snapshot) snapshot.CodexAccount.Health, snapshot.CodexRateLimits.Health, snapshot.CodexTokenUsage.Health, - snapshot.OpenCodeGoQuota.Health + snapshot.OpenCodeGoQuota.Health, + snapshot.ClaudeCodeQuota.Health }.Any(IsDegraded); var providerResults = new[] @@ -51,7 +56,8 @@ public static TrayHealth Calculate(AppSnapshot snapshot) (Health: snapshot.CodexAccount.Health, HasValue: snapshot.CodexAccount.Value is not null), (Health: snapshot.CodexRateLimits.Health, HasValue: snapshot.CodexRateLimits.Value is not null), (Health: snapshot.CodexTokenUsage.Health, HasValue: snapshot.CodexTokenUsage.Value is not null), - (Health: snapshot.OpenCodeGoQuota.Health, HasValue: snapshot.OpenCodeGoQuota.Value is not null) + (Health: snapshot.OpenCodeGoQuota.Health, HasValue: snapshot.OpenCodeGoQuota.Value is not null), + (Health: snapshot.ClaudeCodeQuota.Health, HasValue: snapshot.ClaudeCodeQuota.Value is not null) }; if (providerResults.All(provider => provider.Health == ProviderHealth.Loading) || providerResults.All(provider => !provider.HasValue && provider.Health is @@ -74,7 +80,8 @@ ProviderHealth.Unsupported or snapshot.CodexAccount.Health, snapshot.CodexRateLimits.Health, snapshot.CodexTokenUsage.Health, - snapshot.OpenCodeGoQuota.Health + snapshot.OpenCodeGoQuota.Health, + snapshot.ClaudeCodeQuota.Health }.Any(health => health == ProviderHealth.Healthy); return hasHealthyProvider ? TrayHealth.Green : TrayHealth.Gray; diff --git a/src/TokenStatus.Core/Services/QuotaNotificationTracker.cs b/src/TokenStatus.Core/Services/QuotaNotificationTracker.cs index 2d4bb74..4fe2632 100644 --- a/src/TokenStatus.Core/Services/QuotaNotificationTracker.cs +++ b/src/TokenStatus.Core/Services/QuotaNotificationTracker.cs @@ -147,6 +147,15 @@ private static IEnumerable ReadQuotas(AppSnapshot snapshot) } } + if (snapshot.ClaudeCodeQuota.Health == ProviderHealth.Healthy && + snapshot.ClaudeCodeQuota.Value is { } claude) + { + if (claude.FiveHour is { } fiveHour) + yield return ToReading("claude:five_hour", "Claude Code", "5-hour", fiveHour, true); + if (claude.SevenDay is { } sevenDay) + yield return ToReading("claude:seven_day", "Claude Code", "weekly", sevenDay, true); + } + if (snapshot.OpenCodeGoQuota.Health == ProviderHealth.Healthy && snapshot.OpenCodeGoQuota.Value is { } openCode) { diff --git a/src/TokenStatus.Core/Services/RefreshCoordinator.cs b/src/TokenStatus.Core/Services/RefreshCoordinator.cs index 1450000..5d667bb 100644 --- a/src/TokenStatus.Core/Services/RefreshCoordinator.cs +++ b/src/TokenStatus.Core/Services/RefreshCoordinator.cs @@ -6,18 +6,25 @@ namespace TokenStatus.Core.Services; public sealed record RefreshIntervals( TimeSpan CodexRateLimits, TimeSpan CodexTokenUsage, - TimeSpan OpenCodeQuota) + TimeSpan OpenCodeQuota, + bool CodexAutomaticRefreshEnabled = true, + TimeSpan? ClaudeQuota = null, + bool ClaudeAutomaticRefreshEnabled = true) { public static RefreshIntervals FromSettings(AppSettings settings) => new( - TimeSpan.FromSeconds(Math.Max(AppSettings.MinimumProviderRefreshSeconds, settings.CodexRateLimitRefreshSeconds)), + TimeSpan.FromSeconds(Math.Max(AppSettings.MinimumCodexRefreshSeconds, settings.CodexRateLimitRefreshSeconds)), TimeSpan.FromSeconds(Math.Max(AppSettings.MinimumTokenUsageRefreshSeconds, settings.CodexUsageRefreshSeconds)), - TimeSpan.FromSeconds(Math.Max(AppSettings.MinimumProviderRefreshSeconds, settings.OpenCodeRefreshSeconds))); + TimeSpan.FromSeconds(Math.Max(AppSettings.MinimumProviderRefreshSeconds, settings.OpenCodeRefreshSeconds)), + settings.CodexAutomaticRefreshEnabled, + TimeSpan.FromSeconds(Math.Max(AppSettings.MinimumClaudeRefreshSeconds, settings.ClaudeRefreshSeconds)), + settings.ClaudeAutomaticRefreshEnabled); } public sealed class RefreshCoordinator : IAsyncDisposable { private readonly ICodexUsageClient _codex; private readonly IOpenCodeGoQuotaClient _openCodeGo; + private readonly IClaudeCodeQuotaClient? _claudeCode; private readonly IAwakeController _awake; private readonly SnapshotStore _snapshots; private readonly IClock _clock; @@ -27,13 +34,19 @@ public sealed class RefreshCoordinator : IAsyncDisposable private readonly SemaphoreSlim _rateLimitsGate = new(1, 1); private readonly SemaphoreSlim _tokenUsageGate = new(1, 1); private readonly SemaphoreSlim _openCodeGoGate = new(1, 1); + private readonly SemaphoreSlim _claudeCodeGate = new(1, 1); private readonly object _coalescingGate = new(); private readonly CancellationTokenSource _shutdown = new(); private readonly List _loops = []; private readonly TaskSlot _accountRefresh = new(); + private readonly TaskSlot _codexRefresh = new(); private readonly TaskSlot _rateLimitsRefresh = new(); private readonly TaskSlot _tokenUsageRefresh = new(); private readonly TaskSlot _openCodeGoRefresh = new(); + private readonly TaskSlot _claudeCodeRefresh = new(); + private static readonly TimeSpan FreshnessInterval = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ClaudeMaximumAge = TimeSpan.FromMinutes(15); + private bool _codexRefreshIsForced; private int _started; private int _disposed; @@ -44,10 +57,12 @@ public RefreshCoordinator( SnapshotStore snapshots, IClock clock, RefreshIntervals intervals, - Action? diagnostic = null) + Action? diagnostic = null, + IClaudeCodeQuotaClient? claudeCode = null) { _codex = codex; _openCodeGo = openCodeGo; + _claudeCode = claudeCode; _awake = awake; _snapshots = snapshots; _clock = clock; @@ -64,20 +79,127 @@ public void Start() return; } - _loops.Add(Task.Run(() => RunLoopAsync(RefreshAccountAsync, _intervals.CodexRateLimits, MarkAccountStaleAsync))); - _loops.Add(Task.Run(() => RunLoopAsync(RefreshRateLimitsAsync, _intervals.CodexRateLimits, MarkRateLimitsStaleAsync))); - _loops.Add(Task.Run(() => RunLoopAsync(RefreshTokenUsageAsync, _intervals.CodexTokenUsage, MarkTokenUsageStaleAsync))); + if (_intervals.CodexAutomaticRefreshEnabled) + { + var interval = _intervals.CodexRateLimits < _intervals.CodexTokenUsage + ? _intervals.CodexRateLimits : _intervals.CodexTokenUsage; + _loops.Add(Task.Run(() => RunLoopAsync( + token => RefreshCodexAsync(false, token), interval, () => Task.CompletedTask))); + } _loops.Add(Task.Run(() => RunLoopAsync(RefreshOpenCodeGoAsync, _intervals.OpenCodeQuota, MarkOpenCodeGoStaleAsync))); + if (_claudeCode is not null && _intervals.ClaudeAutomaticRefreshEnabled) + { + _loops.Add(Task.Run(() => RunLoopAsync(RefreshClaudeCodeAsync, + _intervals.ClaudeQuota ?? TimeSpan.FromMinutes(5), MarkClaudeStaleAsync))); + } + // Freshness checks remain active in manual-only mode, without starting a CLI. + _loops.Add(Task.Run(() => RunLoopAsync( + RefreshLocalStateAsync, FreshnessInterval, () => Task.CompletedTask))); } public async Task RefreshNowAsync(CancellationToken cancellationToken = default) { ObjectDisposedException.ThrowIf(_disposed != 0, this); + cancellationToken.ThrowIfCancellationRequested(); await Task.WhenAll( - RefreshAccountAsync(cancellationToken), - RefreshRateLimitsAsync(cancellationToken), - RefreshTokenUsageAsync(cancellationToken), - RefreshOpenCodeGoAsync(cancellationToken)).ConfigureAwait(false); + RefreshCodexAsync(true, _shutdown.Token), + RefreshOpenCodeGoAsync(_shutdown.Token), + RefreshClaudeCodeAsync(_shutdown.Token)).WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task RefreshCodexAsync(bool force, CancellationToken cancellationToken) + { + Task refresh; + bool followUp; + lock (_coalescingGate) + { + // A scheduled batch may omit token usage. A manual request must still + // refresh every Codex value, even when it joins that active batch. + followUp = force && !_codexRefreshIsForced && + _codexRefresh.Current is { IsCompleted: false }; + refresh = Coalesce(_codexRefresh, () => + { + _codexRefreshIsForced = force; + return RefreshCodexCoreAsync(force, cancellationToken); + }); + } + await refresh.ConfigureAwait(false); + if (followUp) + { + await RefreshCodexAsync(true, cancellationToken).ConfigureAwait(false); + } + } + + private async Task RefreshCodexCoreAsync(bool force, CancellationToken cancellationToken) + { + var snapshot = _snapshots.Current; + var now = _clock.UtcNow; + var tasks = new List(); + try + { + if (force || now - snapshot.CodexRateLimits.LastAttemptAt >= _intervals.CodexRateLimits || + snapshot.CodexRateLimits.Health == ProviderHealth.Loading) + { + tasks.Add(RefreshAccountAsync(cancellationToken)); + tasks.Add(RefreshRateLimitsAsync(cancellationToken)); + } + if (force || now - snapshot.CodexTokenUsage.LastAttemptAt >= _intervals.CodexTokenUsage || + snapshot.CodexTokenUsage.Health == ProviderHealth.Loading) + { + tasks.Add(RefreshTokenUsageAsync(cancellationToken)); + } + await Task.WhenAll(tasks).ConfigureAwait(false); + } + finally + { + await _codex.DisconnectAsync().ConfigureAwait(false); + } + } + + private Task MarkCodexStaleAsync() => Task.WhenAll( + MarkAccountStaleAsync(), MarkRateLimitsStaleAsync(), MarkTokenUsageStaleAsync()); + + private Task RefreshLocalStateAsync(CancellationToken cancellationToken) => Task.WhenAll( + MarkClaudeStaleAsync(), MarkCodexStaleAsync()); + + private Task RefreshClaudeCodeAsync(CancellationToken cancellationToken) => _claudeCode is null + ? Task.CompletedTask + : Coalesce(_claudeCodeRefresh, () => RefreshClaudeCodeCoreAsync(cancellationToken)); + + private async Task RefreshClaudeCodeCoreAsync(CancellationToken cancellationToken) + { + await RunExclusiveAsync(_claudeCodeGate, async () => + { + var attempt = _clock.UtcNow; + try + { + var value = await _claudeCode!.GetQuotaAsync(cancellationToken).ConfigureAwait(false); + var stale = attempt - value.ObservedAt > ClaudeMaximumAge || + value.ObservedAt > attempt.AddMinutes(5); + _snapshots.Update(snapshot => snapshot with + { + CapturedAt = _clock.UtcNow, + ClaudeCodeQuota = new ProviderResult( + stale ? ProviderHealth.Stale : ProviderHealth.Healthy, + value, attempt, value.ObservedAt, + stale ? "The last Claude usage check is stale. Click Refresh to try again." : null, + stale ? "claude_usage_stale" : null) + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (RecordFailure( + "claude_code", exception, attempt, _snapshots.Current.ClaudeCodeQuota, + result => _snapshots.Update(snapshot => snapshot with + { + CapturedAt = _clock.UtcNow, + ClaudeCodeQuota = result + }))) + { + } + }, cancellationToken).ConfigureAwait(false); } private async Task RunLoopAsync( @@ -297,6 +419,11 @@ private Task MarkOpenCodeGoStaleAsync() => MarkStaleAsync( _intervals.OpenCodeQuota, (snapshot, result) => snapshot with { CapturedAt = _clock.UtcNow, OpenCodeGoQuota = result }); + private Task MarkClaudeStaleAsync() => MarkStaleAsync( + snapshot => snapshot.ClaudeCodeQuota, + _intervals.ClaudeQuota ?? TimeSpan.FromMinutes(5), + (snapshot, result) => snapshot with { CapturedAt = _clock.UtcNow, ClaudeCodeQuota = result }); + private Task MarkStaleAsync( Func> select, TimeSpan interval, @@ -394,7 +521,13 @@ public async ValueTask DisposeAsync() try { - await Task.WhenAll(_loops).ConfigureAwait(false); + Task[] active; + lock (_coalescingGate) + { + active = new[] { _codexRefresh.Current, _openCodeGoRefresh.Current, _claudeCodeRefresh.Current } + .OfType().ToArray(); + } + await Task.WhenAll(_loops.Concat(active)).ConfigureAwait(false); } catch (OperationCanceledException) { @@ -408,6 +541,7 @@ public async ValueTask DisposeAsync() _rateLimitsGate.Dispose(); _tokenUsageGate.Dispose(); _openCodeGoGate.Dispose(); + _claudeCodeGate.Dispose(); _shutdown.Dispose(); await _codex.DisposeAsync().ConfigureAwait(false); if (_openCodeGo is IDisposable disposableOpenCodeGo) diff --git a/src/TokenStatus.Infrastructure/Claude/ClaudeCodeQuotaClient.cs b/src/TokenStatus.Infrastructure/Claude/ClaudeCodeQuotaClient.cs new file mode 100644 index 0000000..f8fcae8 --- /dev/null +++ b/src/TokenStatus.Infrastructure/Claude/ClaudeCodeQuotaClient.cs @@ -0,0 +1,213 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; +using TokenStatus.Core.Abstractions; +using TokenStatus.Core.Models; +using TokenStatus.Infrastructure.Cli; +using TokenStatus.Infrastructure.Codex; + +namespace TokenStatus.Infrastructure.Claude; + +public sealed class ClaudeCodeQuotaClient(string? configuredPath = null, TimeSpan? requestTimeout = null) + : IClaudeCodeQuotaClient +{ + public async Task GetQuotaAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var executable = ExecutableResolver.Resolve(configuredPath, "claude.exe", Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe")); + if (executable is null) + { + throw new ProviderFailureException(ProviderHealth.NotInstalled, + "Install Claude Code, or choose its executable in Settings.", "claude_not_installed"); + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(requestTimeout ?? TimeSpan.FromSeconds(30)); + using var process = new Process { StartInfo = CreateStartInfo(executable) }; + using var drainCancellation = new CancellationTokenSource(); + Task stderr = Task.CompletedTask; + try + { + process.Start(); + stderr = DrainStderrAsync(process.StandardError, drainCancellation.Token); + var lines = new BoundedAsyncLineReader(process.StandardOutput, 4 * 1024 * 1024); + await RequestAsync(process, lines, "initialize", new { subtype = "initialize" }, timeout.Token) + .ConfigureAwait(false); + var response = await RequestAsync(process, lines, "usage", + new { subtype = "get_usage", skip_behaviors = true }, timeout.Token).ConfigureAwait(false); + return Parse(response, DateTimeOffset.UtcNow); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException exception) + { + throw new ProviderFailureException(ProviderHealth.Stale, + "Claude Code usage check timed out. Try Refresh again.", "claude_timeout", exception); + } + catch (Exception exception) when (exception is JsonException or FormatException or InvalidDataException) + { + throw new ProviderFailureException(ProviderHealth.Error, + "Claude Code returned an unsupported usage format. Check for a CLI update.", + "claude_usage_invalid", exception); + } + catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + throw new ProviderFailureException(ProviderHealth.Error, + "Claude Code could not report usage. Check its installation and sign in with claude auth login.", + "claude_query_failed", exception); + } + finally + { + StopProcess(process); + drainCancellation.Cancel(); + await stderr.ConfigureAwait(false); + } + } + + private static ProcessStartInfo CreateStartInfo(string executable) + { + var start = new ProcessStartInfo + { + FileName = executable, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetTempPath() + }; + // Only control requests are sent: no prompt, tools, hooks, project config, or saved session. + foreach (var argument in new[] + { + "--print", "--input-format", "stream-json", "--output-format", "stream-json", + "--verbose", "--no-session-persistence", "--setting-sources", "", + "--settings", "{\"disableAllHooks\":true}", "--tools", "", "--disable-slash-commands", + "--strict-mcp-config", "--mcp-config", "{\"mcpServers\":{}}", "--no-chrome" + }) + { + start.ArgumentList.Add(argument); + } + start.Environment["ENABLE_CLAUDEAI_MCP_SERVERS"] = "false"; + start.Environment["CLAUDE_CODE_AUTO_CONNECT_IDE"] = "0"; + start.Environment["CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL"] = "1"; + start.Environment["CLAUDE_CODE_ENTRYPOINT"] = "sdk-ts"; + start.Environment.Remove("FORCE_CODE_TERMINAL"); + return start; + } + + private static async Task RequestAsync( + Process process, BoundedAsyncLineReader lines, string id, object request, CancellationToken token) + { + await process.StandardInput.WriteLineAsync( + JsonSerializer.Serialize(new { type = "control_request", request_id = id, request }).AsMemory(), token) + .ConfigureAwait(false); + await process.StandardInput.FlushAsync(token).ConfigureAwait(false); + while (true) + { + var line = await lines.ReadBoundedLineAsync(token).ConfigureAwait(false); + if (line is null) throw new IOException("Claude CLI closed before replying."); + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("type", out var type) || + type.ValueKind != JsonValueKind.String) + continue; + if (type.GetString() == "control_request") + { + // Fail closed if a future CLI attempts to request permissions or run a hook. + throw new ProviderFailureException(ProviderHealth.Unsupported, + "This Claude Code version cannot perform a usage-only check.", "claude_control_unsupported"); + } + if (type.GetString() != "control_response" || + !root.TryGetProperty("response", out var response) || + response.ValueKind != JsonValueKind.Object || + !response.TryGetProperty("request_id", out var requestId) || + requestId.ValueKind != JsonValueKind.String || requestId.GetString() != id) + continue; + if (!response.TryGetProperty("subtype", out var subtype) || + subtype.ValueKind != JsonValueKind.String || subtype.GetString() != "success") + { + throw new ProviderFailureException(ProviderHealth.Unsupported, + "Claude Code could not query subscription usage. Sign in with claude auth login or update the CLI.", + "claude_usage_unsupported"); + } + if (!response.TryGetProperty("response", out var result)) + throw new FormatException("Claude response payload is missing."); + return result.Clone(); + } + } + + public static ClaudeCodeQuota Parse(JsonElement root, DateTimeOffset observedAt) + { + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("rate_limits_available", out var available) || + available.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new FormatException("Claude usage availability is missing."); + if (!available.GetBoolean()) + throw new ProviderFailureException(ProviderHealth.NotAuthenticated, + "Sign in to a Claude subscription with claude auth login. API-key sessions do not provide subscription quota.", + "claude_subscription_required"); + if (!root.TryGetProperty("rate_limits", out var limits) || limits.ValueKind == JsonValueKind.Null) + throw new ProviderFailureException(ProviderHealth.Stale, + "Claude subscription usage is temporarily unavailable. Try Refresh again.", "claude_usage_unavailable"); + if (limits.ValueKind != JsonValueKind.Object) throw new FormatException("Claude rate limits are invalid."); + var fiveHour = ParseWindow(limits, "five_hour", TimeSpan.FromHours(5)); + var sevenDay = ParseWindow(limits, "seven_day", TimeSpan.FromDays(7)); + if (fiveHour is null && sevenDay is null) + throw new FormatException("Claude subscription windows are missing."); + return new ClaudeCodeQuota(observedAt, fiveHour, sevenDay); + } + + private static RateLimitWindow? ParseWindow(JsonElement limits, string name, TimeSpan duration) + { + if (!limits.TryGetProperty(name, out var window) || window.ValueKind == JsonValueKind.Null) + return null; + if (window.ValueKind != JsonValueKind.Object || + !window.TryGetProperty("utilization", out var used) || + used.ValueKind != JsonValueKind.Number || !used.TryGetDecimal(out var percent) || percent < 0) + throw new FormatException("Claude usage percentage is invalid."); + DateTimeOffset? resetsAt = null; + if (window.TryGetProperty("resets_at", out var reset) && reset.ValueKind != JsonValueKind.Null) + { + if (reset.ValueKind != JsonValueKind.String || + !DateTimeOffset.TryParse(reset.GetString(), CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, out var timestamp)) + throw new FormatException("Claude reset time is invalid."); + resetsAt = timestamp; + } + return new RateLimitWindow((int)Math.Ceiling(Math.Min(100, percent)), duration, resetsAt); + } + + private static async Task DrainStderrAsync(StreamReader reader, CancellationToken token) + { + try + { + var buffer = new char[4096]; + while (await reader.ReadAsync(buffer.AsMemory(), token).ConfigureAwait(false) != 0) { } + } + catch (Exception exception) when (exception is OperationCanceledException or IOException or ObjectDisposedException) + { + } + } + + private static void StopProcess(Process process) + { + try + { + if (process.HasExited) return; + try { process.StandardInput.Close(); } + catch (IOException) { } + if (!process.WaitForExit(2000)) + { + process.Kill(entireProcessTree: true); + process.WaitForExit(2000); + } + } + catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception or IOException) + { + // A process which failed to start or already exited needs no cleanup. + } + } +} diff --git a/src/TokenStatus.Infrastructure/Claude/ClaudeStatusLineMigration.cs b/src/TokenStatus.Infrastructure/Claude/ClaudeStatusLineMigration.cs new file mode 100644 index 0000000..cc7935b --- /dev/null +++ b/src/TokenStatus.Infrastructure/Claude/ClaudeStatusLineMigration.cs @@ -0,0 +1,54 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace TokenStatus.Infrastructure.Claude; + +public static class ClaudeStatusLineMigration +{ + public static void RestorePrevious(string? configDirectory = null, string? dataDirectory = null) + { + var configuredDirectory = configDirectory ?? Environment.GetEnvironmentVariable("CLAUDE_CONFIG_DIR"); + var config = string.IsNullOrWhiteSpace(configuredDirectory) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude") + : Path.GetFullPath(configuredDirectory); + var data = dataDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TokenStatus", "claude"); + var settingsPath = Path.Combine(config, "settings.json"); + var previousPath = Path.Combine(data, "previous-statusline.json"); + if (!File.Exists(settingsPath) || !File.Exists(previousPath)) return; + var original = File.ReadAllText(settingsPath); + var settings = JsonNode.Parse(original, documentOptions: new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip + }) as JsonObject ?? throw new FormatException("Claude settings must be a JSON object."); + var scriptPath = Path.Combine(data, "statusline.ps1"); + var invocation = "& '" + scriptPath.Replace("'", "''") + "'"; + var command = "powershell.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand " + + Convert.ToBase64String(Encoding.Unicode.GetBytes(invocation)); + // Restore only our own bridge, preserving any later user customization. + if (settings["statusLine"] is not JsonObject status || + status["command"] is not JsonValue value || + !value.TryGetValue(out var currentCommand) || currentCommand != command) return; + var previous = JsonNode.Parse(File.ReadAllText(previousPath)); + if (previous is null) settings.Remove("statusLine"); + else settings["statusLine"] = previous; + var temporary = settingsPath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(temporary, settings.ToJsonString(new() { WriteIndented = true })); + if (File.ReadAllText(settingsPath) != original) + throw new IOException("Claude settings changed during bridge removal."); + File.Move(temporary, settingsPath, overwrite: true); + } + finally + { + if (File.Exists(temporary)) File.Delete(temporary); + } + foreach (var file in new[] { scriptPath, previousPath, Path.Combine(data, "usage.json") }) + { + if (File.Exists(file)) File.Delete(file); + } + } +} diff --git a/src/TokenStatus.Infrastructure/Codex/CodexAppServerClient.cs b/src/TokenStatus.Infrastructure/Codex/CodexAppServerClient.cs index e50c04f..83421c4 100644 --- a/src/TokenStatus.Infrastructure/Codex/CodexAppServerClient.cs +++ b/src/TokenStatus.Infrastructure/Codex/CodexAppServerClient.cs @@ -227,13 +227,8 @@ private void RegisterRestartFailure() } } - public async ValueTask DisposeAsync() + public async ValueTask DisconnectAsync() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) - { - return; - } - JsonLineRpcConnection? connection; lock (_gate) { @@ -246,4 +241,12 @@ public async ValueTask DisposeAsync() await connection.DisposeAsync().ConfigureAwait(false); } } + + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + await DisconnectAsync().ConfigureAwait(false); + } + } } diff --git a/src/TokenStatus.Infrastructure/Codex/JsonLineRpcConnection.cs b/src/TokenStatus.Infrastructure/Codex/JsonLineRpcConnection.cs index 7ce97d2..ab78591 100644 --- a/src/TokenStatus.Infrastructure/Codex/JsonLineRpcConnection.cs +++ b/src/TokenStatus.Infrastructure/Codex/JsonLineRpcConnection.cs @@ -333,6 +333,7 @@ private void StopProcess(Exception reason) if (!process.WaitForExit(2000)) { process.Kill(entireProcessTree: true); + process.WaitForExit(2000); } } } diff --git a/src/TokenStatus.Infrastructure/OpenCode/OpenCodeGoQuotaClient.cs b/src/TokenStatus.Infrastructure/OpenCode/OpenCodeGoQuotaClient.cs index 1b7fde3..c068fac 100644 --- a/src/TokenStatus.Infrastructure/OpenCode/OpenCodeGoQuotaClient.cs +++ b/src/TokenStatus.Infrastructure/OpenCode/OpenCodeGoQuotaClient.cs @@ -16,15 +16,18 @@ public sealed class OpenCodeGoQuotaClient : IOpenCodeGoQuotaClient, IDisposable private readonly HttpClient _httpClient; private readonly bool _ownsHttpClient; private readonly IRedactedLog? _log; + private readonly TimeSpan _requestTimeout; private int _disposed; public OpenCodeGoQuotaClient( IOpenCodeGoCredentialStore credentials, HttpClient? httpClient = null, - IRedactedLog? log = null) + IRedactedLog? log = null, + TimeSpan? requestTimeout = null) { _credentials = credentials; _log = log; + _requestTimeout = requestTimeout ?? TimeSpan.FromSeconds(10); _ownsHttpClient = httpClient is null; _httpClient = httpClient ?? CreateHttpClient(); } @@ -45,6 +48,21 @@ public Task GetQuotaAsync(CancellationToken cancellationToken) } public async Task GetQuotaAsync(string apiKey, CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_requestTimeout); + try + { + return await GetQuotaCoreAsync(apiKey, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new ProviderFailureException(ProviderHealth.Stale, + "OpenCode Go usage check timed out. Try Refresh again.", "opencode_go_timeout", exception); + } + } + + private async Task GetQuotaCoreAsync(string apiKey, CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_disposed != 0, this); ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); @@ -192,7 +210,7 @@ private static OpenCodeQuotaWindow ParseWindow(JsonElement usage, string name) { var window = RequiredObject(usage, name); if (!window.TryGetProperty("percent", out var percentElement) || - !percentElement.TryGetDecimal(out var percent) || + percentElement.ValueKind != JsonValueKind.Number || !percentElement.TryGetDecimal(out var percent) || percent < 0) { throw new FormatException($"OpenCode quota window {name} has an invalid percent."); diff --git a/tests/TokenStatus.App.Tests/AccessibilityFormTests.cs b/tests/TokenStatus.App.Tests/AccessibilityFormTests.cs index 30fa230..445b009 100644 --- a/tests/TokenStatus.App.Tests/AccessibilityFormTests.cs +++ b/tests/TokenStatus.App.Tests/AccessibilityFormTests.cs @@ -8,6 +8,36 @@ namespace TokenStatus.App.Tests; public sealed class AccessibilityFormTests { + [Fact] + public void ClaudeQuotaExposesAccessibleValuesAndCheckTime() + { + RunOnSta(() => + { + var now = DateTimeOffset.UtcNow; + var snapshot = CreateSnapshot() with + { + ClaudeCodeQuota = new ProviderResult(ProviderHealth.Stale, + new ClaudeCodeQuota(now.AddHours(-1), + new RateLimitWindow(24, TimeSpan.FromHours(5), now.AddHours(2)), + new RateLimitWindow(88, TimeSpan.FromDays(7), now.AddDays(2))), + now, now.AddHours(-1), "Last reported usage is stale.", "claude_usage_stale") + }; + using var form = new StatusPopupForm(snapshot, + () => Task.FromResult(new RefreshOutcome(true, "Updated")), () => { }, (_, _) => { }, () => { }); + form.CreateControl(); + var progress = Descendants(form).OfType() + .Single(control => control.AccessibleName == "Claude 5-hour limit quota remaining"); + Assert.Equal(76, progress.Value); + Assert.Equal(AccessibleRole.ProgressBar, progress.AccessibleRole); + var reported = Descendants(form).OfType