Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
17 changes: 14 additions & 3 deletions src/TokenStatus.App/ApplicationContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -329,7 +339,8 @@ private async Task<RefreshOutcome> 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)
{
Expand All @@ -340,7 +351,7 @@ private async Task<RefreshOutcome> 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)
Expand Down
72 changes: 71 additions & 1 deletion src/TokenStatus.App/UI/SettingsForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppSettings, string?, bool, Task> _save;
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
56 changes: 50 additions & 6 deletions src/TokenStatus.App/UI/StatusPopupForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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))
{
Expand Down Expand Up @@ -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)}.");
Expand Down Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -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";
Expand Down
Loading