mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
* Gate Claude OAuth refresh attempts on terminal failures
Anthropic returns invalid_grant (HTTP 400) when the user's refresh token has
been revoked or rotated, typically after they re-ran claude login on another
device. The previous code rethrew the raw error every refresh cycle, leaving
the Plan UI stuck on a Swift error string and pummeling Anthropic's token
endpoint forever.
The new SubscriptionRefreshGate captures a fingerprint of
~/.claude/.credentials.json on terminal failure and stops trying until that
fingerprint changes (the user re-logs-in). Transient 5xx/network failures
get exponential backoff capped at 6 hours.
Two new SubscriptionError cases let the UI distinguish "user must reconnect"
from "Anthropic is flaky right now" and show a clean reconnect CTA instead
of raw HTTP guts.
* Inline live-quota progress bar inside each AgentTab chip
When a provider exposes a live quota source, the AgentTab chip grows by ~3pt
to host a thin weekly-utilization bar directly under the label. Hovering the
chip reveals a popover with all four Anthropic windows (5-hour, weekly, weekly
Opus, weekly Sonnet) plus reset countdowns. Click still switches the tab as
before.
Today only Claude has a quota source (the existing /api/oauth/usage path);
other providers' chips render unchanged. The QuotaSummary abstraction lets
us bolt on Cursor/Copilot/Codex meters in follow-up commits.
Subscription is now refreshed eagerly on the periodic loop so the bar lights
up without forcing the user to open a deep view first. The previous
SubscriptionRefreshGate keeps a dead refresh token from spamming Anthropic.
Adds two new SubscriptionLoadState cases (terminalFailure, transientFailure)
so the deep Plan view shows a "reconnect" message instead of a raw Swift
error string when the user's claude login expired.
* Replace SubscriptionClient with credential-store + service architecture
The previous SubscriptionClient never persisted refreshed access tokens, so
every 30s tick read the expired token from Keychain, refreshed it (1 call),
fetched usage with the new token (2nd call), and threw the new token away —
3 API calls per cycle, which burned through Anthropic's per-account rate
budget and produced the 429s and `invalid_grant` loops users were seeing.
The replacement mirrors CodexBar's proven pattern:
- ClaudeCredentialStore owns the credential lifecycle. Bootstrap is strictly
user-initiated (Connect button in the Plan tab); the menubar does not touch
Claude's keychain at startup. After bootstrap, refreshed tokens — including
rotated refresh tokens — are persisted to a local cache file under
~/Library/Application Support/CodeBurn (mode 0600). Using a file instead of
our own keychain item means rebuild signature changes don't trigger a
startup keychain prompt; the only prompt the user ever sees is the one for
Claude Code-credentials on Connect.
- ClaudeUsageFetcher (folded into the service) is a pure /api/oauth/usage
call with one allowed 401-recovery roundtrip. 429s record an explicit
backoff window honouring Retry-After.
- ClaudeSubscriptionService orchestrates bootstrap / refresh / disconnect,
applies the 429 backoff, and surfaces terminal vs transient failures so
the UI can show the right CTA.
- Reading Claude's keychain now tries the entry keyed by NSUserName() first
and falls back to the unscoped query, so users who re-ran /login and ended
up with two Claude Code-credentials items pick up the fresh one. This was
the actual cause of "I logged in but the menubar still shows stale data".
User-facing additions:
- A proper Settings window (right-click → Settings…) with General / Claude /
About tabs. Provider quota cadence is configurable (Manual / 1m / 2m / 5m /
15m). New providers plug in as additional tabs.
- Plan tab: notBootstrapped → "Connect Claude subscription" CTA;
terminalFailure → "Reconnect Claude" with the correct /login instruction
for Claude Code 2.1; transientFailure preserves the last loaded view with
a retrying badge.
- AgentTab quota bar slot is always reserved so chip height doesn't jitter
when the user connects for the first time. Hover popover has 250ms enter
/ 150ms exit debounce so swiping across chips doesn't pop a popover for
every chip touched.
- Disconnect requires confirmation, clears capacityEstimates and the
subscription snapshot store so a reconnect under a different account
doesn't surface "Based on last cycle" projections from the old account.
Validator findings applied: cadence anchor only updates on successful
refresh (not every attempt), refresh-token rotation persists in memory
before keychain write so a write failure doesn't lock the user out, server
error bodies are sanitized (token redaction + 240-char cap) before they
reach the UI or NSLog, and Refresh Now refreshes both the menubar payload
and quota.
* Add Codex live quota + multi-provider warning, with validator fixes
CodexCredentialStore reads ~/.codex/auth.json (ChatGPT-mode only) on
user-initiated Connect, caches under Application Support like Claude.
CodexSubscriptionService hits chatgpt.com/backend-api/wham/usage with
the bearer token + ChatGPT-Account-Id header, parses primary/secondary
windows, additional per-model rate limits (e.g. GPT-5.3-Codex-Spark),
and credits balance with a Double-or-String fallback.
Plan-tier enum captures the full ChatGPT plan list including prolite,
free_workspace, education, quorum, k12, plus an unknown(String) case
that preserves the raw plan name when OpenAI ships a tier we haven't
mapped yet.
Multi-provider warning system:
- Menubar flame tints from neutral to yellow (70%) → orange (90%) →
red (100%) based on the worst-affected connected provider's worst
window. Uses NSImage.SymbolConfiguration palette colors.
- Popover header gains a warning row when any provider is at 70%+.
"Claude 79% of quota used", "Claude 79% · Codex 92%", or
"Claude over limit (105%)" when severity hits .danger.
- Hover popover gains a plan-name badge in the top-right corner so
users know which subscription is feeding the bar.
- Codex chip surfaces the credits balance and any non-zero per-model
additional rate limits as footer rows.
Validator fixes applied in the same commit:
- Provider-specific reconnect / disconnected copy in QuotaDetailPopover
(was hardcoded to Claude).
- Generation-token guard on refreshSubscriptionReportingSuccess and
refreshCodexReportingSuccess so a Disconnect during an in-flight
fetch can't resume after the await and re-populate the cleared state.
- Codex codexQuotaSummary promotes secondary to primary when only one
window is returned, so free / guest tiers don't render an empty bar.
- Memory-cache TTL is now actually consulted in currentRecord (the
isFresh check was dead code, leaving cached records valid forever).
- sanitizeForUI now redacts OpenAI sk-* keys, JWT tokens, and Bearer
headers in addition to Claude sk-ant-*.
- Removed diagnostic NSLog that wrote raw chatgpt.com response bodies
to the unified log.
- Codex Connect / Reconnect copy in Settings explains the auth.json
prerequisite and the API-key vs ChatGPT-mode distinction.
- Disconnect dialogs now state explicitly that the auth.json /
credentials keychain entry is left untouched.
- Plan badge in the popover gets line-limit + truncation + max-width
so a long unknown plan name can't overflow the row.
- Renamed shadowing `let max` to `let worst` in aggregateQuotaStatus.
* Add Codex Plan tab + size plan badge to content
The Plan tab is now visible when the Codex chip is selected, mirroring
the Claude tab's deep view. CodexPlanInsight renders the user's plan
tier ("Pro Lite", "Plus", etc.), the primary and secondary rate-limit
windows with reset countdowns, and any non-zero per-model additional
limits (e.g. GPT-5.3-Codex-Spark) so power users see them.
The "On pace at reset" projection that Claude's Plan view shows is not
included here — that math feeds from local Claude per-message spend
extrapolated against API quota windows, and our local Codex spend is
not a 1:1 signal for the ChatGPT-subscription rate windows reported by
wham/usage. Wiring a Codex extrapolator is a follow-up.
Drop the maxWidth=90 frame on the plan badge in the hover popover. It
was stretching short labels like "Pro Lite" to fill the full 90pt slot;
fixedSize makes the badge hug the text. Plan names are bounded short
strings, so truncation is a non-issue in practice.
362 lines
14 KiB
Swift
362 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct AgentTabStrip: View {
|
|
@Environment(AppStore.self) private var store
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 5) {
|
|
ForEach(visibleFilters) { filter in
|
|
Button {
|
|
store.switchTo(provider: filter)
|
|
} label: {
|
|
AgentTab(
|
|
filter: filter,
|
|
cost: cost(for: filter),
|
|
isActive: store.selectedProvider == filter,
|
|
quota: store.quotaSummary(for: filter)
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.top, 8)
|
|
.padding(.bottom, 4)
|
|
}
|
|
}
|
|
|
|
private var todayAll: MenubarPayload {
|
|
store.todayPayload ?? store.payload
|
|
}
|
|
|
|
private var periodAll: MenubarPayload {
|
|
store.periodAllPayload ?? store.payload
|
|
}
|
|
|
|
private var visibleFilters: [ProviderFilter] {
|
|
let detectedKeys = Set(
|
|
todayAll.current.providers.keys.map { $0.lowercased() }
|
|
)
|
|
return ProviderFilter.allCases.filter { filter in
|
|
if filter == .all { return true }
|
|
return filter.providerKeys.contains(where: detectedKeys.contains)
|
|
}
|
|
}
|
|
|
|
private func cost(for filter: ProviderFilter) -> Double? {
|
|
let data = periodAll
|
|
if filter == .all { return data.current.cost }
|
|
if filter == store.selectedProvider, store.hasCachedData {
|
|
return store.payload.current.cost
|
|
}
|
|
let providers = Dictionary(
|
|
data.current.providers.map { ($0.key.lowercased(), $0.value) },
|
|
uniquingKeysWith: +
|
|
)
|
|
return filter.providerKeys.reduce(0.0) { sum, key in
|
|
sum + (providers[key] ?? 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AgentTab: View {
|
|
let filter: ProviderFilter
|
|
let cost: Double?
|
|
let isActive: Bool
|
|
let quota: QuotaSummary?
|
|
|
|
@State private var hoverPopoverShown = false
|
|
@State private var hoverEnterTask: DispatchWorkItem?
|
|
@State private var hoverExitTask: DispatchWorkItem?
|
|
|
|
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
|
|
/// label, even when not yet connected. Driven by which providers we
|
|
/// actually implement live-quota fetching for in AppStore.quotaSummary.
|
|
static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool {
|
|
switch filter {
|
|
case .claude, .codex: return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 3) {
|
|
HStack(spacing: 5) {
|
|
Text(filter.rawValue)
|
|
.font(.system(size: 11.5, weight: .medium))
|
|
.tracking(-0.05)
|
|
if let cost, cost > 0 {
|
|
Text(cost.asCompactCurrency())
|
|
.font(.codeMono(size: 10.5, weight: .medium))
|
|
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
|
|
.tracking(-0.2)
|
|
}
|
|
}
|
|
// Reserve the bar slot only for providers whose quota source we
|
|
// implement (Claude, Codex). Providers that will never have a bar
|
|
// (All / Cursor / Droid / Gemini / Copilot) skip the slot entirely
|
|
// so the text centers naturally and the chip stays compact.
|
|
// Reserving the slot for Claude/Codex prevents the strip from
|
|
// jumping by 6pt the moment the user clicks Connect.
|
|
if Self.providerSupportsQuota(filter) {
|
|
AgentTabQuotaBar(quota: quota, isActive: isActive)
|
|
.frame(height: 3)
|
|
.opacity(quota == nil ? 0 : 1)
|
|
}
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(isActive ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.08)))
|
|
)
|
|
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
|
.contentShape(Rectangle())
|
|
.onHover { hovering in
|
|
// Debounce: 250ms enter so swiping across chips doesn't pop a
|
|
// popover for every chip touched, and 150ms exit so cursor travel
|
|
// between chip and popover doesn't dismiss prematurely.
|
|
hoverEnterTask?.cancel()
|
|
hoverExitTask?.cancel()
|
|
if hovering, quota != nil {
|
|
let task = DispatchWorkItem { hoverPopoverShown = true }
|
|
hoverEnterTask = task
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
|
|
} else {
|
|
let task = DispatchWorkItem { hoverPopoverShown = false }
|
|
hoverExitTask = task
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
|
|
}
|
|
}
|
|
.popover(isPresented: $hoverPopoverShown) {
|
|
if let quota {
|
|
QuotaDetailPopover(quota: quota)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota
|
|
/// source. Width matches the chip; color shifts green → amber → red at 70% / 90%.
|
|
private struct AgentTabQuotaBar: View {
|
|
let quota: QuotaSummary?
|
|
let isActive: Bool
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(trackColor)
|
|
if let percent = filledFraction {
|
|
Capsule()
|
|
.fill(barColor)
|
|
.frame(width: max(2, geo.size.width * CGFloat(percent)))
|
|
.animation(.easeOut(duration: 0.25), value: percent)
|
|
}
|
|
if case .terminalFailure = quota?.connection {
|
|
// Hatched/red strip to telegraph "broken; reconnect needed".
|
|
Capsule()
|
|
.fill(Color.red.opacity(0.7))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var filledFraction: Double? {
|
|
guard let pct = quota?.primary?.percent else { return nil }
|
|
return min(max(pct, 0), 1)
|
|
}
|
|
|
|
private var barColor: Color {
|
|
guard let pct = quota?.primary?.percent else { return .clear }
|
|
switch QuotaSummary.severity(for: pct) {
|
|
case .normal: return isActive ? Color.white : Color.green.opacity(0.85)
|
|
case .warning: return Color.yellow
|
|
case .critical: return Color.orange
|
|
case .danger: return Color.red
|
|
}
|
|
}
|
|
|
|
private var trackColor: Color {
|
|
isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18)
|
|
}
|
|
}
|
|
|
|
private struct QuotaDetailPopover: View {
|
|
let quota: QuotaSummary
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
switch quota.connection {
|
|
case .terminalFailure(let reason):
|
|
terminalFailureCard(reason: reason)
|
|
case .disconnected:
|
|
Text(disconnectedMessage)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
case .loading where quota.details.isEmpty:
|
|
Text("Loading…")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
default:
|
|
rowsCard
|
|
}
|
|
}
|
|
.padding(12)
|
|
.frame(width: 260)
|
|
}
|
|
|
|
private var disconnectedMessage: String {
|
|
switch quota.providerFilter {
|
|
case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota."
|
|
case .claude: return "Sign in to Claude Code to track quota."
|
|
default: return "Sign in to track quota."
|
|
}
|
|
}
|
|
|
|
private var rowsCard: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 6) {
|
|
Text("\(quota.providerFilter.rawValue) usage")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
if case .stale = quota.connection {
|
|
Text("stale")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.secondary)
|
|
} else if case .transientFailure = quota.connection {
|
|
Text("retrying")
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(.orange)
|
|
}
|
|
Spacer()
|
|
if let plan = quota.planLabel, !plan.isEmpty {
|
|
Text(plan)
|
|
.font(.system(size: 9.5, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(Color.secondary.opacity(0.12))
|
|
)
|
|
// Size to content. Plan names are bounded short strings
|
|
// ("Max 20x", "Pro Lite", "Free Workspace"); a forced
|
|
// maxWidth was making short labels look stretched.
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
}
|
|
ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in
|
|
QuotaDetailRow(window: w)
|
|
}
|
|
if !quota.footerLines.isEmpty {
|
|
Divider()
|
|
.padding(.top, 2)
|
|
ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in
|
|
Text(line)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func terminalFailureCard(reason: String?) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(reconnectTitle)
|
|
.font(.system(size: 11.5, weight: .semibold))
|
|
.foregroundStyle(.red)
|
|
Text(reason ?? defaultReconnectReason)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
Text(reconnectInstruction)
|
|
.font(.system(size: 10.5))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var reconnectTitle: String {
|
|
switch quota.providerFilter {
|
|
case .codex: return "Reconnect Codex"
|
|
default: return "Reconnect Claude"
|
|
}
|
|
}
|
|
|
|
private var defaultReconnectReason: String {
|
|
switch quota.providerFilter {
|
|
case .codex: return "Refresh token rejected by OpenAI."
|
|
default: return "Refresh token rejected by Anthropic."
|
|
}
|
|
}
|
|
|
|
private var reconnectInstruction: String {
|
|
switch quota.providerFilter {
|
|
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
|
|
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct QuotaDetailRow: View {
|
|
let window: QuotaSummary.Window
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
Text(window.label)
|
|
.font(.system(size: 10.5))
|
|
.frame(width: 92, alignment: .leading)
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule().fill(Color.secondary.opacity(0.18))
|
|
Capsule()
|
|
.fill(barColor)
|
|
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
Text(window.percentLabel)
|
|
.font(.codeMono(size: 10.5, weight: .medium))
|
|
.frame(width: 36, alignment: .trailing)
|
|
if !window.resetsInLabel.isEmpty {
|
|
Text(window.resetsInLabel)
|
|
.font(.codeMono(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 50, alignment: .trailing)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var barColor: Color {
|
|
switch QuotaSummary.severity(for: window.percent) {
|
|
case .normal: return Color.green.opacity(0.85)
|
|
case .warning: return Color.yellow
|
|
case .critical: return Color.orange
|
|
case .danger: return Color.red
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ProviderFilter {
|
|
@MainActor var color: Color {
|
|
switch self {
|
|
case .all: return Theme.brandAccent
|
|
case .claude: return Theme.categoricalClaude
|
|
case .codex: return Theme.categoricalCodex
|
|
case .cursor: return Theme.categoricalCursor
|
|
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
|
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
|
|
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
|
|
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
|
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
|
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
|
|
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
|
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
|
|
case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0)
|
|
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
|
|
case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
|
|
}
|
|
}
|
|
}
|