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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 72 additions & 119 deletions AGENTS.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion AgentsPanelExtension/AgentsPanelCommandsProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions AgentsPanelExtension/AgentsPanelExtension.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CommandPalette.Extensions" />
<!-- Read-only access to opencode's local SQLite DB (Data/OpenCode/OpenCodeUsageReader.cs).
Brings the bundled e_sqlite3 native library; fine for this package shape (self-contained,
single-file, JIT, no trimming). -->
<PackageReference Include="Microsoft.Data.Sqlite" />
<PackageReference Include="Microsoft.Windows.CsWinRT" />
<PackageReference Include="Shmuelie.WinRTServer" />
<PackageReference Include="System.Reactive" />
Expand Down
Binary file added AgentsPanelExtension/Assets/provider_opencode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions AgentsPanelExtension/Data/Claude/ApiClaudeDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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\<slug>\<session>.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;
194 changes: 194 additions & 0 deletions AgentsPanelExtension/Data/Claude/ClaudeTokenLogReader.cs
Original file line number Diff line number Diff line change
@@ -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\<slug>\<session>.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<string, MessageUsage> Messages = new(StringComparer.Ordinal);
}

private readonly record struct MessageUsage(
DateTimeOffset Timestamp, long Input, long Output, long CacheRead, long CacheWrite);

private readonly Dictionary<string, FileState> _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<byte> line, Dictionary<string, MessageUsage> 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<string>? 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);
}
}
12 changes: 12 additions & 0 deletions AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
// 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";
Expand All @@ -43,6 +47,14 @@
public bool IsAvailable => true;

public async Task<DomainUsageSnapshot> 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<DomainUsageSnapshot> FetchQuotaAsync(CancellationToken ct)
{
// Read credentials at call time — a sign-in/sign-out applies on the next poll, no reload.
var credentials = ClaudeCredentialsReader.Read();
Expand Down Expand Up @@ -119,7 +131,7 @@
return client;
}

private static IReadOnlyList<DomainUsageWindow> MapWindows(ApiClaudeUsageDto dto)

Check warning on line 134 in AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs

View workflow job for this annotation

GitHub Actions / build

Change return type of method 'MapWindows' from 'System.Collections.Generic.IReadOnlyList<AgentsPanelExtension.DomainUsageWindow>' to 'System.Collections.Generic.List<AgentsPanelExtension.DomainUsageWindow>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)

Check warning on line 134 in AgentsPanelExtension/Data/Claude/ClaudeUsageProvider.cs

View workflow job for this annotation

GitHub Actions / build

Change return type of method 'MapWindows' from 'System.Collections.Generic.IReadOnlyList<AgentsPanelExtension.DomainUsageWindow>' to 'System.Collections.Generic.List<AgentsPanelExtension.DomainUsageWindow>' for improved performance (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1859)
{
var windows = new List<DomainUsageWindow>(5);

Expand Down
27 changes: 27 additions & 0 deletions AgentsPanelExtension/Data/Codex/ApiCodexDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading