From 813f0614936e803050dbe4261d192f858da3ac27 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 00:27:01 +0300 Subject: [PATCH 1/7] Add token usage --- .../Data/Claude/ApiClaudeDtos.cs | 22 ++ .../Data/Claude/ClaudeTokenLogReader.cs | 194 ++++++++++++++++++ .../Data/Claude/ClaudeUsageProvider.cs | 12 ++ AgentsPanelExtension/Data/UsageRepository.cs | 10 +- AgentsPanelExtension/Models/DomainUsage.cs | 13 +- AgentsPanelExtension/Models/UiUsage.cs | 26 +++ .../Pages/UsageProviderPage.cs | 14 ++ .../Properties/Resources.Designer.cs | 36 ++++ .../Properties/Resources.resx | 12 ++ .../Settings/UsageSettingsManager.cs | 12 ++ 10 files changed, 349 insertions(+), 2 deletions(-) create mode 100644 AgentsPanelExtension/Data/Claude/ClaudeTokenLogReader.cs diff --git a/AgentsPanelExtension/Data/Claude/ApiClaudeDtos.cs b/AgentsPanelExtension/Data/Claude/ApiClaudeDtos.cs index d8c9f54..9c57f24 100644 --- a/AgentsPanelExtension/Data/Claude/ApiClaudeDtos.cs +++ b/AgentsPanelExtension/Data/Claude/ApiClaudeDtos.cs @@ -60,9 +60,31 @@ internal sealed record ApiClaudeOauthDto( [property: JsonPropertyName("subscriptionType")] string? SubscriptionType, [property: JsonPropertyName("rateLimitTier")] string? RateLimitTier); +// One line of a Claude Code transcript (%USERPROFILE%\.claude\projects\\.jsonl) — only +// the sliver ClaudeTokenLogReader needs. Assistant lines carry message.usage (the API's own usage +// object); everything else (user lines, summaries, tool results) has Type != "assistant" or a null +// Usage and is skipped. The same message id can appear on SEVERAL lines (streaming rewrites, verified +// live 2026-08) — readers must dedupe by message id, keeping the LAST occurrence. +internal sealed record ApiClaudeTranscriptLineDto( + [property: JsonPropertyName("type")] string? Type, + [property: JsonPropertyName("timestamp")] string? Timestamp, // ISO-8601 UTC + [property: JsonPropertyName("requestId")] string? RequestId, + [property: JsonPropertyName("message")] ApiClaudeTranscriptMessageDto? Message); + +internal sealed record ApiClaudeTranscriptMessageDto( + [property: JsonPropertyName("id")] string? Id, + [property: JsonPropertyName("usage")] ApiClaudeTranscriptUsageDto? Usage); + +internal sealed record ApiClaudeTranscriptUsageDto( + [property: JsonPropertyName("input_tokens")] long? InputTokens, + [property: JsonPropertyName("output_tokens")] long? OutputTokens, + [property: JsonPropertyName("cache_read_input_tokens")] long? CacheReadInputTokens, + [property: JsonPropertyName("cache_creation_input_tokens")] long? CacheCreationInputTokens); + // ⚠️ ALL [JsonSerializable] attributes for this context MUST stay on this ONE partial declaration. // Splitting them across multiple partials silently breaks the source generator for the whole build // (every context type comes back null at runtime) — MarketExtension hit exactly this. [JsonSerializable(typeof(ApiClaudeUsageDto))] [JsonSerializable(typeof(ApiClaudeCredentialsFileDto))] +[JsonSerializable(typeof(ApiClaudeTranscriptLineDto))] internal sealed partial class ClaudeJsonContext : JsonSerializerContext; diff --git a/AgentsPanelExtension/Data/Claude/ClaudeTokenLogReader.cs b/AgentsPanelExtension/Data/Claude/ClaudeTokenLogReader.cs new file mode 100644 index 0000000..faa54ca --- /dev/null +++ b/AgentsPanelExtension/Data/Claude/ClaudeTokenLogReader.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace AgentsPanelExtension; + +// Sums the last 24 hours' token usage from Claude Code's own local transcripts +// (%USERPROFILE%\.claude\projects\\.jsonl) — no network, no auth, works even when the +// OAuth token is expired. This is the "activity" companion to the quota endpoint: machine-local by +// nature (usage on other devices/web never appears here) and deliberately NOT mapped to quota %. +// +// Transcripts are append-only JSONL, so the reader tails incrementally: per file it remembers the byte +// offset after the last fully-parsed line plus the per-message usage seen so far, and each poll only +// parses bytes appended since. Active files reach tens of MB — re-reading them every 3-minute poll is +// the failure mode this cache exists to avoid. A file that SHRANK (rotation/rewrite) is reparsed from +// zero. Streaming rewrites repeat a message id across lines (verified live), so usage is keyed by +// message id with last-occurrence-wins, then summed. +// +// Never throws: any failure degrades to null (provider shows no token row) with a Warn. One instance +// per provider; the repository single-flights refreshes, so no internal locking is needed. +internal sealed class ClaudeTokenLogReader +{ + private const string Tag = "ClaudeTokens"; + + private static string ProjectsRoot => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".claude", "projects"); + + // One transcript's tail state. Messages holds every usage-bearing line seen in the file — the + // rolling-window filter happens at sum time, not parse time, so entries age out of the total + // naturally as the window slides without any reparsing. + private sealed class FileState + { + public long Offset; + public readonly Dictionary Messages = new(StringComparer.Ordinal); + } + + private readonly record struct MessageUsage( + DateTimeOffset Timestamp, long Input, long Output, long CacheRead, long CacheWrite); + + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + + public DomainTokenStats? ReadLast24Hours() + { + try + { + var root = new DirectoryInfo(ProjectsRoot); + if (!root.Exists) + { + Log.Info(Tag, "projects directory not found — no local transcripts"); + return null; + } + + // Rolling 24-hour window (not calendar-day: an "as of midnight" reset would zero the row + // right when a late-night session is in full swing). Only files touched inside the window + // can contain qualifying lines, and only lines stamped inside it count. + var windowStart = DateTimeOffset.UtcNow.AddHours(-24); + + var parsed = 0; + foreach (var file in root.EnumerateFiles("*.jsonl", SearchOption.AllDirectories)) + { + if (file.LastWriteTimeUtc < windowStart.UtcDateTime) + continue; + TailFile(file); + parsed++; + } + + // Drop tail state for files that can no longer contribute (untouched for 24h) so the + // cache doesn't grow a new entry per session forever. + PruneStale(windowStart); + + long input = 0, output = 0, cacheRead = 0, cacheWrite = 0; + var messages = 0; + foreach (var state in _files.Values) + { + foreach (var usage in state.Messages.Values) + { + if (usage.Timestamp < windowStart) + continue; + input += usage.Input; + output += usage.Output; + cacheRead += usage.CacheRead; + cacheWrite += usage.CacheWrite; + messages++; + } + } + + Log.Info(Tag, $"tailed {parsed} file(s): {messages} message(s) in the last 24h"); + return new DomainTokenStats(input, output, cacheRead, cacheWrite); + } + catch (Exception ex) + { + Log.Warn(Tag, $"token log read failed — no token stats this poll ({ex.GetType().Name}: {ex.Message})"); + return null; + } + } + + // Parse the bytes appended since the last poll. A per-file failure (sharing violation, torn write) + // only skips THAT file this poll — the saved offset makes the next attempt cheap. + private void TailFile(FileInfo file) + { + if (!_files.TryGetValue(file.FullName, out var state)) + _files[file.FullName] = state = new FileState(); + + try + { + // Claude Code holds these open for append — share everything. + using var stream = new FileStream( + file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + if (stream.Length < state.Offset) + { + Log.Info(Tag, $"transcript shrank — reparsing from zero: {file.Name}"); + state.Offset = 0; + state.Messages.Clear(); + } + + if (stream.Length == state.Offset) + return; + + stream.Position = state.Offset; + var buffer = new byte[stream.Length - state.Offset]; + stream.ReadExactly(buffer); + + // Only complete (newline-terminated) lines are consumed; a trailing partial line stays + // un-consumed (offset not advanced past it) and is retried when the writer finishes it. + var lineStart = 0; + for (var i = 0; i < buffer.Length; i++) + { + if (buffer[i] != (byte)'\n') + continue; + var line = buffer.AsSpan(lineStart, i - lineStart); + if (line.Length > 0 && line[^1] == (byte)'\r') + line = line[..^1]; + ParseLine(line, state.Messages); + lineStart = i + 1; + } + + state.Offset += lineStart; + } + catch (Exception ex) + { + Log.Warn(Tag, $"failed tailing '{file.Name}' — skipped this poll ({ex.GetType().Name})"); + } + } + + // One JSONL line → at most one Messages entry. Non-assistant lines, usage-less lines, and + // malformed JSON are all consumed silently — transcripts legitimately contain many line shapes. + private static void ParseLine(ReadOnlySpan line, Dictionary messages) + { + if (line.IsEmpty) + return; + + ApiClaudeTranscriptLineDto? dto; + try + { + dto = JsonSerializer.Deserialize(line, ClaudeJsonContext.Default.ApiClaudeTranscriptLineDto); + } + catch (JsonException) + { + return; + } + + if (dto is not { Type: "assistant", Message: { Id: { Length: > 0 } id, Usage: { } usage } }) + return; + if (!DateTimeOffset.TryParse(dto.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.None, out var timestamp)) + return; + + // Last occurrence wins — a streaming rewrite of the same message replaces its earlier partial. + messages[id] = new MessageUsage( + timestamp, + usage.InputTokens ?? 0, + usage.OutputTokens ?? 0, + usage.CacheReadInputTokens ?? 0, + usage.CacheCreationInputTokens ?? 0); + } + + private void PruneStale(DateTimeOffset windowStart) + { + List? stale = null; + foreach (var (path, _) in _files) + { + // Missing files prune too (File.GetLastWriteTimeUtc returns a sentinel far in the past). + if (File.GetLastWriteTimeUtc(path) < windowStart.UtcDateTime) + (stale ??= []).Add(path); + } + + if (stale is null) + return; + foreach (var path in stale) + _files.Remove(path); + } +} diff --git a/AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs b/AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs index 0502de5..3ac1aa1 100644 --- a/AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs +++ b/AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs @@ -34,6 +34,10 @@ internal sealed class ClaudeUsageProvider : IAgentUsageProvider // per-request because the token is re-read from disk on every fetch. private static readonly HttpClient Http = CreateClient(); + // Tails Claude Code's local transcripts for the last 24h's token counts. Stateful (per-file byte + // offsets) — must live as long as the provider so increments stay cheap across polls. + private readonly ClaudeTokenLogReader _tokenLogReader = new(); + public string Id => "claude"; public string DisplayName => "Claude"; @@ -43,6 +47,14 @@ internal sealed class ClaudeUsageProvider : IAgentUsageProvider public bool IsAvailable => true; public async Task GetUsageAsync(CancellationToken ct = default) + { + // Token stats come from LOCAL logs — independent of the endpoint, so they ride on every + // outcome, including NotConfigured/TokenExpired (the row still works while signed out). + var snapshot = await FetchQuotaAsync(ct).ConfigureAwait(false); + return snapshot with { TokenStats = _tokenLogReader.ReadLast24Hours() }; + } + + private async Task FetchQuotaAsync(CancellationToken ct) { // Read credentials at call time — a sign-in/sign-out applies on the next poll, no reload. var credentials = ClaudeCredentialsReader.Read(); diff --git a/AgentsPanelExtension/Data/UsageRepository.cs b/AgentsPanelExtension/Data/UsageRepository.cs index 864dc10..5cb3733 100644 --- a/AgentsPanelExtension/Data/UsageRepository.cs +++ b/AgentsPanelExtension/Data/UsageRepository.cs @@ -160,6 +160,10 @@ private static async Task FetchSafeAsync(IAgentUsageProvide // them, so the UI shows the last real numbers marked stale. A fresh Ok replaces outright. So does // NotConfigured: the credentials are GONE, and old numbers with a "sign in" prompt would mislead. // Membership is defined by the fresh set — providers that dropped out (IsAvailable off) disappear. + // + // TokenStats ride the FRESH snapshot even under keep-last-good: they come from local logs, so a + // failed endpoint poll usually still carries current counts. Only a fresh null (log read failed) + // keeps the old value. private static IReadOnlyList Merge( IReadOnlyList prev, DomainUsageSnapshot[] fresh) { @@ -171,7 +175,11 @@ private static IReadOnlyList Merge( && prevById.TryGetValue(snapshot.ProviderId, out var last) && last.Windows.Count > 0; merged.Add(keepLastGood - ? prevById[snapshot.ProviderId] with { Status = snapshot.Status } + ? prevById[snapshot.ProviderId] with + { + Status = snapshot.Status, + TokenStats = snapshot.TokenStats ?? prevById[snapshot.ProviderId].TokenStats, + } : snapshot); } diff --git a/AgentsPanelExtension/Models/DomainUsage.cs b/AgentsPanelExtension/Models/DomainUsage.cs index 35d45f2..7d9a81b 100644 --- a/AgentsPanelExtension/Models/DomainUsage.cs +++ b/AgentsPanelExtension/Models/DomainUsage.cs @@ -40,6 +40,16 @@ internal enum UsageStatus Error, // network/HTTP/parse failure } +// Tokens consumed in the last 24 hours (rolling), summed from the provider CLI's own session logs on +// THIS machine — a different beast from the quota windows: machine-local (other devices/web don't +// appear) and NOT convertible to quota % (limits are opaque weighted units server-side). Null on the +// snapshot when the provider has no local logs (Copilot) or the read failed. NO formatting here. +internal sealed record DomainTokenStats( + long InputTokens, // non-cache input + long OutputTokens, + long CacheReadTokens, // cache_read_input_tokens + long CacheWriteTokens); // cache_creation_input_tokens + // One provider's usage state. Providers return this and NEVER throw for expected failures: a failure is // a snapshot with Status != Ok and empty Windows. The repository then merges it with the last good // snapshot (keeping Windows/FetchedAt/PlanLabel, taking the new Status) so a bad poll never blanks a @@ -50,7 +60,8 @@ internal sealed record DomainUsageSnapshot( UsageStatus Status, IReadOnlyList Windows, string? PlanLabel, // e.g. "Max 20x" from subscriptionType + rateLimitTier - DateTimeOffset? FetchedAt) // when Windows were last SUCCESSFULLY fetched; null = never + DateTimeOffset? FetchedAt, // when Windows were last SUCCESSFULLY fetched; null = never + DomainTokenStats? TokenStats = null) // today's local-log token counts; independent of Status { // True when this snapshot is showing old numbers under a non-Ok status (keep-last-good survivor). public bool IsStale => Status != UsageStatus.Ok && Windows.Count > 0; diff --git a/AgentsPanelExtension/Models/UiUsage.cs b/AgentsPanelExtension/Models/UiUsage.cs index b5687e9..6e2f47f 100644 --- a/AgentsPanelExtension/Models/UiUsage.cs +++ b/AgentsPanelExtension/Models/UiUsage.cs @@ -120,6 +120,32 @@ public IEnumerable VisibleWindows(UsageSettingsManager settings) public UiUsageWindow? WorstVisibleWindow(UsageSettingsManager settings) => VisibleWindows(settings).OrderByDescending(w => w.Window.Utilization).FirstOrDefault(); + // "56k in · 29k out · 2.2M cache read" for the provider page's "Tokens (24h)" row — the last 24 + // hours' counts from the provider's local session logs. "in" = fresh input + cache WRITES (tokens entering the + // model as new context) — the raw input_tokens alone is a misleading crumb once prompt caching is + // doing its job (observed: 56 for a full day). Cache READS stay separate: they're replay volume, + // interesting but a different beast. Null when the provider has no local logs, the read failed, or + // the setting is off. + public string? TokenSummary(UsageSettingsManager settings) + { + if (!settings.ShowTokenStats || Snapshot.TokenStats is not { } tokens) + return null; + return Strings.Format( + Resources.Tokens_Summary, + FormatTokens(tokens.InputTokens + tokens.CacheWriteTokens), + FormatTokens(tokens.OutputTokens), + FormatTokens(tokens.CacheReadTokens)); + } + + // Compact count: 823 → "823", 52_300 → "52k", 1_430_000 → "1.4M". One decimal only while the + // leading figure is a single digit. Invariant digits, matching FormatPercent. + private static string FormatTokens(long count) => count switch + { + >= 1_000_000 => ((double)count / 1_000_000).ToString(count < 10_000_000 ? "0.#" : "0", CultureInfo.InvariantCulture) + "M", + >= 1_000 => ((double)count / 1_000).ToString(count < 10_000 ? "0.#" : "0", CultureInfo.InvariantCulture) + "k", + _ => count.ToString(CultureInfo.InvariantCulture), + }; + // "stale · as of 17:02" — shown on window rows when the numbers survived a failed refresh // (keep-last-good); null when the snapshot is fresh. public string? StaleText() diff --git a/AgentsPanelExtension/Pages/UsageProviderPage.cs b/AgentsPanelExtension/Pages/UsageProviderPage.cs index 8b84a46..dcc6b93 100644 --- a/AgentsPanelExtension/Pages/UsageProviderPage.cs +++ b/AgentsPanelExtension/Pages/UsageProviderPage.cs @@ -24,6 +24,7 @@ internal sealed partial class UsageProviderPage : ListPage, INotifyItemsChanged private const string SessionGlyph = "\uE823"; // Segoe MDL2 Recent (clock) private const string WeekGlyph = "\uE787"; // Segoe MDL2 Calendar private const string ExtraGlyph = "\uE8C7"; // Segoe MDL2 Payment + private const string TokensGlyph = "\uE8EF"; // Segoe MDL2 Calculator private readonly UsageRepository _repository; private readonly string _providerId; @@ -109,6 +110,19 @@ public override IListItem[] GetItems() }); } + // The last 24h's token counts from the provider's LOCAL session logs (machine-local activity, + // not quota) — present even when the endpoint is failing or signed out, since the logs are + // ours to read either way. + if (usage.TokenSummary(settings) is { } tokenSummary) + { + items.Add(new ListItem(new NoOpCommand { Id = $"{Id}.tokens" }) + { + Title = Resources.Tokens_Last24h_Title, + Subtitle = tokenSummary, + Icon = new IconInfo(TokensGlyph), + }); + } + // The problem row (sign in / expired / rate-limited / error) — the whole story when there // are no windows, a footnote under the stale numbers otherwise. if (UsageStatusHint.StatusRow(usage.Snapshot) is { } status) diff --git a/AgentsPanelExtension/Properties/Resources.Designer.cs b/AgentsPanelExtension/Properties/Resources.Designer.cs index 80134ce..4cc30c4 100644 --- a/AgentsPanelExtension/Properties/Resources.Designer.cs +++ b/AgentsPanelExtension/Properties/Resources.Designer.cs @@ -211,6 +211,24 @@ public static string Stale_AsOf { } } + /// + /// Looks up a localized string similar to Tokens (24h). + /// + public static string Tokens_Last24h_Title { + get { + return ResourceManager.GetString("Tokens_Last24h_Title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} in · {1} out · {2} cache read. + /// + public static string Tokens_Summary { + get { + return ResourceManager.GetString("Tokens_Summary", resourceCulture); + } + } + /// /// Looks up a localized string similar to Can't reach {0}. /// @@ -490,6 +508,24 @@ public static string Settings_ShowExtraUsage_Label { } } + /// + /// Looks up a localized string similar to Token counts. + /// + public static string Settings_ShowTokenStats_Label { + get { + return ResourceManager.GetString("Settings_ShowTokenStats_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Show tokens used in the last 24 hours, read from the agent's local session logs on this PC. + /// + public static string Settings_ShowTokenStats_Desc { + get { + return ResourceManager.GetString("Settings_ShowTokenStats_Desc", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show model-specific limits (e.g. Opus, Sonnet) as their own rows. /// diff --git a/AgentsPanelExtension/Properties/Resources.resx b/AgentsPanelExtension/Properties/Resources.resx index 541aa74..c71fd3c 100644 --- a/AgentsPanelExtension/Properties/Resources.resx +++ b/AgentsPanelExtension/Properties/Resources.resx @@ -121,6 +121,12 @@ stale — as of {0} + + Tokens (24h) + + + {0} in · {1} out · {2} cache read + Plan — {0} @@ -249,6 +255,12 @@ own API. Show pay-as-you-go extra usage when the account reports it + + Token counts + + + Show tokens used in the last 24 hours, read from the agent's local session logs on this PC + Copilot GitHub token diff --git a/AgentsPanelExtension/Settings/UsageSettingsManager.cs b/AgentsPanelExtension/Settings/UsageSettingsManager.cs index ebf1e66..97b1792 100644 --- a/AgentsPanelExtension/Settings/UsageSettingsManager.cs +++ b/AgentsPanelExtension/Settings/UsageSettingsManager.cs @@ -50,6 +50,14 @@ internal sealed class UsageSettingsManager : JsonSettingsManager Description = Resources.Settings_ShowExtraUsage_Desc, }; + // Show the "Tokens today" row on provider pages — token counts read from the agent CLI's own + // local session logs (machine-local activity, unrelated to the quota percentages). On by default. + private readonly ToggleSetting _showTokenStats = new("showTokenStats", true) + { + Label = Resources.Settings_ShowTokenStats_Label, + Description = Resources.Settings_ShowTokenStats_Desc, + }; + // Optional explicit GitHub token for the Copilot provider (e.g. a fine-grained PAT with // "Copilot Requests: Read"). Blank by default — CopilotCredentialsReader then falls back to the // tokens gh CLI / Copilot CLI / the editor plugins already left on the machine. ⚠️ Persisted in @@ -81,6 +89,9 @@ internal sealed class UsageSettingsManager : JsonSettingsManager // Whether the extra-usage credits window renders. Read pull-style each render. public bool ShowExtraUsage => _showExtraUsage.Value; + // Whether the local-log token counts row renders. Read pull-style each render. + public bool ShowTokenStats => _showTokenStats.Value; + // The user's explicit Copilot token, or null when blank/whitespace. Read pull-style each fetch. // NEVER log the value. public string? CopilotToken => @@ -92,6 +103,7 @@ private UsageSettingsManager() Settings.Add(_refreshMinutes); Settings.Add(_showModelWindows); Settings.Add(_showExtraUsage); + Settings.Add(_showTokenStats); Settings.Add(_copilotToken); LoadSettings(); Settings.SettingsChanged += (_, _) => SaveSettings(); From 40cf99fab9be7543d94cba8234ce71c694421028 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 00:42:01 +0300 Subject: [PATCH 2/7] Add token usage --- AGENTS.md | 20 ++++++++++++-- AgentsPanelExtension/Models/UiUsage.cs | 11 ++++++++ AgentsPanelExtension/Pages/UsageDockPage.cs | 13 +++++++++ .../Properties/Resources.Designer.cs | 27 +++++++++++++++++++ .../Properties/Resources.resx | 9 +++++++ .../Settings/UsageSettingsManager.cs | 12 +++++++++ 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ee230b..b3e330f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,9 @@ Single observable source of truth; every surface OBSERVES, none fetches: `Pages/UsageDockPage.cs` (**one instance per provider** since 2026-08-13, observing `ObserveUsage(providerId)`; each `GetItems()` row = one dock button, title budget ~15 chars, **deep-links to that provider's page**; per-band `Id` = `…agentspanel.dock.` — must - stay unique. Deliberately NO "show X in dock" setting: the host's pin/unpin per band IS the - chooser). All three implement the explicit + stay unique. Deliberately NO "show provider X in dock" setting: the host's pin/unpin per band IS + the chooser — ShowTokenStatsInDock is different, it toggles a button WITHIN a band). All three + implement the explicit `INotifyItemsChanged` pattern: subscribe `ObserveUsage(...)` in the event's `add` accessor, dispose-all in `remove`, `List` (host may add twice), `private new void RaiseItemsChanged`. `Pages/UsageProviderPageCache.cs` holds ONE `UsageProviderPage` per ProviderId, @@ -63,6 +64,21 @@ Single observable source of truth; every surface OBSERVES, none fetches: `WorstVisibleWindow` — the ShowModelWindows/ShowExtraUsage filter has ONE home now); status pill/subtitle extraction lives in `UsageStatusHint.StatusTag`/`StatusSubtitle` (used by `StatusRow` too — don't re-fork the colors/strings). +- **Token stats** (since 2026-08-15): `DomainTokenStats` rides `DomainUsageSnapshot.TokenStats` + (nullable — absent means "provider has no local logs / read failed", a QUIET absence, never an + error surface). Claude only so far: `Data/Claude/ClaudeTokenLogReader.cs` tails + `%USERPROFILE%\.claude\projects\*\*.jsonl` incrementally (per-file byte offset + per-message-id + dedupe — streaming rewrites repeat message ids; last wins), summing a **rolling 24h** window + (deliberately not calendar-day: a midnight reset zeroes the row mid-session), attached AROUND the + quota fetch so it rides every outcome incl. NotConfigured/TokenExpired. The repository merge takes + the FRESH TokenStats even under keep-last-good (local logs succeed when HTTP fails). Formatting: + `UiUsage.TokenSummary` ("56k in · 29k out · 2.2M cache read"; in = input + cache WRITES — raw + input_tokens is a misleading crumb under prompt caching) + `TokenDockTitle` ("24h 29k", output + only — title budget). Two settings: ShowTokenStats (page row) and subordinate ShowTokenStatsInDock + (dock button; requires the first). Codex has the same data locally + (`%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl`, `token_count` events — total_token_usage is + CUMULATIVE per session, take the last) — a future `CodexTokenLogReader` sibling lights up the row + and dock button automatically. Copilot has no local logs; stays null. ## The Claude data source (Data/Claude/ — deliberately isolated) diff --git a/AgentsPanelExtension/Models/UiUsage.cs b/AgentsPanelExtension/Models/UiUsage.cs index 6e2f47f..f901bde 100644 --- a/AgentsPanelExtension/Models/UiUsage.cs +++ b/AgentsPanelExtension/Models/UiUsage.cs @@ -137,6 +137,17 @@ public IEnumerable VisibleWindows(UsageSettingsManager settings) FormatTokens(tokens.CacheReadTokens)); } + // "24h 29k" for the optional dock token button — output tokens only: the title budget (~15 chars) + // fits ONE figure, and "what did the model write" is the most meaningful single number. The full + // in/out/cache breakdown rides on the button's subtitle (TokenSummary). Null when token stats are + // absent or either token toggle is off. + public string? TokenDockTitle(UsageSettingsManager settings) + { + if (!settings.ShowTokenStatsInDock || Snapshot.TokenStats is not { } tokens) + return null; + return Strings.Format(Resources.Tokens_Dock_Title, FormatTokens(tokens.OutputTokens)); + } + // Compact count: 823 → "823", 52_300 → "52k", 1_430_000 → "1.4M". One decimal only while the // leading figure is a single digit. Invariant digits, matching FormatPercent. private static string FormatTokens(long count) => count switch diff --git a/AgentsPanelExtension/Pages/UsageDockPage.cs b/AgentsPanelExtension/Pages/UsageDockPage.cs index 008f7bb..b8e2c55 100644 --- a/AgentsPanelExtension/Pages/UsageDockPage.cs +++ b/AgentsPanelExtension/Pages/UsageDockPage.cs @@ -113,6 +113,19 @@ public override IListItem[] GetItems() }); } + // Optional token-count button (Settings) — local-log activity, not quota. Deliberately NO + // stale "!" marker: the counts come from this machine's transcripts, so they're current even + // when the endpoint poll is failing. The subtitle carries the full in/out/cache breakdown. + if (usage.TokenDockTitle(settings) is { } tokenTitle) + { + items.Add(new ListItem(page) + { + Title = tokenTitle, + Subtitle = usage.TokenSummary(settings) ?? string.Empty, + Icon = icon, + }); + } + // No windows to show — the button IS the status. Ok-but-empty is a healthy account that // reported no active limits; it must NOT borrow the error wording (we reached the API fine). if (usage.Windows.Count == 0) diff --git a/AgentsPanelExtension/Properties/Resources.Designer.cs b/AgentsPanelExtension/Properties/Resources.Designer.cs index 4cc30c4..0632917 100644 --- a/AgentsPanelExtension/Properties/Resources.Designer.cs +++ b/AgentsPanelExtension/Properties/Resources.Designer.cs @@ -229,6 +229,15 @@ public static string Tokens_Summary { } } + /// + /// Looks up a localized string similar to 24h {0}. + /// + public static string Tokens_Dock_Title { + get { + return ResourceManager.GetString("Tokens_Dock_Title", resourceCulture); + } + } + /// /// Looks up a localized string similar to Can't reach {0}. /// @@ -526,6 +535,24 @@ public static string Settings_ShowTokenStats_Desc { } } + /// + /// Looks up a localized string similar to Token count in dock. + /// + public static string Settings_ShowTokenStatsInDock_Label { + get { + return ResourceManager.GetString("Settings_ShowTokenStatsInDock_Label", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Also show a 24-hour token button in the provider's Dock band. + /// + public static string Settings_ShowTokenStatsInDock_Desc { + get { + return ResourceManager.GetString("Settings_ShowTokenStatsInDock_Desc", resourceCulture); + } + } + /// /// Looks up a localized string similar to Show model-specific limits (e.g. Opus, Sonnet) as their own rows. /// diff --git a/AgentsPanelExtension/Properties/Resources.resx b/AgentsPanelExtension/Properties/Resources.resx index c71fd3c..cee6159 100644 --- a/AgentsPanelExtension/Properties/Resources.resx +++ b/AgentsPanelExtension/Properties/Resources.resx @@ -127,6 +127,9 @@ {0} in · {1} out · {2} cache read + + 24h {0} + Plan — {0} @@ -261,6 +264,12 @@ own API. Show tokens used in the last 24 hours, read from the agent's local session logs on this PC + + Token count in dock + + + Also show a 24-hour token button in the provider's Dock band + Copilot GitHub token diff --git a/AgentsPanelExtension/Settings/UsageSettingsManager.cs b/AgentsPanelExtension/Settings/UsageSettingsManager.cs index 97b1792..9593e82 100644 --- a/AgentsPanelExtension/Settings/UsageSettingsManager.cs +++ b/AgentsPanelExtension/Settings/UsageSettingsManager.cs @@ -58,6 +58,14 @@ internal sealed class UsageSettingsManager : JsonSettingsManager Description = Resources.Settings_ShowTokenStats_Desc, }; + // Also show a token-count button in the provider's Dock band. On by default; subordinate to + // _showTokenStats — turning token counts off hides the dock button too. + private readonly ToggleSetting _showTokenStatsInDock = new("showTokenStatsInDock", true) + { + Label = Resources.Settings_ShowTokenStatsInDock_Label, + Description = Resources.Settings_ShowTokenStatsInDock_Desc, + }; + // Optional explicit GitHub token for the Copilot provider (e.g. a fine-grained PAT with // "Copilot Requests: Read"). Blank by default — CopilotCredentialsReader then falls back to the // tokens gh CLI / Copilot CLI / the editor plugins already left on the machine. ⚠️ Persisted in @@ -92,6 +100,9 @@ internal sealed class UsageSettingsManager : JsonSettingsManager // Whether the local-log token counts row renders. Read pull-style each render. public bool ShowTokenStats => _showTokenStats.Value; + // Whether the dock band also gets a token-count button. Requires ShowTokenStats. + public bool ShowTokenStatsInDock => _showTokenStats.Value && _showTokenStatsInDock.Value; + // The user's explicit Copilot token, or null when blank/whitespace. Read pull-style each fetch. // NEVER log the value. public string? CopilotToken => @@ -104,6 +115,7 @@ private UsageSettingsManager() Settings.Add(_showModelWindows); Settings.Add(_showExtraUsage); Settings.Add(_showTokenStats); + Settings.Add(_showTokenStatsInDock); Settings.Add(_copilotToken); LoadSettings(); Settings.SettingsChanged += (_, _) => SaveSettings(); From ec14a8708ea8e08eb57f8bd0f8dadfb4a37641fc Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 00:54:28 +0300 Subject: [PATCH 3/7] codex tokens --- AGENTS.md | 24 +- .../Data/Codex/ApiCodexDtos.cs | 27 +++ .../Data/Codex/CodexTokenLogReader.cs | 227 ++++++++++++++++++ .../Data/Codex/CodexUsageProvider.cs | 12 + 4 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 AgentsPanelExtension/Data/Codex/CodexTokenLogReader.cs diff --git a/AGENTS.md b/AGENTS.md index b3e330f..02cc33e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,19 +66,21 @@ Single observable source of truth; every surface OBSERVES, none fetches: too — don't re-fork the colors/strings). - **Token stats** (since 2026-08-15): `DomainTokenStats` rides `DomainUsageSnapshot.TokenStats` (nullable — absent means "provider has no local logs / read failed", a QUIET absence, never an - error surface). Claude only so far: `Data/Claude/ClaudeTokenLogReader.cs` tails - `%USERPROFILE%\.claude\projects\*\*.jsonl` incrementally (per-file byte offset + per-message-id - dedupe — streaming rewrites repeat message ids; last wins), summing a **rolling 24h** window - (deliberately not calendar-day: a midnight reset zeroes the row mid-session), attached AROUND the - quota fetch so it rides every outcome incl. NotConfigured/TokenExpired. The repository merge takes - the FRESH TokenStats even under keep-last-good (local logs succeed when HTTP fails). Formatting: - `UiUsage.TokenSummary` ("56k in · 29k out · 2.2M cache read"; in = input + cache WRITES — raw + error surface). Both readers sum a **rolling 24h** window (deliberately not calendar-day: a + midnight reset zeroes the row mid-session), tail incrementally (per-file byte offset; only + newline-complete lines consumed; shrunken files reparsed from zero), and are attached AROUND the + quota fetch so they ride every outcome incl. NotConfigured/TokenExpired. The repository merge takes + the FRESH TokenStats even under keep-last-good (local logs succeed when HTTP fails). + `Data/Claude/ClaudeTokenLogReader.cs` tails `%USERPROFILE%\.claude\projects\*\*.jsonl` with + per-message-id dedupe (streaming rewrites repeat message ids; last wins). + `Data/Codex/CodexTokenLogReader.cs` tails `%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl` + `token_count` events — ⚠️ `total_token_usage` is CUMULATIVE per session, so it DIFFS consecutive + events into timestamped deltas (never sums; rebaselines on a counter reset), and Codex's + `input_tokens` INCLUDES the cached subset, so fresh input = input − cached. Formatting: + `UiUsage.TokenSummary` ("56k in · 29k out · 2.2M cache read"; in = fresh input + cache WRITES — raw input_tokens is a misleading crumb under prompt caching) + `TokenDockTitle` ("24h 29k", output only — title budget). Two settings: ShowTokenStats (page row) and subordinate ShowTokenStatsInDock - (dock button; requires the first). Codex has the same data locally - (`%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl`, `token_count` events — total_token_usage is - CUMULATIVE per session, take the last) — a future `CodexTokenLogReader` sibling lights up the row - and dock button automatically. Copilot has no local logs; stays null. + (dock button; requires the first). Copilot has no local logs; stays null. ## The Claude data source (Data/Claude/ — deliberately isolated) diff --git a/AgentsPanelExtension/Data/Codex/ApiCodexDtos.cs b/AgentsPanelExtension/Data/Codex/ApiCodexDtos.cs index f842885..31c6016 100644 --- a/AgentsPanelExtension/Data/Codex/ApiCodexDtos.cs +++ b/AgentsPanelExtension/Data/Codex/ApiCodexDtos.cs @@ -55,10 +55,37 @@ internal sealed record ApiCodexJwtPayloadDto( internal sealed record ApiCodexJwtAuthClaimDto( [property: JsonPropertyName("chatgpt_plan_type")] string? ChatGptPlanType); +// One line of a Codex rollout file (%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl) — only the +// sliver CodexTokenLogReader needs. Token events are envelope type "event_msg" with payload type +// "token_count"; everything else (session_meta, turns, tool calls) parses to non-matching fields and +// is skipped. ⚠️ info.total_token_usage is CUMULATIVE for the session — readers must diff +// consecutive events, never sum them (last_token_usage exists on current versions but not all +// generations, so the diff approach is the portable one). +internal sealed record ApiCodexRolloutLineDto( + [property: JsonPropertyName("timestamp")] string? Timestamp, // ISO-8601 UTC + [property: JsonPropertyName("type")] string? Type, + [property: JsonPropertyName("payload")] ApiCodexRolloutPayloadDto? Payload); + +internal sealed record ApiCodexRolloutPayloadDto( + [property: JsonPropertyName("type")] string? Type, + [property: JsonPropertyName("info")] ApiCodexTokenCountInfoDto? Info); + +internal sealed record ApiCodexTokenCountInfoDto( + [property: JsonPropertyName("total_token_usage")] ApiCodexTokenUsageDto? TotalTokenUsage); + +// Mirrors codex-rs's TokenUsage: input_tokens INCLUDES cached_input_tokens (OpenAI's prompt/cached +// subset convention), output_tokens includes reasoning. +internal sealed record ApiCodexTokenUsageDto( + [property: JsonPropertyName("input_tokens")] long? InputTokens, + [property: JsonPropertyName("cached_input_tokens")] long? CachedInputTokens, + [property: JsonPropertyName("cache_write_input_tokens")] long? CacheWriteInputTokens, + [property: JsonPropertyName("output_tokens")] long? OutputTokens); + // ⚠️ ALL [JsonSerializable] attributes for this context MUST stay on this ONE partial declaration. // Splitting them across multiple partials silently breaks the source generator for the whole build // (every context type comes back null at runtime) — MarketExtension hit exactly this. [JsonSerializable(typeof(ApiCodexUsageDto))] [JsonSerializable(typeof(ApiCodexAuthFileDto))] [JsonSerializable(typeof(ApiCodexJwtPayloadDto))] +[JsonSerializable(typeof(ApiCodexRolloutLineDto))] internal sealed partial class CodexJsonContext : JsonSerializerContext; diff --git a/AgentsPanelExtension/Data/Codex/CodexTokenLogReader.cs b/AgentsPanelExtension/Data/Codex/CodexTokenLogReader.cs new file mode 100644 index 0000000..8d4481d --- /dev/null +++ b/AgentsPanelExtension/Data/Codex/CodexTokenLogReader.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace AgentsPanelExtension; + +// Sums the last 24 hours' token usage from Codex's own rollout logs +// (%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl) — the Data/Codex sibling of +// ClaudeTokenLogReader: no network, no auth, works even when the OAuth token is expired. Same +// incremental tailing (per-file byte offset, only newly appended bytes parsed per poll, shrunken +// files reparsed from zero), same never-throw contract (any failure degrades to null with a Warn). +// +// The parse model differs from Claude's because the data does: token_count events carry a CUMULATIVE +// per-session total, so the reader diffs consecutive events into per-event deltas stamped with the +// event's own timestamp, then sums the deltas inside the rolling window. That keeps a session that +// spans the window edge honest (only its in-window turns count) and works on log generations that +// lack the per-turn last_token_usage field. A totals RESET mid-file (shouldn't happen; belt and +// braces) is treated as a fresh baseline rather than a negative delta. +// +// One instance per provider; the repository single-flights refreshes, so no internal locking. +internal sealed class CodexTokenLogReader +{ + private const string Tag = "CodexTokens"; + + // Same CODEX_HOME resolution as CodexCredentialsReader — read per call so an env change applies + // without a reload. + private static string SessionsRoot + { + get + { + var home = Environment.GetEnvironmentVariable("CODEX_HOME"); + if (string.IsNullOrWhiteSpace(home)) + home = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex"); + return Path.Combine(home, "sessions"); + } + } + + // One rollout's tail state: the running cumulative totals (diff baseline) plus the per-event + // deltas seen so far. Deltas older than the window are pruned as it slides. + private sealed class FileState + { + public long Offset; + public TokenCounts PrevTotals; + public readonly List Deltas = []; + } + + private readonly record struct TokenCounts(long Input, long Output, long CacheRead, long CacheWrite); + + private readonly record struct TokenDelta( + DateTimeOffset Timestamp, long Input, long Output, long CacheRead, long CacheWrite); + + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + + public DomainTokenStats? ReadLast24Hours() + { + try + { + var root = new DirectoryInfo(SessionsRoot); + if (!root.Exists) + { + Log.Info(Tag, "sessions directory not found — no local rollouts"); + return null; + } + + var windowStart = DateTimeOffset.UtcNow.AddHours(-24); + + var parsed = 0; + foreach (var file in root.EnumerateFiles("*.jsonl", SearchOption.AllDirectories)) + { + if (file.LastWriteTimeUtc < windowStart.UtcDateTime) + continue; + TailFile(file, windowStart); + parsed++; + } + + PruneStale(windowStart); + + long input = 0, output = 0, cacheRead = 0, cacheWrite = 0; + var events = 0; + foreach (var state in _files.Values) + { + foreach (var delta in state.Deltas) + { + if (delta.Timestamp < windowStart) + continue; + input += delta.Input; + output += delta.Output; + cacheRead += delta.CacheRead; + cacheWrite += delta.CacheWrite; + events++; + } + } + + Log.Info(Tag, $"tailed {parsed} rollout(s): {events} token event(s) in the last 24h"); + return new DomainTokenStats(input, output, cacheRead, cacheWrite); + } + catch (Exception ex) + { + Log.Warn(Tag, $"rollout read failed — no token stats this poll ({ex.GetType().Name}: {ex.Message})"); + return null; + } + } + + private void TailFile(FileInfo file, DateTimeOffset windowStart) + { + if (!_files.TryGetValue(file.FullName, out var state)) + _files[file.FullName] = state = new FileState(); + + try + { + // Codex holds active rollouts open for append — share everything. + using var stream = new FileStream( + file.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + + if (stream.Length < state.Offset) + { + Log.Info(Tag, $"rollout shrank — reparsing from zero: {file.Name}"); + state.Offset = 0; + state.PrevTotals = default; + state.Deltas.Clear(); + } + + if (stream.Length == state.Offset) + { + state.Deltas.RemoveAll(d => d.Timestamp < windowStart); + return; + } + + stream.Position = state.Offset; + var buffer = new byte[stream.Length - state.Offset]; + stream.ReadExactly(buffer); + + // Only complete (newline-terminated) lines are consumed; a trailing partial line stays + // un-consumed and is retried when the writer finishes it. + var lineStart = 0; + for (var i = 0; i < buffer.Length; i++) + { + if (buffer[i] != (byte)'\n') + continue; + var line = buffer.AsSpan(lineStart, i - lineStart); + if (line.Length > 0 && line[^1] == (byte)'\r') + line = line[..^1]; + ParseLine(line, state); + lineStart = i + 1; + } + + state.Offset += lineStart; + + // A long-running rollout keeps appending inside the window while its old deltas age out — + // prune so a file's list stays bounded to ~one day of turns. + state.Deltas.RemoveAll(d => d.Timestamp < windowStart); + } + catch (Exception ex) + { + Log.Warn(Tag, $"failed tailing '{file.Name}' — skipped this poll ({ex.GetType().Name})"); + } + } + + // One JSONL line → at most one delta. Non-token_count events and malformed JSON are consumed + // silently — rollouts legitimately contain many line shapes. + private static void ParseLine(ReadOnlySpan line, FileState state) + { + if (line.IsEmpty) + return; + + ApiCodexRolloutLineDto? dto; + try + { + dto = JsonSerializer.Deserialize(line, CodexJsonContext.Default.ApiCodexRolloutLineDto); + } + catch (JsonException) + { + return; + } + + if (dto is not { Payload: { Type: "token_count", Info.TotalTokenUsage: { } raw } }) + return; + if (!DateTimeOffset.TryParse(dto.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.None, out var timestamp)) + return; + + // Cumulative totals → this event's delta. input_tokens includes the cached subset, so fresh + // input is the difference (clamped — the fields come from an undocumented log format). + var cacheRead = raw.CachedInputTokens ?? 0; + var totals = new TokenCounts( + Math.Max(0, (raw.InputTokens ?? 0) - cacheRead), + raw.OutputTokens ?? 0, + cacheRead, + raw.CacheWriteInputTokens ?? 0); + + // A component going backwards means the session's counter reset — rebaseline on the current + // totals instead of recording a negative delta. + var prev = state.PrevTotals; + var reset = totals.Input < prev.Input || totals.Output < prev.Output + || totals.CacheRead < prev.CacheRead || totals.CacheWrite < prev.CacheWrite; + var delta = reset + ? totals + : new TokenCounts( + totals.Input - prev.Input, + totals.Output - prev.Output, + totals.CacheRead - prev.CacheRead, + totals.CacheWrite - prev.CacheWrite); + state.PrevTotals = totals; + + if (delta is { Input: 0, Output: 0, CacheRead: 0, CacheWrite: 0 }) + return; // heartbeat/no-op event — nothing to record + + state.Deltas.Add(new TokenDelta(timestamp, delta.Input, delta.Output, delta.CacheRead, delta.CacheWrite)); + } + + private void PruneStale(DateTimeOffset windowStart) + { + List? stale = null; + foreach (var (path, _) in _files) + { + // Missing files prune too (File.GetLastWriteTimeUtc returns a sentinel far in the past). + if (File.GetLastWriteTimeUtc(path) < windowStart.UtcDateTime) + (stale ??= []).Add(path); + } + + if (stale is null) + return; + foreach (var path in stale) + _files.Remove(path); + } +} diff --git a/AgentsPanelExtension/Data/Codex/CodexUsageProvider.cs b/AgentsPanelExtension/Data/Codex/CodexUsageProvider.cs index 49425ad..bfb08ca 100644 --- a/AgentsPanelExtension/Data/Codex/CodexUsageProvider.cs +++ b/AgentsPanelExtension/Data/Codex/CodexUsageProvider.cs @@ -34,6 +34,10 @@ internal sealed class CodexUsageProvider : IAgentUsageProvider // account-id headers are per-request because credentials are re-read from disk on every fetch. private static readonly HttpClient Http = CreateClient(); + // Tails Codex's local rollout logs for the last 24h's token counts. Stateful (per-file byte + // offsets) — must live as long as the provider so increments stay cheap across polls. + private readonly CodexTokenLogReader _tokenLogReader = new(); + public string Id => "codex"; public string DisplayName => "Codex"; @@ -43,6 +47,14 @@ internal sealed class CodexUsageProvider : IAgentUsageProvider public bool IsAvailable => true; public async Task GetUsageAsync(CancellationToken ct = default) + { + // Token stats come from LOCAL logs — independent of the endpoint, so they ride on every + // outcome, including NotConfigured/TokenExpired (the row still works while signed out). + var snapshot = await FetchQuotaAsync(ct).ConfigureAwait(false); + return snapshot with { TokenStats = _tokenLogReader.ReadLast24Hours() }; + } + + private async Task FetchQuotaAsync(CancellationToken ct) { // Read credentials at call time — a sign-in/sign-out applies on the next poll, no reload. var credentials = CodexCredentialsReader.Read(); From 82f3b16a5a51fcbbeb9135764876a786b1d546cd Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 01:00:28 +0300 Subject: [PATCH 4/7] switch to sum --- AGENTS.md | 6 ++++-- AgentsPanelExtension/Models/DomainUsage.cs | 7 ++++++- AgentsPanelExtension/Models/UiUsage.cs | 18 +++++++++++++----- .../Pages/UsageProviderPage.cs | 3 +++ 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 02cc33e..1eb3677 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,8 +78,10 @@ Single observable source of truth; every surface OBSERVES, none fetches: events into timestamped deltas (never sums; rebaselines on a counter reset), and Codex's `input_tokens` INCLUDES the cached subset, so fresh input = input − cached. Formatting: `UiUsage.TokenSummary` ("56k in · 29k out · 2.2M cache read"; in = fresh input + cache WRITES — raw - input_tokens is a misleading crumb under prompt caching) + `TokenDockTitle` ("24h 29k", output - only — title budget). Two settings: ShowTokenStats (page row) and subordinate ShowTokenStatsInDock + input_tokens is a misleading crumb under prompt caching) + `TokenDockTitle`/`TokenTotalText` + ("24h 2.3M" / page-row tag — the TOTAL of all four counters, the ecosystem's glance number; any + single component reads as noise at first sight). Two settings: ShowTokenStats (page row) and + subordinate ShowTokenStatsInDock (dock button; requires the first). Copilot has no local logs; stays null. ## The Claude data source (Data/Claude/ — deliberately isolated) diff --git a/AgentsPanelExtension/Models/DomainUsage.cs b/AgentsPanelExtension/Models/DomainUsage.cs index 7d9a81b..da482e0 100644 --- a/AgentsPanelExtension/Models/DomainUsage.cs +++ b/AgentsPanelExtension/Models/DomainUsage.cs @@ -48,7 +48,12 @@ internal sealed record DomainTokenStats( long InputTokens, // non-cache input long OutputTokens, long CacheReadTokens, // cache_read_input_tokens - long CacheWriteTokens); // cache_creation_input_tokens + long CacheWriteTokens) // cache_creation_input_tokens +{ + // All four counters summed — the ecosystem's "big number" (it deliberately includes cache + // replay volume; that's what makes it land at a glance). + public long TotalTokens => InputTokens + OutputTokens + CacheReadTokens + CacheWriteTokens; +} // One provider's usage state. Providers return this and NEVER throw for expected failures: a failure is // a snapshot with Status != Ok and empty Windows. The repository then merges it with the last good diff --git a/AgentsPanelExtension/Models/UiUsage.cs b/AgentsPanelExtension/Models/UiUsage.cs index f901bde..51cbc30 100644 --- a/AgentsPanelExtension/Models/UiUsage.cs +++ b/AgentsPanelExtension/Models/UiUsage.cs @@ -137,17 +137,25 @@ public IEnumerable VisibleWindows(UsageSettingsManager settings) FormatTokens(tokens.CacheReadTokens)); } - // "24h 29k" for the optional dock token button — output tokens only: the title budget (~15 chars) - // fits ONE figure, and "what did the model write" is the most meaningful single number. The full - // in/out/cache breakdown rides on the button's subtitle (TokenSummary). Null when token stats are - // absent or either token toggle is off. + // "24h 2.3M" for the optional dock token button — TOTAL tokens (all four counters), the + // ecosystem-standard glance number: the title budget (~15 chars) fits ONE figure, and any single + // component (output alone read as a puny "2.5k" on read-heavy Codex sessions) says nothing at + // first sight. The in/out/cache breakdown rides on the button's subtitle (TokenSummary). Null + // when token stats are absent or either token toggle is off. public string? TokenDockTitle(UsageSettingsManager settings) { if (!settings.ShowTokenStatsInDock || Snapshot.TokenStats is not { } tokens) return null; - return Strings.Format(Resources.Tokens_Dock_Title, FormatTokens(tokens.OutputTokens)); + return Strings.Format(Resources.Tokens_Dock_Title, FormatTokens(tokens.TotalTokens)); } + // The total on its own ("2.3M") — the page row's tag, mirroring how quota rows lead with their + // percent pill. Null under the same conditions as TokenSummary. + public string? TokenTotalText(UsageSettingsManager settings) => + settings.ShowTokenStats && Snapshot.TokenStats is { } tokens + ? FormatTokens(tokens.TotalTokens) + : null; + // Compact count: 823 → "823", 52_300 → "52k", 1_430_000 → "1.4M". One decimal only while the // leading figure is a single digit. Invariant digits, matching FormatPercent. private static string FormatTokens(long count) => count switch diff --git a/AgentsPanelExtension/Pages/UsageProviderPage.cs b/AgentsPanelExtension/Pages/UsageProviderPage.cs index dcc6b93..d07557e 100644 --- a/AgentsPanelExtension/Pages/UsageProviderPage.cs +++ b/AgentsPanelExtension/Pages/UsageProviderPage.cs @@ -115,11 +115,14 @@ public override IListItem[] GetItems() // ours to read either way. if (usage.TokenSummary(settings) is { } tokenSummary) { + // The total rides as the tag (the glance number, like the quota rows' percent pill) — + // deliberately uncolored: a token count has no severity. items.Add(new ListItem(new NoOpCommand { Id = $"{Id}.tokens" }) { Title = Resources.Tokens_Last24h_Title, Subtitle = tokenSummary, Icon = new IconInfo(TokensGlyph), + Tags = usage.TokenTotalText(settings) is { } total ? [new Tag(total)] : [], }); } From ae98f1cc198d381e3a279e75564e2a46025db9b5 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 02:26:52 +0300 Subject: [PATCH 5/7] opencode integration --- AGENTS.md | 54 ++++++- .../AgentsPanelCommandsProvider.cs | 3 +- .../AgentsPanelExtension.csproj | 4 + .../Assets/provider_opencode.png | Bin 0 -> 2608 bytes .../OpenCode/OpenCodeCredentialsReader.cs | 63 ++++++++ .../Data/OpenCode/OpenCodeUsageProvider.cs | 66 ++++++++ .../Data/OpenCode/OpenCodeUsageReader.cs | 146 ++++++++++++++++++ AgentsPanelExtension/Helpers/ProviderIcons.cs | 2 + AgentsPanelExtension/Models/DomainUsage.cs | 4 +- AgentsPanelExtension/Models/UiUsage.cs | 28 +++- AgentsPanelExtension/Pages/UsagePage.cs | 2 +- .../Pages/UsageProviderPage.cs | 2 +- .../Properties/Resources.Designer.cs | 9 ++ .../Properties/Resources.resx | 3 + Directory.Packages.props | 1 + README.md | 5 + 16 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 AgentsPanelExtension/Assets/provider_opencode.png create mode 100644 AgentsPanelExtension/Data/OpenCode/OpenCodeCredentialsReader.cs create mode 100644 AgentsPanelExtension/Data/OpenCode/OpenCodeUsageProvider.cs create mode 100644 AgentsPanelExtension/Data/OpenCode/OpenCodeUsageReader.cs diff --git a/AGENTS.md b/AGENTS.md index 1eb3677..e5a8030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,8 @@ A PowerToys **Command Palette** extension showing AI-agent **usage quotas** — reset times, plan info — with pinnable per-provider **Dock bands** as the main selling point ("5h 23%" / "Wk 41%" quick-look buttons; one band per provider, so the user picks which agents to pin via the host's own band management). Covers **Claude** (Pro/Max subscription limits), **Codex** -(ChatGPT subscription limits), and **Copilot** (GitHub Copilot quotas); the provider architecture is +(ChatGPT subscription limits), **Copilot** (GitHub Copilot quotas), and **opencode** (local-data +only — no usage API exists yet); the provider architecture is open for more later. .NET 9 / C# / MSIX, self-contained single-file JIT (trim/AOT deliberately OFF). @@ -36,7 +37,8 @@ Single observable source of truth; every surface OBSERVES, none fetches: fallback routing: an unconfigured provider stays active and reports `NotConfigured` (rendered as a "Sign in" row/button) instead of disappearing — agent providers aren't interchangeable. - Registration order in `AgentsPanelCommandsProvider`: `ClaudeUsageProvider` → `CodexUsageProvider` - → `CopilotUsageProvider`, in the static `Providers` array (used twice: repository ctor + one dock + → `CopilotUsageProvider` → `OpenCodeUsageProvider`, in the static `Providers` array (used twice: + repository ctor + one dock band per entry). Add providers to that array, plus a PNG + case in `Helpers/ProviderIcons.cs` (ProviderId → icon; identifies dock buttons/hub rows — the terse dock titles carry no provider identity) — the hub row @@ -66,9 +68,10 @@ Single observable source of truth; every surface OBSERVES, none fetches: too — don't re-fork the colors/strings). - **Token stats** (since 2026-08-15): `DomainTokenStats` rides `DomainUsageSnapshot.TokenStats` (nullable — absent means "provider has no local logs / read failed", a QUIET absence, never an - error surface). Both readers sum a **rolling 24h** window (deliberately not calendar-day: a - midnight reset zeroes the row mid-session), tail incrementally (per-file byte offset; only - newline-complete lines consumed; shrunken files reparsed from zero), and are attached AROUND the + error surface). All readers sum a **rolling 24h** window (deliberately not calendar-day: a + midnight reset zeroes the row mid-session); the Claude/Codex JSONL readers tail incrementally + (per-file byte offset; only + newline-complete lines consumed; shrunken files reparsed from zero), and all are attached AROUND the quota fetch so they ride every outcome incl. NotConfigured/TokenExpired. The repository merge takes the FRESH TokenStats even under keep-last-good (local logs succeed when HTTP fails). `Data/Claude/ClaudeTokenLogReader.cs` tails `%USERPROFILE%\.claude\projects\*\*.jsonl` with @@ -82,7 +85,9 @@ Single observable source of truth; every surface OBSERVES, none fetches: ("24h 2.3M" / page-row tag — the TOTAL of all four counters, the ecosystem's glance number; any single component reads as noise at first sight). Two settings: ShowTokenStats (page row) and subordinate ShowTokenStatsInDock - (dock button; requires the first). Copilot has no local logs; stays null. + (dock button; requires the first). Copilot has no local logs; stays null. opencode's stats come + from its SQLite DB via `Data/OpenCode/OpenCodeUsageReader.cs` (stateless windowed query, not a + tail — see its section below). ## The Claude data source (Data/Claude/ — deliberately isolated) @@ -157,6 +162,43 @@ segment, steipete/CodexBar, ericc-ch/copilot-api. are other apps' long-lived OAuth tokens. GitHub tokens are opaque (no JWT claims), so there's no local expiry check: a dead/rejected token surfaces as 401/403 ⇒ `TokenExpired`. +## The opencode data source (Data/OpenCode/ — LOCAL-ONLY, no HTTP at all) + +The deliberate v1 shape, because **opencode has no balance/usage API**: `/zen/v1/balance` and +`/zen/v1/usage` 404 (probed live 2026-08-15); upstream requests anomalyco/opencode **#10448** (Zen +balance) + **#16017** (Go plan windows) both open. The only remote source is the opencode.ai +dashboard's cookie-authenticated `_server?id=` RPC (what steipete/CodexBar scrapes on +macOS) — **rejected**: Windows browser-cookie decryption is invasive, and the function ids are +frontend-build hashes that churn every deploy. When the balance endpoint ships, the HTTP fetch +drops into `OpenCodeUsageProvider` as the quota path (auth.json API key, read at call time, never +logged, no refresh) — same seam as the other providers. + +- Everything comes from opencode's local data dir (xdg-style even on Windows: + `XDG_DATA_HOME` ?? `%USERPROFILE%\.local\share`, + `\opencode`): + - `auth.json` (`OpenCodeCredentialsReader`) — a map `providerID → {type: api|oauth|wellknown, …}`; + an `"opencode"` entry (Zen login = `type: "api"`) is the ONLY thing checked, purely for the + NotConfigured/Ok pivot. **v1 never extracts the key** — there's nothing to spend it on. + - `opencode.db` (`OpenCodeUsageReader`, Microsoft.Data.Sqlite) — WAL-mode SQLite. The `message` + table's assistant rows carry `data` JSON with `providerID`/`cost` (USD)/`tokens{input, output, + reasoning, cache{read,write}}` and `time_created` in epoch **milliseconds** (verified live + 2026-08). One windowed query per poll (stateless — no tailing), `json_extract` in SQL so message + CONTENT never leaves the database. ⚠️ The `session` table's counters are cumulative-per-session — + can't be time-windowed; don't "simplify" to them. +- Reports: one `ExtraUsage` window = rolling-24h **gateway spend** (`Σ cost WHERE providerID = + 'opencode'` — Zen credits / Go balance-fallback, the money that actually leaves the account; + Go-subscription-covered messages record cost 0 locally so the sum stays honest) with + `Qualifier "24h"` + `Unit "USD"` (renders "$0.13 used" / dock "24h $0.13" — `Unit` is what + separates verified dollars from Copilot's unit-unknown bare "n used"), plus TokenStats over ALL + opencode messages (reasoning folds into output). No HTTP ⇒ status is only ever + NotConfigured (no auth.json entry) or Ok; a failed DB read degrades to an empty-Ok snapshot + (quiet absence). +- SQLite read strategy: `Mode=ReadOnly; Pooling=False` (no handle survives the poll), + `CommandTimeout=2` (= busy timeout). Read-only WAL access works mid-session (live `-shm`) and + after clean exit; the crash-orphaned-wal edge falls back to copying db+wal+shm to temp and + querying the copy. Any failure → null this poll, retried next. +- DisplayName/Id are `opencode` (lowercase brand; names the AGENT, not a billing product — Go and + Zen are billing modes of the same account, so a "Zen" row would age badly). + ## Build & Deploy `dotnet build AgentsPanelExtension.sln -p:Platform=x64` — ⚠️ without the platform flag MSBuild picks diff --git a/AgentsPanelExtension/AgentsPanelCommandsProvider.cs b/AgentsPanelExtension/AgentsPanelCommandsProvider.cs index d43043a..1860a9e 100644 --- a/AgentsPanelExtension/AgentsPanelCommandsProvider.cs +++ b/AgentsPanelExtension/AgentsPanelCommandsProvider.cs @@ -13,7 +13,8 @@ public partial class AgentsPanelCommandsProvider : CommandProvider private static readonly IAgentUsageProvider[] Providers = [new ClaudeUsageProvider(), new CodexUsageProvider(), - new CopilotUsageProvider()]; + new CopilotUsageProvider(), + new OpenCodeUsageProvider()]; // The repository coordinates all agent-usage providers; the palette page and the dock bands share // this one instance (single source of truth — all surfaces observe the same flow). diff --git a/AgentsPanelExtension/AgentsPanelExtension.csproj b/AgentsPanelExtension/AgentsPanelExtension.csproj index 3ae0c1e..ae38c10 100644 --- a/AgentsPanelExtension/AgentsPanelExtension.csproj +++ b/AgentsPanelExtension/AgentsPanelExtension.csproj @@ -73,6 +73,10 @@ + + diff --git a/AgentsPanelExtension/Assets/provider_opencode.png b/AgentsPanelExtension/Assets/provider_opencode.png new file mode 100644 index 0000000000000000000000000000000000000000..b46c3ab5742122f3cfc96314a856c9ee3e4fa90a GIT binary patch literal 2608 zcmV-03eWY4P)1 zd2m%%8ULMg?|o~?#um1w2}HJp3P=UC0yP75${!GEv391?R_lhf9Y-w=Oq$WDt-!Q1 zmEw##BNgo*w4vizrPy)AacUJPg%AX4Nq`VS$iBSoF30)4`)+oSJV}JSU*^3e=bm%k z_xsMbopaw6=nJI;{}w{cEB{8GE`#foa@IpR^8rkSqxdRrZ74_z&lJ;Tb&C0`Iim6SjA16}%$3Qg>mQmz&d)8O#nTb(>XkgOy#%dUyt#DzF{ z{FrM`X96`;R5F^Hsj{!PXG9)Ep$ns ze1#6IbV{7ox3y5P)as6yLI?w65K1Xi4~Gmp5`Cvi=Bxq!A|)ygStWMMcl9ulkB(XL z1mZ>9DF4nR$1zVzO-@jjLpCus%>G(XP(qh*Y^O;|qgXnQS*H1X6CKl5 zW1c)gKoD2v?3iUzC{@ML`;abYQ6hyV@|5FHe^uW0Th7UenAAJRAQlcXog`sP>YeoSU-#HUSlcr!&W;P2H zw@fs&x8cm?IvlR~H%?u?2q`4Oem@I;EwCjipf%EsoKP4mX3xi>{6gG0VKQ>VVQ5lA z%rw#1*@iO>_4wP_Q>bfcVtLePC_p6N+9TaqHFp8Nx^fMc6ck;zt~PBOht8bD%kR8} z6Lq!74uywQ0x2kAo4m4{KD7#uEMJAfiFwzR=X~>3tb6%6widDwHPQ-D0HTQmp1FSm zo>;MZkV(2fp8wKGi-4hND4ll~K3y~yTPuHq%A7Fyzi51T4O8;OCES#)9ej?usV;p4L^n#`cB!rLwKnQz73GjbKgjLMBfi zOl>4Qtq=)H*N>k6h?SI=nT;g{vv`$A32wl2*vx;l{$ZTBSc}$37Yt2z$HwE>v2hcV zr`05@A-HpsYgjzya$76Ubw}<56i?4bW-v553UHLe(fnJVy`MkOOMM>sLuab7wQ?`& z+gf_A7f-tbFFo`)2QZHat@!wiad>#y3cT=#N=(S`v*PY9n1!;%OZmFqkG#Oa>JPB< zz?-OPZiHpo>|bR13uhE!-J(yzr)h{K66A9uQwT{R5s4@8@fn4fH+2U4ADO2o;Dwf} zc>1+p;7UguvVvhYZCaV5wI5;28+$p8k!TznC{QVd`-&HFKT@peSSVY(tmkZ&-d$@E z_|vK5_}ZS`sBLaU$nYa86ykAy-N51653z0kZ#gCnHw(z&D1a18CP?Y1Z!Y)zd*9)A z(bUz6ae)w%rBx&snJ_Msm2k4|f@>{~ON#!^A3qWK<0qjz9!E|n19uk`B|poJiV^j zz_emhvvUwl#4$ND8&k4#_?o9Enz{PYdDL9Jf}r7N#SR21B-N8WIh@XXqfrPpy`HwD zutd5o(w)oz{e@vub{wu5yfM9_fv`Wo#B#!6&Jf-h4;NdT*^)-$PNhs`&~WdWy5A}g z97Bczn=$}Hx-og+S~~9c5=I1aIGu?%nObKw(%Y7d0kra&fzUODCCj0x?~QTib3dLe zhl-e(nc%qws&`6uwwp$A3s``o6qlU?+X!=~PUj#wR3SvB&6y*qNUuty4OCrgGlD)p z8avz36_0Trs`Mzs5K5-yV`@$=;-)#|++G1T@-YD5!sOV68h3E=L%k3G{NiN@`TR_Z z1f>_{@~fTgST=JuX6H@g6((1pm_UF3>vA3HTAMfyTyAf}*(>#K+3UELK<<$d2;$qN zYta>p_9(%75y&FRdiw4XM??XR<1j(0^3F9jxNXTiO;#{x%5?nXfiJNuP*KyKd@U`U zjUTT6;-FQc|9^MvABbBP3@H&e6Zrk{BmMe$TWzWjc7EY8jL*nKS1gA1h&wl3TREo~ z&u@5yt;{RSZxo_r+mpj`EfR=)cEcx@WBY@fI68Y38~d*iXzuFdl-LoACQIQWw?Ii= zCn4$ovkmpw@X8B46(cE=YW>R1TTnQ0a!8 z6zIBRF?@gD9{l<%PccESzgHKG3k0!z)|^3eQ;HR%l?D~SCp%l;*o)VXyvqXe9-NP* z+?^Q}J`@n?H<2JyB>|~#LwhTp_|+~{)zz}^6L|s=%m@V7#lHUP&(PA{$^AXW)92D| zk>|F0Uj&*bl|ZPcq=4iefL8RG*I&hZ=g#8G_mpxBAoIHxC@#Ep;s|#C`2gzMTX>%^ zuxj)y?5}65@!;<5c(QaY*34hX8?fuG8&#R!aU4c62K0q{&;j;8GR=kgg;<AEKs-N?IQiy55l(`F_+)z_ct@ECo_Zvw2zBymclkpjcDe z)QI=bpTh?i&cRM@%0^8AeT5}5l>TTBPk~Ij-_~Sn@eVu4r|GccMo)0PlO$3EWr%@? zE#xvpno=`mr&OThCcs-s+LiscqwDLf`apO`UqQp*-QKn4r6w{`i8O~C;s`FCprlCU7-ft=NFk)MRfBNr z>vXaNMVytb8YG~qgsu_!)Qaib6OPMSRY@g8B|k{#J{Ff2aJdRW+@+L8rJYDzm4-$K zX=C3F6clk~&eEuqCn|O}3Z-^x0Y5!P;hryzVGu{+N}Nv+_jG@3PNe&TlaimMefC*# z`uSbWc1P!Zc06&=2nP+J>l7I2_6=o++c7rKxtl{j#DzE!H{wWKi8Ck;_H<7^OG|&! zgT_LNZwgK0C&m?h0cjXVS+@cx&kMR?Lq=}f^~zDZsw!T3ZXj3DZ&VsSKK~yi`d~fO S!@X(%0000 "opencode"; + + // The brand styles itself lowercase — and the row names the AGENT, not a billing product + // (Go and Zen are billing modes of the same account; this must not age badly). + public string DisplayName => "opencode"; + + // Always in the active set, rendering "Sign in" when signed out (see IAgentUsageProvider). + public bool IsAvailable => true; + + public Task GetUsageAsync(CancellationToken ct = default) + { + // All-local, fast IO — no async work to await. The read runs regardless of sign-in state + // so token stats ride NotConfigured too (same attachment convention as Claude/Codex). + var read = OpenCodeUsageReader.ReadLast24Hours(); + + DomainUsageSnapshot snapshot; + if (!OpenCodeCredentialsReader.IsConfigured()) + { + snapshot = DomainUsageSnapshot.Failed(Id, DisplayName, UsageStatus.NotConfigured); + } + else + { + // Spend rides even at $0.00 — "nothing billed in the last 24h" is information, and a + // constant row keeps the dock band's shape stable. Absent only when the DB read failed. + IReadOnlyList windows = read is { } r + ? [new DomainUsageWindow( + "gateway_spend_24h", UsageWindowKind.ExtraUsage, Utilization: 0, ResetsAt: null, + Qualifier: "24h", Used: r.GatewaySpend, Limit: null, Unit: "USD")] + : []; + snapshot = new DomainUsageSnapshot( + Id, DisplayName, UsageStatus.Ok, windows, PlanLabel: null, DateTimeOffset.UtcNow); + Log.Info(Tag, $"local usage read ({windows.Count} window(s))"); + } + + return Task.FromResult(snapshot with { TokenStats = read?.Tokens }); + } +} diff --git a/AgentsPanelExtension/Data/OpenCode/OpenCodeUsageReader.cs b/AgentsPanelExtension/Data/OpenCode/OpenCodeUsageReader.cs new file mode 100644 index 0000000..b778f6a --- /dev/null +++ b/AgentsPanelExtension/Data/OpenCode/OpenCodeUsageReader.cs @@ -0,0 +1,146 @@ +using System; +using System.IO; +using Microsoft.Data.Sqlite; + +namespace AgentsPanelExtension; + +// Sums the last 24 hours' token usage AND gateway spend from opencode's local SQLite DB +// (\opencode.db) — the Data/OpenCode sibling of the token log readers, with one twist: +// opencode records per-message COST in USD, so this reader also returns the rolling-24h spend that +// v1 surfaces in place of the (nonexistent) balance API. No network, no auth. Same never-throw +// contract (any failure degrades to null with a Warn). +// +// Source of truth is the `message` table: assistant rows carry a data JSON with +// providerID / cost (USD) / tokens { input, output, reasoning, cache: { read, write } } and a +// time_created in epoch MILLISECONDS (all verified live 2026-08). The `session` table's counters +// are deliberately NOT used — they're cumulative per session, so they can't be windowed by time. +// Spend counts only providerID == "opencode" (the Zen gateway — money that actually leaves the +// account); token counts cover ALL opencode activity, matching the other readers' "what this agent +// did on this machine" semantics. Extraction happens inside SQLite via json_extract, so message +// CONTENT never leaves the database. +// +// The DB is WAL-mode and live while opencode runs, hence the read strategy: a read-only open works +// both mid-session (the -shm exists) and after a clean exit (WAL checkpointed away); the one edge +// where it can fail is a crash-orphaned -wal without -shm, where read-only recovery is impossible — +// then fall back to copying the db+wal+shm to temp and reading the copy. Pooling is off so no file +// handle outlives a poll. +// +// Static (unlike the tailing token log readers, which hold per-file offsets): each poll is one +// self-contained windowed query, so there's no state to keep between polls. +internal static class OpenCodeUsageReader +{ + private const string Tag = "OpenCodeUsage"; + + internal readonly record struct Result(DomainTokenStats Tokens, decimal GatewaySpend); + + public static Result? ReadLast24Hours() + { + var dbPath = Path.Combine(OpenCodeCredentialsReader.DataDirectory, "opencode.db"); + try + { + if (!File.Exists(dbPath)) + { + Log.Info(Tag, "opencode.db not found — no local usage"); + return null; + } + + try + { + return Query(dbPath); + } + catch (SqliteException ex) + { + Log.Warn(Tag, $"read-only query failed ({ex.SqliteErrorCode}) — retrying on a temp copy"); + return QueryOnCopy(dbPath); + } + } + catch (Exception ex) + { + Log.Warn(Tag, $"db read failed — no usage stats this poll ({ex.GetType().Name}: {ex.Message})"); + return null; + } + } + + private static Result Query(string dbPath) + { + var connectionString = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false, + }.ConnectionString; + + using var connection = new SqliteConnection(connectionString); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandTimeout = 2; // maps to the SQLite busy timeout — a live writer shouldn't stall a poll + command.CommandText = + """ + SELECT json_extract(data, '$.providerID'), + json_extract(data, '$.cost'), + json_extract(data, '$.tokens.input'), + json_extract(data, '$.tokens.output'), + json_extract(data, '$.tokens.reasoning'), + json_extract(data, '$.tokens.cache.read'), + json_extract(data, '$.tokens.cache.write') + FROM message + WHERE time_created >= @cutoffMs + AND json_extract(data, '$.role') = 'assistant' + """; + command.Parameters.AddWithValue( + "@cutoffMs", DateTimeOffset.UtcNow.AddHours(-24).ToUnixTimeMilliseconds()); + + long input = 0, output = 0, cacheRead = 0, cacheWrite = 0; + double gatewaySpend = 0; + var rows = 0; + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + rows++; + input += GetLong(reader, 2); + // opencode reports reasoning separately from output; our four-counter model folds it + // into output (it's generated-token volume either way). + output += GetLong(reader, 3) + GetLong(reader, 4); + cacheRead += GetLong(reader, 5); + cacheWrite += GetLong(reader, 6); + + if (!reader.IsDBNull(0) && reader.GetString(0) == "opencode" && !reader.IsDBNull(1)) + gatewaySpend += reader.GetDouble(1); + } + + Log.Info(Tag, $"summed {rows} assistant message(s) in the last 24h"); + return new Result( + new DomainTokenStats(input, output, cacheRead, cacheWrite), + (decimal)gatewaySpend); + } + + private static long GetLong(SqliteDataReader reader, int ordinal) => + reader.IsDBNull(ordinal) ? 0 : reader.GetInt64(ordinal); + + // Crash-orphaned-WAL fallback: snapshot the three files into temp and query the copy. A torn + // copy under a live writer just throws into the caller's catch — null this poll, retried next. + private static Result QueryOnCopy(string dbPath) + { + var copyDir = Path.Combine(Path.GetTempPath(), "AgentsPanelExtension"); + Directory.CreateDirectory(copyDir); + var copyPath = Path.Combine(copyDir, "opencode.db"); + + File.Copy(dbPath, copyPath, overwrite: true); + CopySidecar(dbPath, copyPath, "-wal"); + CopySidecar(dbPath, copyPath, "-shm"); + + return Query(copyPath); + } + + private static void CopySidecar(string dbPath, string copyPath, string suffix) + { + var source = dbPath + suffix; + var target = copyPath + suffix; + if (File.Exists(source)) + File.Copy(source, target, overwrite: true); + else + File.Delete(target); // never pair a fresh db copy with a stale sidecar from a prior poll + } +} diff --git a/AgentsPanelExtension/Helpers/ProviderIcons.cs b/AgentsPanelExtension/Helpers/ProviderIcons.cs index da790b8..64e7ae7 100644 --- a/AgentsPanelExtension/Helpers/ProviderIcons.cs +++ b/AgentsPanelExtension/Helpers/ProviderIcons.cs @@ -13,6 +13,7 @@ internal static class ProviderIcons private static readonly IconInfo Claude = IconHelpers.FromRelativePath("Assets\\provider_claude.png"); private static readonly IconInfo Codex = IconHelpers.FromRelativePath("Assets\\provider_codex.png"); private static readonly IconInfo Copilot = IconHelpers.FromRelativePath("Assets\\provider_copilot.png"); + private static readonly IconInfo OpenCode = IconHelpers.FromRelativePath("Assets\\provider_opencode.png"); private static readonly IconInfo Fallback = IconHelpers.FromRelativePath("Assets\\agentspanel_logo_base_square.png"); public static IconInfo For(string providerId) => providerId switch @@ -20,6 +21,7 @@ internal static class ProviderIcons "claude" => Claude, "codex" => Codex, "copilot" => Copilot, + "opencode" => OpenCode, _ => Fallback, }; } diff --git a/AgentsPanelExtension/Models/DomainUsage.cs b/AgentsPanelExtension/Models/DomainUsage.cs index da482e0..546611e 100644 --- a/AgentsPanelExtension/Models/DomainUsage.cs +++ b/AgentsPanelExtension/Models/DomainUsage.cs @@ -27,7 +27,9 @@ internal sealed record DomainUsageWindow( DateTimeOffset? ResetsAt, // null when the window has no reset (ExtraUsage) string? Qualifier = null, // e.g. "Opus" for a per-model weekly window decimal? Used = null, // ExtraUsage: used_credits - decimal? Limit = null); // ExtraUsage: monthly_limit + decimal? Limit = null, // ExtraUsage: monthly_limit + string? Unit = null); // ExtraUsage: "USD" when Used is verified dollars (opencode + // spend); null = unverified units (Copilot credits) → bare number // Why a snapshot has no usable windows — or, after a keep-last-good merge, the degradation flag // riding on an old-but-good one. diff --git a/AgentsPanelExtension/Models/UiUsage.cs b/AgentsPanelExtension/Models/UiUsage.cs index 51cbc30..e300c1a 100644 --- a/AgentsPanelExtension/Models/UiUsage.cs +++ b/AgentsPanelExtension/Models/UiUsage.cs @@ -27,7 +27,8 @@ internal sealed record UiUsageWindow(DomainUsageWindow Window) // "Code") the same way ModelWeek's does; unqualified stays plain "Mo". UsageWindowKind.Month => Window.Qualifier ?? Resources.Window_Month_Short, UsageWindowKind.ModelWeek => Window.Qualifier ?? Resources.Window_Week_Short, - UsageWindowKind.ExtraUsage => Resources.Window_Extra_Short, + // ExtraUsage honors Qualifier the same way ("24h" for opencode's rolling spend window). + UsageWindowKind.ExtraUsage => Window.Qualifier ?? Resources.Window_Extra_Short, _ => Window.Id, }; @@ -40,7 +41,9 @@ internal sealed record UiUsageWindow(DomainUsageWindow Window) ? Strings.Format(Resources.Window_MonthQualified_Long, qualifier) : Resources.Window_Month_Long, UsageWindowKind.ModelWeek => Strings.Format(Resources.Window_ModelWeek_Long, Window.Qualifier ?? "?"), - UsageWindowKind.ExtraUsage => Resources.Window_Extra_Long, + UsageWindowKind.ExtraUsage => Window.Qualifier is { } extraQualifier + ? Strings.Format(Resources.Window_ExtraQualified_Long, extraQualifier) + : Resources.Window_Extra_Long, _ => Window.Id, }; @@ -48,9 +51,17 @@ internal sealed record UiUsageWindow(DomainUsageWindow Window) public string FormatPercent() => string.Create(CultureInfo.InvariantCulture, $"{(int)Math.Round(Window.Utilization)}%"); - // The dock button title, e.g. "5h 23%" / "Wk 41%" / "Opus 67%" — must stay within the host's - // ~15-char button budget (longer titles get ellipsized, not scrolled). - public string DockTitle() => $"{ShortLabel} {FormatPercent()}"; + // The pill/tag figure: percent for quota windows, but a dollar-denominated ExtraUsage window + // shows its amount ("$0.12") — its utilization is a placeholder 0, and a green "0%" would read + // as a healthy quota. (Copilot's unit-unknown credits row keeps the percent form.) + public string TagText() => + Window is { Kind: UsageWindowKind.ExtraUsage, Used: { } used, Unit: "USD" } + ? FormatDollars(used) + : FormatPercent(); + + // The dock button title, e.g. "5h 23%" / "Wk 41%" / "Opus 67%" / "24h $0.12" — must stay within + // the host's ~15-char button budget (longer titles get ellipsized, not scrolled). + public string DockTitle() => $"{ShortLabel} {TagText()}"; // The secondary line: when the window resets — "resets 17:30" today, "resets Thu 09:00" further // out — or, for the credits window, "$6.00 of $50.00 used". Empty when there's nothing to say. @@ -58,12 +69,15 @@ public string FormatReset(DateTimeOffset now) { if (Window.Kind == UsageWindowKind.ExtraUsage) { - // Both bounds → "$x of $y" (Claude credits, verified USD). Used only → a bare "n used" - // (Copilot AI credits: the cap isn't reported and the unit is unverified, so no $). + // Both bounds → "$x of $y" (Claude credits, verified USD). Used only: "$x used" when the + // unit is verified USD (opencode spend), else a bare "n used" (Copilot AI credits: the + // cap isn't reported and the unit is unverified, so no $). return Window switch { { Used: { } used, Limit: { } limit } => Strings.Format(Resources.Extra_UsedOfLimit, FormatDollars(used), FormatDollars(limit)), + { Used: { } used, Unit: "USD" } => + Strings.Format(Resources.Extra_Used, FormatDollars(used)), { Used: { } used } => Strings.Format(Resources.Extra_Used, used.ToString("0.##", CultureInfo.InvariantCulture)), _ => string.Empty, diff --git a/AgentsPanelExtension/Pages/UsagePage.cs b/AgentsPanelExtension/Pages/UsagePage.cs index ec206d3..b928b29 100644 --- a/AgentsPanelExtension/Pages/UsagePage.cs +++ b/AgentsPanelExtension/Pages/UsagePage.cs @@ -94,7 +94,7 @@ public override IListItem[] GetItems() // the snapshot is degraded — both may be present with keep-last-good stale numbers. var tags = new List(); if (usage.WorstVisibleWindow(settings) is { } worst) - tags.Add(new Tag(worst.FormatPercent()) { Foreground = worst.SeverityColor() }); + tags.Add(new Tag(worst.TagText()) { Foreground = worst.SeverityColor() }); if (UsageStatusHint.StatusTag(usage.Snapshot.Status) is { } statusTag) tags.Add(statusTag); diff --git a/AgentsPanelExtension/Pages/UsageProviderPage.cs b/AgentsPanelExtension/Pages/UsageProviderPage.cs index d07557e..e07837d 100644 --- a/AgentsPanelExtension/Pages/UsageProviderPage.cs +++ b/AgentsPanelExtension/Pages/UsageProviderPage.cs @@ -95,7 +95,7 @@ public override IListItem[] GetItems() Title = window.LongLabel, Subtitle = stale ?? window.FormatReset(now), Icon = new IconInfo(WindowGlyph(window.Window.Kind)), - Tags = [new Tag(window.FormatPercent()) { Foreground = window.SeverityColor() }], + Tags = [new Tag(window.TagText()) { Foreground = window.SeverityColor() }], }); } diff --git a/AgentsPanelExtension/Properties/Resources.Designer.cs b/AgentsPanelExtension/Properties/Resources.Designer.cs index 0632917..28d7af1 100644 --- a/AgentsPanelExtension/Properties/Resources.Designer.cs +++ b/AgentsPanelExtension/Properties/Resources.Designer.cs @@ -382,6 +382,15 @@ public static string Window_Extra_Short { } } + /// + /// Looks up a localized string similar to Extra usage ({0}). + /// + public static string Window_ExtraQualified_Long { + get { + return ResourceManager.GetString("Window_ExtraQualified_Long", resourceCulture); + } + } + /// /// Looks up a localized string similar to Weekly — {0}. /// diff --git a/AgentsPanelExtension/Properties/Resources.resx b/AgentsPanelExtension/Properties/Resources.resx index cee6159..17e95e4 100644 --- a/AgentsPanelExtension/Properties/Resources.resx +++ b/AgentsPanelExtension/Properties/Resources.resx @@ -97,6 +97,9 @@ Extra usage + + Extra usage ({0}) + 5h diff --git a/Directory.Packages.props b/Directory.Packages.props index c9b4816..d3cec29 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/README.md b/README.md index fa957be..757095d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A Windows 11 [Command Palette](https://learn.microsoft.com/en-us/windows/powerto - Claude Code signed in with a Pro/Max account - Codex CLI or desktop app signed in - A local GitHub Copilot sign-in, or a fine-grained PAT with *Copilot Requests: Read* +- opencode signed in (Zen or Go) ## Installation @@ -53,6 +54,10 @@ One top-level **Agents Panel** command opens the hub. +### opencode + +TODO ! + ### Dock integration Each agent is its own dock band with quick-look buttons like `5h 27%` and `Wk 17%` that live-update while pinned and click through to the full panel. From ad5d02d911fb6df3cbdbc4816945c2ba72be631a Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 02:32:01 +0300 Subject: [PATCH 6/7] opencode integration --- docs/index.html | 2 +- docs/privacy.html | 17 ++++++++++++++--- docs/terms.html | 4 ++-- notes/certification-notes.md | 9 +++++---- notes/store-listing.md | 11 ++++++++--- 5 files changed, 30 insertions(+), 13 deletions(-) diff --git a/docs/index.html b/docs/index.html index 9965228..11e2c21 100644 --- a/docs/index.html +++ b/docs/index.html @@ -19,7 +19,7 @@

Agents Panel for Command Palette

Your AI agents' usage — right inside Windows PowerToys Command Palette.

-

Agents Panel is an open-source PowerToys Command Palette extension that shows how much of your Claude, Codex, and GitHub Copilot usage quotas you have used — session and weekly percentages, reset times, and plan info, with pinnable Dock quick-look buttons. It reads the sign-ins your machine already has and runs entirely locally — no telemetry, no analytics, no accounts of its own.

+

Agents Panel is an open-source PowerToys Command Palette extension that shows how much of your Claude, Codex, and GitHub Copilot usage quotas you have used, plus your recent opencode spend and token counts — session and weekly percentages, reset times, and plan info, with pinnable Dock quick-look buttons. It reads the sign-ins your machine already has and runs entirely locally — no telemetry, no analytics, no accounts of its own.

diff --git a/docs/privacy.html b/docs/privacy.html index 2c0e528..b16be67 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -20,11 +20,11 @@

Privacy Policy

Agents Panel for Command Palette — Last updated August 2026

- In short: Agents Panel runs entirely on your machine. It has no telemetry, no analytics, and no accounts of its own, and it never sends your data to its author. It reads the sign-in credentials that other apps on your machine already store (Claude Code, the Codex CLI/app, GitHub tooling) and uses them for exactly one thing: asking each provider's own usage endpoint for your quota numbers, directly from your device. + In short: Agents Panel runs entirely on your machine. It has no telemetry, no analytics, and no accounts of its own, and it never sends your data to its author. It reads the sign-in credentials that other apps on your machine already store (Claude Code, the Codex CLI/app, GitHub tooling) and uses them for exactly one thing: asking each provider's own usage endpoint for your quota numbers, directly from your device. For agents that keep activity records locally (Claude Code, Codex, opencode), it also reads those files to show your recent token counts and spend — those numbers are computed on your device and never leave it.

Overview

-

Agents Panel for Command Palette (the "extension") is an open-source PowerToys Command Palette extension that shows the usage quotas of AI coding agents — Claude, Codex, and GitHub Copilot. This policy explains what the extension reads, what it stores, what leaves your device, and what it deliberately does not do.

+

Agents Panel for Command Palette (the "extension") is an open-source PowerToys Command Palette extension that shows the usage of AI coding agents — Claude, Codex, GitHub Copilot, and opencode. This policy explains what the extension reads, what it stores, what leaves your device, and what it deliberately does not do.

Credentials the extension reads (but does not own)

To fetch your usage numbers, the extension reads access tokens that other applications on your machine have already stored as part of their own sign-in:

@@ -32,9 +32,19 @@

Credentials the extension reads (but does not own)

  • Claude: the Claude Code sign-in token from %USERPROFILE%\.claude\.credentials.json.
  • Codex: the Codex CLI/app sign-in from auth.json in your Codex home directory.
  • GitHub Copilot: an existing GitHub token found via environment variables, the Windows Credential Manager (GitHub CLI or Copilot CLI sign-ins), GitHub CLI's hosts.yml, or the editor Copilot apps' local files — or a fine-grained personal access token you paste into the extension's settings yourself.
  • +
  • opencode: the extension only checks whether opencode's auth.json contains a sign-in entry, to show a signed-in or signed-out row. It never reads, uses, or transmits the key itself — the opencode row involves no network requests at all (opencode currently exposes no usage endpoint).
  • Tokens are read at request time only. The extension never copies them elsewhere, never logs them, never refreshes or modifies them, and never transmits them to anyone except the matching provider's own API (see below). One exception on storage: if you paste a GitHub personal access token into the extension's settings, that token is saved in plain text in the extension's local settings file (agentspanel.settings.json under Command Palette's settings folder) — use a fine-grained token limited to Copilot Requests: Read, and remove it from settings to delete it.

    +

    Local activity records the extension reads

    +

    Some agents keep records of your sessions on your machine. To show your recent token counts (and, for opencode, spend), the extension reads:

    +
      +
    • Claude: Claude Code's session logs under %USERPROFILE%\.claude\projects.
    • +
    • Codex: the Codex CLI/app's session logs in your Codex home directory.
    • +
    • opencode: opencode's local database in its data directory.
    • +
    +

    These files can contain the content of your conversations with the agents. The extension extracts only numeric usage figures — token counts, timestamps, and per-message cost — and sums them into the totals it displays. It never reads your conversation content into the display, never stores copies of these files, and never transmits anything from them; the resulting totals exist only on your device. The token-count rows can be hidden in the extension's settings.

    +

    Data stored on your device

    The extension keeps a small local settings file (refresh interval, display options, and the optional GitHub token described above). It stays on your machine and is never transmitted to the author. You can clear it by removing the settings file or uninstalling the extension.

    @@ -45,6 +55,7 @@

    Data that leaves your device

  • OpenAI (chatgpt.com) — your Codex/ChatGPT usage and limits.
  • GitHub (api.github.com) — your Copilot quotas and plan.
  • +

    The opencode row is the exception: it is computed entirely from local files, and the extension makes no network requests to opencode at all.

    These requests do not pass through any server operated by the author, and the author never sees your tokens, your account details, or your usage numbers. Each provider handles the requests it receives under its own privacy policy and terms of service; your use of those services remains subject to their terms.

    What the extension does not do

    @@ -54,7 +65,7 @@

    What the extension does not do

  • Send crash reports or diagnostics
  • Require or create an account
  • Store, log, refresh, or forward your tokens (beyond the optional pasted GitHub token saved in local settings, described above)
  • -
  • Share, sell, or transmit your data to the author or any party other than the three providers described above
  • +
  • Share, sell, or transmit your data to the author or any party other than the providers described above
  • Release builds emit nothing off your machine beyond the direct provider requests above.

    diff --git a/docs/terms.html b/docs/terms.html index 2420bdf..a4a00e5 100644 --- a/docs/terms.html +++ b/docs/terms.html @@ -30,10 +30,10 @@

    2. License

    The extension is free and open-source software, licensed under the MIT License. Your use, modification, and distribution of the source code are governed by that license. The source is available at github.com/CostaFot/agents-panel-extension.

    3. Non-affiliation

    -

    Agents Panel is an independent project. It is not affiliated with, endorsed by, sponsored by, or supported by Anthropic, OpenAI, GitHub, or Microsoft. "Claude" is a trademark of Anthropic, PBC; "ChatGPT" and "Codex" are trademarks of OpenAI; "GitHub" and "GitHub Copilot" are trademarks of GitHub, Inc.; "Windows", "PowerToys", and "Microsoft" are trademarks of Microsoft Corporation. Those names are used solely to identify the services whose usage the extension displays.

    +

    Agents Panel is an independent project. It is not affiliated with, endorsed by, sponsored by, or supported by Anthropic, OpenAI, GitHub, Microsoft, or Anomaly. "Claude" is a trademark of Anthropic, PBC; "ChatGPT" and "Codex" are trademarks of OpenAI; "GitHub" and "GitHub Copilot" are trademarks of GitHub, Inc.; "Windows", "PowerToys", and "Microsoft" are trademarks of Microsoft Corporation; "opencode" is a trademark of its developer, Anomaly Innovations. Those names are used solely to identify the services whose usage the extension displays.

    4. Third-party services & your accounts

    -

    The extension displays usage data for subscriptions and accounts you hold with third-party providers (Anthropic, OpenAI, GitHub). It requires those existing sign-ins to show live data and provides no service of its own without them. Your relationship with each provider — including your subscription, its limits, and your compliance with its terms of service — is solely between you and that provider. The extension reads locally stored credentials created by those providers' own apps and uses them only as described in the Privacy Policy; you are responsible for ensuring that this use is acceptable under your agreements with each provider.

    +

    The extension displays usage data for subscriptions and accounts you hold with third-party providers (Anthropic, OpenAI, GitHub, Anomaly). It requires those existing sign-ins to show live data and provides no service of its own without them. Your relationship with each provider — including your subscription, its limits, and your compliance with its terms of service — is solely between you and that provider. The extension reads locally stored credentials and activity records created by those providers' own apps and uses them only as described in the Privacy Policy; you are responsible for ensuring that this use is acceptable under your agreements with each provider.

    5. Data accuracy & availability

    The usage endpoints the extension calls are not documented or sanctioned for third-party use. Figures may lag the provider's own display, be shaped differently across plans, or disappear entirely when a provider changes its API. The extension deliberately degrades by showing last-known values marked as stale, or a sign-in/error row, rather than guessing. Do not rely on the extension as your sole indicator of remaining quota.

    diff --git a/notes/certification-notes.md b/notes/certification-notes.md index 95c412d..361a0d1 100644 --- a/notes/certification-notes.md +++ b/notes/certification-notes.md @@ -1,14 +1,15 @@ # Certification test notes (Partner Center submission) Paste (or adapt) the block below into the "Notes for certification" field. A Store reviewer has no -Claude/Codex/Copilot credentials, so the app will show three "Not signed in" rows — these notes explain -why that is the expected, fully functional state, and give the reviewer a way to exercise live data. +Claude/Codex/Copilot/opencode credentials, so the app will show four "Not signed in" rows — these notes +explain why that is the expected, fully functional state, and give the reviewer a way to exercise live +data. --- Agents Panel is a PowerToys Command Palette extension — it works only inside Command Palette and shows usage data for AI subscriptions already signed in on the machine (Claude, Codex, GitHub -Copilot). No accounts, servers, or telemetry of its own. +Copilot, opencode). No accounts, servers, or telemetry of its own. **IMPORTANT: on a machine with no AI sign-ins, every row shows "Not signed in". That is the correct, fully functional state — the app never shows fake data. Please do not fail it for this; @@ -18,7 +19,7 @@ To test: 1. Install Microsoft PowerToys (free) and enable Command Palette. (Launching "Agents Panel" from the Start menu only shows a pointer to Command Palette — by design.) -2. Press **Win+Alt+Space**, type **Agents Panel**, press Enter → three provider rows, each "Not +2. Press **Win+Alt+Space**, type **Agents Panel**, press Enter → four provider rows, each "Not signed in" (expected). Enter on a row opens its detail page with a Refresh command. 3. Optional, to see live data at no cost: on a GitHub account with Copilot enabled (free tier is enough), create a fine-grained token at https://github.com/settings/personal-access-tokens diff --git a/notes/store-listing.md b/notes/store-listing.md index bf3875c..5df76f9 100644 --- a/notes/store-listing.md +++ b/notes/store-listing.md @@ -17,8 +17,8 @@ Track your AI coding agents' usage right inside Command Palette. 1. Claude usage 2. Codex usage 3. Copilot usage -4. AI agent quota -5. usage limits +4. opencode usage +5. AI agent quota 6. PowerToys 7. Command Palette extension @@ -30,6 +30,7 @@ dropped them — these carry the "claude usage"-style search queries. Track your AI coding agents' usage right inside Command Palette. • See session and weekly quotas, reset times, and plan info for Claude, Codex, and GitHub Copilot +• See your recent opencode spend and token counts, read from its data on your device • Pin per-agent quick-look buttons like "5h 23%" and "Wk 41%" to the Command Palette dock • No accounts, no tracking, no data collection @@ -51,10 +52,14 @@ Requirements "no servers of its own", "Requirements" block). That listing passed certification naming Finnhub/Twelve Data/Frankfurter freely — precedent that nominative naming is fine. - **Name frequency minimized** (2026-08-14 decision): providers are named once in the body (the - first bullet); the non-affiliation paragraph names the companies (mirroring Markets' "or any + first two bullets — opencode gets its own bullet since 2026-08-15 because its data is local-only, + not quota %s, and lumping it into the quota bullet would misdescribe it); the non-affiliation + paragraph names the companies (mirroring Markets' "or any exchange or data provider" with "or any AI provider"); every other paragraph says "provider"/"agent". The short description drops the names too (2026-08-14 final, user call — matches the description opener verbatim); provider-name search relies on the full description. +- **opencode search term** replaced "usage limits" (2026-08-15): still 7 terms / 15 words, and the + provider-name queries are what the terms exist for. - **No "official"** — anywhere, ever; the whole listing leans on "independent"/"unofficial". - **No "real-time"/"live" guarantees** — polling is minutes-coarse and the endpoints are undocumented; the delay disclaimer carries the honest version. From 13fc65b7727617a16d5073620a2650da7ca19bc5 Mon Sep 17 00:00:00 2001 From: Costa Fotiadis Date: Sat, 15 Aug 2026 02:40:45 +0300 Subject: [PATCH 7/7] update slop files --- AGENTS.md | 247 +++++++++++-------------------------- notes/provider-claude.md | 36 ++++++ notes/provider-codex.md | 43 +++++++ notes/provider-copilot.md | 57 +++++++++ notes/provider-opencode.md | 51 ++++++++ notes/token-stats.md | 39 ++++++ 6 files changed, 295 insertions(+), 178 deletions(-) create mode 100644 notes/provider-claude.md create mode 100644 notes/provider-codex.md create mode 100644 notes/provider-copilot.md create mode 100644 notes/provider-opencode.md create mode 100644 notes/token-stats.md diff --git a/AGENTS.md b/AGENTS.md index e5a8030..c713fca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,23 +1,22 @@ # Agents Panel Extension for Command Palette — Agent Guide Single source of truth for coding agents (Claude Code, Codex, …) working in this repo — CLAUDE.md -just imports this file. +just imports this file. Deep provider/token-stats detail lives in `notes/` (see below); read the +matching note BEFORE changing anything under `Data//`. A PowerToys **Command Palette** extension showing AI-agent **usage quotas** — session/weekly % used, reset times, plan info — with pinnable per-provider **Dock bands** as the main selling point ("5h 23%" / "Wk 41%" quick-look buttons; one band per provider, so the user picks which agents to -pin via the host's own band management). Covers **Claude** (Pro/Max subscription limits), **Codex** -(ChatGPT subscription limits), **Copilot** (GitHub Copilot quotas), and **opencode** (local-data -only — no usage API exists yet); the provider architecture is -open for more later. .NET 9 / C# / MSIX, -self-contained single-file JIT (trim/AOT deliberately OFF). +pin via the host's own band management). Covers **Claude**, **Codex**, **Copilot**, and +**opencode**; the provider architecture is open for more later. .NET 9 / C# / MSIX, self-contained +single-file JIT (trim/AOT deliberately OFF). ## Reference project — use it A LOT -Scaffolded from **MarketExtension** (`C:\Users\jarla\code\MarketExtension`, same author). When in doubt, -look there first — its `CLAUDE.md` + `notes/` document hard-won CmdPal knowledge (`notes/cmdpal-toolkit.md` -before fighting any toolkit behavior, `notes/releasing.md` for the release flow). Its `reference/` folder -holds the pristine AdbExtension blank-extension files. +Scaffolded from **MarketExtension** (`C:\Users\jarla\code\MarketExtension`, same author). When in +doubt, look there first — its `CLAUDE.md` + `notes/` document hard-won CmdPal knowledge +(`notes/cmdpal-toolkit.md` before fighting any toolkit behavior, `notes/releasing.md` for the +release flow). Its `reference/` folder holds the pristine AdbExtension blank-extension files. ## Architecture in one screen @@ -25,179 +24,71 @@ Single observable source of truth; every surface OBSERVES, none fetches: - `Data/UsageRepository.cs` — **the coordinator the UI depends on**: owns one `MutableStateFlow>` (the whole cache — no DynamicData/per-key - streams), a permanent `PollTicker` subscription whose handler no-ops at 0 observers, single-flight - refresh, **keep-last-good merge** (a failed poll never blanks good numbers — old windows survive with - the new `UsageStatus` riding on them), and `ObserveUsage()` ending in the **load-bearing - `ObserveOn(TaskPoolScheduler.Default)`** hop. `ObserveUsage(providerId)` is a thin projection layered - ON TOP of it (inherits refcounting + the hop; emits null while the provider is absent) — keep it that - way. -- `Data/IAgentUsageProvider.cs` — the extension seam: `Id`, `DisplayName`, `IsAvailable` (participates - at all — read live), `GetUsageAsync` (**never throws** for expected failures; returns a - `UsageStatus`-carrying snapshot). ⚠️ Unlike MarketExtension there is NO - fallback routing: an unconfigured provider stays active and reports `NotConfigured` (rendered as a + streams), a permanent `PollTicker` subscription whose handler no-ops at 0 observers, + single-flight refresh, **keep-last-good merge** (a failed poll never blanks good numbers — old + windows survive with the new `UsageStatus` riding on them), and `ObserveUsage()` ending in the + **load-bearing `ObserveOn(TaskPoolScheduler.Default)`** hop. `ObserveUsage(providerId)` is a + thin projection layered ON TOP of it (inherits refcounting + the hop; emits null while the + provider is absent) — keep it that way. +- `Data/IAgentUsageProvider.cs` — the extension seam: `Id`, `DisplayName`, `IsAvailable` + (participates at all — read live), `GetUsageAsync` (**never throws** for expected failures; + returns a `UsageStatus`-carrying snapshot). ⚠️ Unlike MarketExtension there is NO fallback + routing: an unconfigured provider stays active and reports `NotConfigured` (rendered as a "Sign in" row/button) instead of disappearing — agent providers aren't interchangeable. -- Registration order in `AgentsPanelCommandsProvider`: `ClaudeUsageProvider` → `CodexUsageProvider` - → `CopilotUsageProvider` → `OpenCodeUsageProvider`, in the static `Providers` array (used twice: - repository ctor + one dock - band per entry). Add - providers to that array, plus a PNG + case in `Helpers/ProviderIcons.cs` (ProviderId → icon; - identifies dock buttons/hub rows — the terse dock titles carry no provider identity) — the hub row - and dock band then appear automatically. +- Registration: `ClaudeUsageProvider` → `CodexUsageProvider` → `CopilotUsageProvider` → + `OpenCodeUsageProvider` in `AgentsPanelCommandsProvider`'s static `Providers` array (used twice: + repository ctor + one dock band per entry). To add a provider: add it to that array, plus a PNG + + case in `Helpers/ProviderIcons.cs` (ProviderId → icon; identifies dock buttons/hub rows — the + terse dock titles carry no provider identity) — the hub row and dock band then appear + automatically. - Model layering (MarketExtension convention): `Api*Dto` (wire format, all-nullable) → `Domain*` - (provider-agnostic, NO formatting) → `Ui*` (`Models/UiUsage.cs` — the ONLY formatting home). Enums - (`UsageWindowKind`, `UsageStatus`) unprefixed. + (provider-agnostic, NO formatting) → `Ui*` (`Models/UiUsage.cs` — the ONLY formatting home). + Enums (`UsageWindowKind`, `UsageStatus`) unprefixed. - Surfaces (two-level since 2026-08): `Pages/UsagePage.cs` (hub = **provider list**, one row per - provider with a "5h 23% · Wk 41%" summary subtitle + worst-window/status pills; also the top-level - command) → `Pages/UsageProviderPage.cs` (per-provider drill-in: window rows, status/plan rows, - Refresh — dock buttons bypass the hub, so those can't be hub-only) → and + provider with a "5h 23% · Wk 41%" summary subtitle + worst-window/status pills; also the + top-level command) → `Pages/UsageProviderPage.cs` (per-provider drill-in: window rows, + status/plan rows, Refresh — dock buttons bypass the hub, so those can't be hub-only) → `Pages/UsageDockPage.cs` (**one instance per provider** since 2026-08-13, observing `ObserveUsage(providerId)`; each `GetItems()` row = one dock button, title budget ~15 chars, **deep-links to that provider's page**; per-band `Id` = `…agentspanel.dock.` — must stay unique. Deliberately NO "show provider X in dock" setting: the host's pin/unpin per band IS the chooser — ShowTokenStatsInDock is different, it toggles a button WITHIN a band). All three - implement the explicit - `INotifyItemsChanged` pattern: subscribe `ObserveUsage(...)` in the event's `add` accessor, - dispose-all in `remove`, `List` (host may add twice), `private new void - RaiseItemsChanged`. `Pages/UsageProviderPageCache.cs` holds ONE `UsageProviderPage` per ProviderId, - shared by hub AND dock (built in `AgentsPanelCommandsProvider`) so both navigate into the same - instance; lazy, never evicts — an unobserved page holds no repository observer, so stale entries - are free. + implement the explicit `INotifyItemsChanged` pattern: subscribe `ObserveUsage(...)` in the + event's `add` accessor, dispose-all in `remove`, `List` (host may add twice), + `private new void RaiseItemsChanged`. `Pages/UsageProviderPageCache.cs` holds ONE + `UsageProviderPage` per ProviderId, shared by hub AND dock (built in + `AgentsPanelCommandsProvider`) so both navigate into the same instance; lazy, never evicts — an + unobserved page holds no repository observer, so stale entries are free. - Provider-row summary/filter formatting lives in `UiUsage` (`VisibleWindows`/`SummaryText`/ `WorstVisibleWindow` — the ShowModelWindows/ShowExtraUsage filter has ONE home now); status - pill/subtitle extraction lives in `UsageStatusHint.StatusTag`/`StatusSubtitle` (used by `StatusRow` - too — don't re-fork the colors/strings). + pill/subtitle extraction lives in `UsageStatusHint.StatusTag`/`StatusSubtitle` (used by + `StatusRow` too — don't re-fork the colors/strings). - **Token stats** (since 2026-08-15): `DomainTokenStats` rides `DomainUsageSnapshot.TokenStats` - (nullable — absent means "provider has no local logs / read failed", a QUIET absence, never an - error surface). All readers sum a **rolling 24h** window (deliberately not calendar-day: a - midnight reset zeroes the row mid-session); the Claude/Codex JSONL readers tail incrementally - (per-file byte offset; only - newline-complete lines consumed; shrunken files reparsed from zero), and all are attached AROUND the - quota fetch so they ride every outcome incl. NotConfigured/TokenExpired. The repository merge takes - the FRESH TokenStats even under keep-last-good (local logs succeed when HTTP fails). - `Data/Claude/ClaudeTokenLogReader.cs` tails `%USERPROFILE%\.claude\projects\*\*.jsonl` with - per-message-id dedupe (streaming rewrites repeat message ids; last wins). - `Data/Codex/CodexTokenLogReader.cs` tails `%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl` - `token_count` events — ⚠️ `total_token_usage` is CUMULATIVE per session, so it DIFFS consecutive - events into timestamped deltas (never sums; rebaselines on a counter reset), and Codex's - `input_tokens` INCLUDES the cached subset, so fresh input = input − cached. Formatting: - `UiUsage.TokenSummary` ("56k in · 29k out · 2.2M cache read"; in = fresh input + cache WRITES — raw - input_tokens is a misleading crumb under prompt caching) + `TokenDockTitle`/`TokenTotalText` - ("24h 2.3M" / page-row tag — the TOTAL of all four counters, the ecosystem's glance number; any - single component reads as noise at first sight). Two settings: ShowTokenStats (page row) and - subordinate ShowTokenStatsInDock - (dock button; requires the first). Copilot has no local logs; stays null. opencode's stats come - from its SQLite DB via `Data/OpenCode/OpenCodeUsageReader.cs` (stateless windowed query, not a - tail — see its section below). - -## The Claude data source (Data/Claude/ — deliberately isolated) - -`GET https://api.anthropic.com/api/oauth/usage`, `Authorization: Bearer ` from -`%USERPROFILE%\.claude\.credentials.json`, `anthropic-beta: oauth-2025-04-20`, honest UA -`agents-panel/` (**decision: no client spoofing**). ⚠️ **Undocumented endpoint, ToS-gray** for -published tools; it can change or vanish — that's why everything endpoint-specific stays behind -`IAgentUsageProvider` in `Data/Claude/`, so a replacement source is a drop-in sibling. - -- Response has TWO generations of shape: legacy named windows (`five_hour`, `seven_day`, - `seven_day_opus/sonnet` — the per-model ones are null now) AND the newer generic `limits` array - (`kind`: `session`/`weekly_all`/`weekly_scoped` + `scope.model.display_name`). **Map from `limits` - when present**; legacy is the fallback. Verified live 2026-08. -- **Never log the token**; read credentials at call time only; NO token refresh (undocumented rotation - could log the user out of Claude Code) — expired ⇒ `TokenExpired` status. -- Endpoint rate-limits hard: poll floor **3 min**, clamped in `UsageSettingsManager.RefreshMinutes`'s - getter (not just the placeholder). `HttpRetry` honors Retry-After, 3 attempts, 8s bail. - -## The Codex data source (Data/Codex/ — deliberately isolated, mirrors Data/Claude/) - -`GET https://chatgpt.com/backend-api/wham/usage`, `Authorization: Bearer ` + -`ChatGPT-Account-Id: ` from `%CODEX_HOME%\auth.json` (default `%USERPROFILE%\.codex`), -honest UA. Same ⚠️ **undocumented, ToS-gray** tier as the Claude endpoint (paths/fields/plan_type -values have all churned across 2025–2026); reference implementations: openai/codex -`codex-rs/backend-client` + steipete/CodexBar. - -- **Window shape varies BY PLAN** (why `UsageWindowKind` picks from `limit_window_seconds`, never the - primary/secondary slot): Plus/Pro report 5h primary + weekly secondary; the Go plan reports ONE - 30-day primary (→ `Month` kind, "Mo" dock label) and a null secondary. Verified live 2026-08 on Go. -- Reset time: `reset_at` (epoch seconds) when present, else `now + reset_after_seconds` — both - generations exist in the wild. -- **Never log the token**; read credentials at call time; NO token refresh — a harder no than Claude: - Codex **rotates refresh tokens**, so a third-party refresh that doesn't write back perfectly logs - the user out of Codex itself. Expired ⇒ `TokenExpired`; Codex's own use refreshes it (~8-day cadence). - Plan type + expiry come from the access token's JWT claims (decoded unvalidated, local trust); - `id_token`/`refresh_token` are deliberately never deserialized. -- On this dev machine Codex is the MSIX desktop app (`OpenAI.Codex`) — no `codex` CLI on PATH and - WindowsApps ACLs block spawning its bundled codex.exe, which is why the `codex app-server` JSON-RPC - route was rejected in favor of direct HTTP. - -## The Copilot data source (Data/Copilot/ — deliberately isolated, mirrors Data/Codex/) - -`GET https://api.github.com/copilot_internal/user`, `Authorization: Bearer `, honest UA — -deliberately NO Editor-Version masquerade (the endpoint answers plain user agents; Oh My Posh's -copilot segment ships the same call). Same ⚠️ **undocumented, ToS-gray** tier as the other two -(GitHub has never sanctioned third-party use of `copilot_internal`; the 2026-06 move to AI-credit -billing already reshaped the response once). Reference implementations: Oh My Posh `copilot` -segment, steipete/CodexBar, ericc-ch/copilot-api. - -- **Response shape varies BY PLAN and by billing generation** — `MapWindows` branches, in order: - (1) `quota_snapshots` (all current plans; verified live 2026-08 on Free: chat 200 + completions - 2000 metered, premium_interactions zeroed) — skip buckets with `unlimited`, `has_quota=false`, or - `entitlement<=0` (**a zeroed bucket's `percent_remaining: 0` is "not metered", NOT "100% used"**; - credit-billed seats zero out all three — CodexBar#1258); (2) legacy free-tier - `limited_user_quotas`/`monthly_quotas` maps; (3) top-level `credits_used` → an ExtraUsage - "n used" row (cap/units unreported — no fabricated percentage). All windows are `Month` kind - (one shared monthly reset: `quota_reset_date_utc` → `quota_reset_date` → `limited_user_reset_date`); - `Qualifier` ("Chat"/"Code") tells multiple monthly buckets apart — `UiUsage` renders a qualified - Month like a qualified ModelWeek. -- **Credential discovery chain** (`CopilotCredentialsReader`, first usable wins): settings PAT - override (`UsageSettingsManager.CopilotToken` — plain text in agentspanel.settings.json) → env - `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN` → Windows Credential Manager (one full - enumerate matching by target-name shape, **observed live**: Copilot CLI = - `.github-copilot-app` — a SUFFIX match, CredEnumerate wildcards are prefix-only — and gh - secure storage = `gh:github.com:`, go-keyring `service:user` naming, so exact `CredRead` - misses it; blob may be raw token or a JSON wrapper — both handled) → `%APPDATA%\GitHub CLI\ - hosts.yml` (hand-parsed, GH_CONFIG_DIR honored; empty of tokens when secure storage is in use) → - `%LOCALAPPDATA%\github-copilot\apps.json`/`hosts.json`. Classic `ghp_` PATs are skipped - everywhere (endpoint rejects them); `gho_`/`ghu_` OAuth tokens and fine-grained PATs - (*Copilot Requests: Read*) work. -- **Never log the token** (log only the source label); read at call time; NO token refresh — these - are other apps' long-lived OAuth tokens. GitHub tokens are opaque (no JWT claims), so there's no - local expiry check: a dead/rejected token surfaces as 401/403 ⇒ `TokenExpired`. - -## The opencode data source (Data/OpenCode/ — LOCAL-ONLY, no HTTP at all) - -The deliberate v1 shape, because **opencode has no balance/usage API**: `/zen/v1/balance` and -`/zen/v1/usage` 404 (probed live 2026-08-15); upstream requests anomalyco/opencode **#10448** (Zen -balance) + **#16017** (Go plan windows) both open. The only remote source is the opencode.ai -dashboard's cookie-authenticated `_server?id=` RPC (what steipete/CodexBar scrapes on -macOS) — **rejected**: Windows browser-cookie decryption is invasive, and the function ids are -frontend-build hashes that churn every deploy. When the balance endpoint ships, the HTTP fetch -drops into `OpenCodeUsageProvider` as the quota path (auth.json API key, read at call time, never -logged, no refresh) — same seam as the other providers. - -- Everything comes from opencode's local data dir (xdg-style even on Windows: - `XDG_DATA_HOME` ?? `%USERPROFILE%\.local\share`, + `\opencode`): - - `auth.json` (`OpenCodeCredentialsReader`) — a map `providerID → {type: api|oauth|wellknown, …}`; - an `"opencode"` entry (Zen login = `type: "api"`) is the ONLY thing checked, purely for the - NotConfigured/Ok pivot. **v1 never extracts the key** — there's nothing to spend it on. - - `opencode.db` (`OpenCodeUsageReader`, Microsoft.Data.Sqlite) — WAL-mode SQLite. The `message` - table's assistant rows carry `data` JSON with `providerID`/`cost` (USD)/`tokens{input, output, - reasoning, cache{read,write}}` and `time_created` in epoch **milliseconds** (verified live - 2026-08). One windowed query per poll (stateless — no tailing), `json_extract` in SQL so message - CONTENT never leaves the database. ⚠️ The `session` table's counters are cumulative-per-session — - can't be time-windowed; don't "simplify" to them. -- Reports: one `ExtraUsage` window = rolling-24h **gateway spend** (`Σ cost WHERE providerID = - 'opencode'` — Zen credits / Go balance-fallback, the money that actually leaves the account; - Go-subscription-covered messages record cost 0 locally so the sum stays honest) with - `Qualifier "24h"` + `Unit "USD"` (renders "$0.13 used" / dock "24h $0.13" — `Unit` is what - separates verified dollars from Copilot's unit-unknown bare "n used"), plus TokenStats over ALL - opencode messages (reasoning folds into output). No HTTP ⇒ status is only ever - NotConfigured (no auth.json entry) or Ok; a failed DB read degrades to an empty-Ok snapshot - (quiet absence). -- SQLite read strategy: `Mode=ReadOnly; Pooling=False` (no handle survives the poll), - `CommandTimeout=2` (= busy timeout). Read-only WAL access works mid-session (live `-shm`) and - after clean exit; the crash-orphaned-wal edge falls back to copying db+wal+shm to temp and - querying the copy. Any failure → null this poll, retried next. -- DisplayName/Id are `opencode` (lowercase brand; names the AGENT, not a billing product — Go and - Zen are billing modes of the same account, so a "Zen" row would age badly). + (nullable — absent is a QUIET absence, never an error surface); all readers sum a **rolling 24h** + window; formatting (`TokenSummary`/`TokenDockTitle`/`TokenTotalText`) lives in `UiUsage`. + ⚠️ Reader mechanics are subtle (incremental tails, cumulative-counter diffing, dedupe) — read + `notes/token-stats.md` before touching any of it. + +## Data sources — read the note before touching + +Each provider is deliberately isolated behind `IAgentUsageProvider` in its own `Data//` +folder, so a replacement source is a drop-in sibling. All HTTP endpoints are ⚠️ **undocumented and +ToS-gray** — they can change or vanish. Shared credential rules, no exceptions: **never log a +token**, read credentials at call time only, **NO token refresh** (each provider's note explains +why its refresh is dangerous); expired ⇒ `TokenExpired`. Honest UA, no client spoofing. + +- **Claude** (`Data/Claude/`, `notes/provider-claude.md`) — `api.anthropic.com/api/oauth/usage` + with the Claude Code OAuth token. ⚠️ Response has two shape generations — map from `limits` when + present; poll floor 3 min (endpoint rate-limits hard). +- **Codex** (`Data/Codex/`, `notes/provider-codex.md`) — `chatgpt.com/backend-api/wham/usage` with + the Codex auth.json token. ⚠️ Window shape varies by plan — pick `UsageWindowKind` from + `limit_window_seconds`, never the primary/secondary slot. +- **Copilot** (`Data/Copilot/`, `notes/provider-copilot.md`) — `api.github.com/copilot_internal/ + user` via a five-step credential discovery chain. ⚠️ A zeroed quota bucket means "not metered", + NOT "100% used". +- **opencode** (`Data/OpenCode/`, `notes/provider-opencode.md`) — LOCAL-ONLY (no usage API exists + yet): auth.json for the NotConfigured/Ok pivot + windowed SQLite reads of `opencode.db`. + ⚠️ The `session` table's counters are cumulative-per-session — don't "simplify" to them. ## Build & Deploy @@ -236,8 +127,8 @@ So: - ⚠️ **The Write tool drops Segoe MDL2 glyph chars.** Always write them as `\uXXXX` escapes and byte-check after editing. (Bit us during initial development of this very repo.) - ⚠️ **`ObserveOn(TaskPoolScheduler.Default)` in `UsageRepository.ObserveUsage` is load-bearing** — - removing it (or "fixing" with `SubscribeOn`) re-creates the Rx-gate↔STA deadlock that hangs CmdPal. - Don't add `Task.Run` on the delivery path either. + removing it (or "fixing" with `SubscribeOn`) re-creates the Rx-gate↔STA deadlock that hangs + CmdPal. Don't add `Task.Run` on the delivery path either. - ⚠️ **Page-activation:** `GetItems()` runs before `ItemsChanged` is subscribed — a constructor `RaiseItemsChanged` is lost. First paint must come from the `add`-accessor subscription replay. - ⚠️ **Dock bands require a non-empty command `Id`** or the band silently disappears — and with one @@ -246,14 +137,14 @@ So: (`Data/Claude/ApiClaudeDtos.cs`, `Data/Codex/ApiCodexDtos.cs` — one context per provider) — splitting silently breaks the generator. - ⚠️ **`TextSetting` renders `Description` as the on-screen label** (Label is ignored by Input.Text). -- **PollTicker source operators must never throw** — a throw OnErrors the multicast and kills polling - for every subscriber until reload. Settings reads stay parse-with-fallback. +- **PollTicker source operators must never throw** — a throw OnErrors the multicast and kills + polling for every subscriber until reload. Settings reads stay parse-with-fallback. - AOT/trim intentionally OFF — reflection-based code is fine. ## Git -- Never amend commits (`git commit --amend`) — always create a new commit, unless explicitly asked to - amend in the moment. +- Never amend commits (`git commit --amend`) — always create a new commit, unless explicitly asked + to amend in the moment. - **Never commit on your own** — leave changes in the working tree and let the user review; commit only when explicitly asked in that moment (a general "commit when done" in a plan does not carry over to later work). diff --git a/notes/provider-claude.md b/notes/provider-claude.md new file mode 100644 index 0000000..da2c250 --- /dev/null +++ b/notes/provider-claude.md @@ -0,0 +1,36 @@ +# Claude data source (Data/Claude/) + +Read this before changing anything in `Data/Claude/`. Deliberately isolated: everything +endpoint-specific stays behind `IAgentUsageProvider` here, so a replacement source is a drop-in +sibling. + +## Endpoint + +`GET https://api.anthropic.com/api/oauth/usage`, `Authorization: Bearer ` from +`%USERPROFILE%\.claude\.credentials.json`, `anthropic-beta: oauth-2025-04-20`, honest UA +`agents-panel/` (**decision: no client spoofing**). ⚠️ **Undocumented endpoint, ToS-gray** for +published tools; it can change or vanish. + +## Response shape + +Two generations of shape: legacy named windows (`five_hour`, `seven_day`, `seven_day_opus/sonnet` — +the per-model ones are null now) AND the newer generic `limits` array (`kind`: +`session`/`weekly_all`/`weekly_scoped` + `scope.model.display_name`). **Map from `limits` when +present**; legacy is the fallback. Verified live 2026-08. + +## Credentials + +- **Never log the token**; read credentials at call time only. +- **NO token refresh** — undocumented rotation could log the user out of Claude Code. Expired ⇒ + `TokenExpired` status. + +## Rate limits + +Endpoint rate-limits hard: poll floor **3 min**, clamped in `UsageSettingsManager.RefreshMinutes`'s +getter (not just the placeholder). `HttpRetry` honors Retry-After, 3 attempts, 8s bail. + +## Token log reader + +`ClaudeTokenLogReader.cs` tails `%USERPROFILE%\.claude\projects\*\*.jsonl` with per-message-id +dedupe (streaming rewrites repeat message ids; last wins). Incremental tail mechanics: see +`notes/token-stats.md`. diff --git a/notes/provider-codex.md b/notes/provider-codex.md new file mode 100644 index 0000000..5c9e252 --- /dev/null +++ b/notes/provider-codex.md @@ -0,0 +1,43 @@ +# Codex data source (Data/Codex/) + +Read this before changing anything in `Data/Codex/`. Deliberately isolated, mirrors `Data/Claude/`. + +## Endpoint + +`GET https://chatgpt.com/backend-api/wham/usage`, `Authorization: Bearer ` + +`ChatGPT-Account-Id: ` from `%CODEX_HOME%\auth.json` (default `%USERPROFILE%\.codex`), +honest UA. Same ⚠️ **undocumented, ToS-gray** tier as the Claude endpoint (paths/fields/plan_type +values have all churned across 2025–2026). Reference implementations: openai/codex +`codex-rs/backend-client` + steipete/CodexBar. + +## Response shape + +- **Window shape varies BY PLAN** (why `UsageWindowKind` picks from `limit_window_seconds`, never + the primary/secondary slot): Plus/Pro report 5h primary + weekly secondary; the Go plan reports + ONE 30-day primary (→ `Month` kind, "Mo" dock label) and a null secondary. Verified live 2026-08 + on Go. +- Reset time: `reset_at` (epoch seconds) when present, else `now + reset_after_seconds` — both + generations exist in the wild. + +## Credentials + +- **Never log the token**; read credentials at call time. +- **NO token refresh** — a harder no than Claude: Codex **rotates refresh tokens**, so a + third-party refresh that doesn't write back perfectly logs the user out of Codex itself. + Expired ⇒ `TokenExpired`; Codex's own use refreshes it (~8-day cadence). +- Plan type + expiry come from the access token's JWT claims (decoded unvalidated, local trust); + `id_token`/`refresh_token` are deliberately never deserialized. + +## Dev-machine quirk + +On this dev machine Codex is the MSIX desktop app (`OpenAI.Codex`) — no `codex` CLI on PATH and +WindowsApps ACLs block spawning its bundled codex.exe, which is why the `codex app-server` JSON-RPC +route was rejected in favor of direct HTTP. + +## Token log reader + +`CodexTokenLogReader.cs` tails `%CODEX_HOME%\sessions\yyyy\mm\dd\rollout-*.jsonl` `token_count` +events. ⚠️ `total_token_usage` is CUMULATIVE per session, so it DIFFS consecutive events into +timestamped deltas (never sums; rebaselines on a counter reset), and Codex's `input_tokens` +INCLUDES the cached subset, so fresh input = input − cached. Incremental tail mechanics: see +`notes/token-stats.md`. diff --git a/notes/provider-copilot.md b/notes/provider-copilot.md new file mode 100644 index 0000000..179039a --- /dev/null +++ b/notes/provider-copilot.md @@ -0,0 +1,57 @@ +# Copilot data source (Data/Copilot/) + +Read this before changing anything in `Data/Copilot/`. Deliberately isolated, mirrors `Data/Codex/`. + +## Endpoint + +`GET https://api.github.com/copilot_internal/user`, `Authorization: Bearer `, honest UA — +deliberately NO Editor-Version masquerade (the endpoint answers plain user agents; Oh My Posh's +copilot segment ships the same call). Same ⚠️ **undocumented, ToS-gray** tier as the other +providers (GitHub has never sanctioned third-party use of `copilot_internal`; the 2026-06 move to +AI-credit billing already reshaped the response once). Reference implementations: Oh My Posh +`copilot` segment, steipete/CodexBar, ericc-ch/copilot-api. + +## Response shape + +**Varies BY PLAN and by billing generation** — `MapWindows` branches, in order: + +1. `quota_snapshots` (all current plans; verified live 2026-08 on Free: chat 200 + completions + 2000 metered, premium_interactions zeroed) — skip buckets with `unlimited`, `has_quota=false`, + or `entitlement<=0` (**a zeroed bucket's `percent_remaining: 0` is "not metered", NOT "100% + used"**; credit-billed seats zero out all three — CodexBar#1258). +2. Legacy free-tier `limited_user_quotas`/`monthly_quotas` maps. +3. Top-level `credits_used` → an ExtraUsage "n used" row (cap/units unreported — no fabricated + percentage). + +All windows are `Month` kind (one shared monthly reset: `quota_reset_date_utc` → +`quota_reset_date` → `limited_user_reset_date`); `Qualifier` ("Chat"/"Code") tells multiple monthly +buckets apart — `UiUsage` renders a qualified Month like a qualified ModelWeek. + +## Credential discovery chain + +`CopilotCredentialsReader`, first usable wins: + +1. Settings PAT override (`UsageSettingsManager.CopilotToken` — plain text in + agentspanel.settings.json). +2. Env `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`. +3. Windows Credential Manager — one full enumerate matching by target-name shape, **observed + live**: Copilot CLI = `.github-copilot-app` — a SUFFIX match, CredEnumerate wildcards are + prefix-only — and gh secure storage = `gh:github.com:`, go-keyring `service:user` naming, + so exact `CredRead` misses it; blob may be raw token or a JSON wrapper — both handled. +4. `%APPDATA%\GitHub CLI\hosts.yml` (hand-parsed, GH_CONFIG_DIR honored; empty of tokens when + secure storage is in use). +5. `%LOCALAPPDATA%\github-copilot\apps.json`/`hosts.json`. + +Classic `ghp_` PATs are skipped everywhere (endpoint rejects them); `gho_`/`ghu_` OAuth tokens and +fine-grained PATs (*Copilot Requests: Read*) work. + +## Credentials rules + +- **Never log the token** (log only the source label); read at call time. +- **NO token refresh** — these are other apps' long-lived OAuth tokens. +- GitHub tokens are opaque (no JWT claims), so there's no local expiry check: a dead/rejected token + surfaces as 401/403 ⇒ `TokenExpired`. + +## Token stats + +Copilot has no local logs; `TokenStats` stays null. diff --git a/notes/provider-opencode.md b/notes/provider-opencode.md new file mode 100644 index 0000000..2accc6a --- /dev/null +++ b/notes/provider-opencode.md @@ -0,0 +1,51 @@ +# opencode data source (Data/OpenCode/) — LOCAL-ONLY, no HTTP at all + +Read this before changing anything in `Data/OpenCode/`. + +## Why local-only (the deliberate v1 shape) + +**opencode has no balance/usage API**: `/zen/v1/balance` and `/zen/v1/usage` 404 (probed live +2026-08-15); upstream requests anomalyco/opencode **#10448** (Zen balance) + **#16017** (Go plan +windows) both open. The only remote source is the opencode.ai dashboard's cookie-authenticated +`_server?id=` RPC (what steipete/CodexBar scrapes on macOS) — **rejected**: Windows +browser-cookie decryption is invasive, and the function ids are frontend-build hashes that churn +every deploy. When the balance endpoint ships, the HTTP fetch drops into `OpenCodeUsageProvider` +as the quota path (auth.json API key, read at call time, never logged, no refresh) — same seam as +the other providers. + +## Local data dir + +xdg-style even on Windows: `XDG_DATA_HOME` ?? `%USERPROFILE%\.local\share`, + `\opencode`: + +- `auth.json` (`OpenCodeCredentialsReader`) — a map `providerID → {type: api|oauth|wellknown, …}`; + an `"opencode"` entry (Zen login = `type: "api"`) is the ONLY thing checked, purely for the + NotConfigured/Ok pivot. **v1 never extracts the key** — there's nothing to spend it on. +- `opencode.db` (`OpenCodeUsageReader`, Microsoft.Data.Sqlite) — WAL-mode SQLite. The `message` + table's assistant rows carry `data` JSON with `providerID`/`cost` (USD)/`tokens{input, output, + reasoning, cache{read,write}}` and `time_created` in epoch **milliseconds** (verified live + 2026-08). One windowed query per poll (stateless — no tailing), `json_extract` in SQL so message + CONTENT never leaves the database. ⚠️ The `session` table's counters are cumulative-per-session — + can't be time-windowed; don't "simplify" to them. + +## What it reports + +- One `ExtraUsage` window = rolling-24h **gateway spend** (`Σ cost WHERE providerID = 'opencode'` — + Zen credits / Go balance-fallback, the money that actually leaves the account; + Go-subscription-covered messages record cost 0 locally so the sum stays honest) with + `Qualifier "24h"` + `Unit "USD"` (renders "$0.13 used" / dock "24h $0.13" — `Unit` is what + separates verified dollars from Copilot's unit-unknown bare "n used"). +- TokenStats over ALL opencode messages (reasoning folds into output). +- No HTTP ⇒ status is only ever NotConfigured (no auth.json entry) or Ok; a failed DB read degrades + to an empty-Ok snapshot (quiet absence). + +## SQLite read strategy + +`Mode=ReadOnly; Pooling=False` (no handle survives the poll), `CommandTimeout=2` (= busy timeout). +Read-only WAL access works mid-session (live `-shm`) and after clean exit; the crash-orphaned-wal +edge falls back to copying db+wal+shm to temp and querying the copy. Any failure → null this poll, +retried next. + +## Naming + +DisplayName/Id are `opencode` (lowercase brand; names the AGENT, not a billing product — Go and +Zen are billing modes of the same account, so a "Zen" row would age badly). diff --git a/notes/token-stats.md b/notes/token-stats.md new file mode 100644 index 0000000..a76d2a7 --- /dev/null +++ b/notes/token-stats.md @@ -0,0 +1,39 @@ +# Token stats (since 2026-08-15) + +Read this before changing any token-stats reader or its formatting. + +## Model plumbing + +`DomainTokenStats` rides `DomainUsageSnapshot.TokenStats` (nullable — absent means "provider has no +local logs / read failed", a QUIET absence, never an error surface). Readers are attached AROUND +the quota fetch so they ride every outcome incl. NotConfigured/TokenExpired. The repository merge +takes the FRESH TokenStats even under keep-last-good (local logs succeed when HTTP fails). + +## Windowing + +All readers sum a **rolling 24h** window — deliberately not calendar-day: a midnight reset zeroes +the row mid-session. + +## Readers + +- The Claude/Codex JSONL readers tail incrementally: per-file byte offset; only newline-complete + lines consumed; shrunken files reparsed from zero. +- `Data/Claude/ClaudeTokenLogReader.cs` — `.claude\projects\*\*.jsonl`, per-message-id dedupe + (streaming rewrites repeat message ids; last wins). Details: `notes/provider-claude.md`. +- `Data/Codex/CodexTokenLogReader.cs` — `token_count` events; ⚠️ diffs the CUMULATIVE + `total_token_usage` into deltas, never sums; fresh input = input − cached. Details: + `notes/provider-codex.md`. +- opencode: stateless windowed SQLite query, not a tail — `notes/provider-opencode.md`. +- Copilot has no local logs; stays null. + +## Formatting (all in `UiUsage`) + +- `TokenSummary` — "56k in · 29k out · 2.2M cache read"; in = fresh input + cache WRITES — raw + input_tokens is a misleading crumb under prompt caching. +- `TokenDockTitle`/`TokenTotalText` — "24h 2.3M" / page-row tag — the TOTAL of all four counters, + the ecosystem's glance number; any single component reads as noise at first sight. + +## Settings + +Two settings: ShowTokenStats (page row) and subordinate ShowTokenStatsInDock (dock button; +requires the first).