From 9655f99516065c76e5ace7c7a398440a6eb29fdb Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 15:29:18 -0400 Subject: [PATCH 01/34] Add native preview shell --- Package.swift | 8 + Sources/ContextPanelCore/UsageLimit.swift | 199 ++++- .../ContextPanelPreviewApp.swift | 830 ++++++++++++++++++ .../ContextPanelPreview/SampleUsageData.swift | 94 ++ .../UsageLimitTests.swift | 47 + 5 files changed, 1167 insertions(+), 11 deletions(-) create mode 100644 Sources/ContextPanelPreview/ContextPanelPreviewApp.swift create mode 100644 Sources/ContextPanelPreview/SampleUsageData.swift diff --git a/Package.swift b/Package.swift index c9da4ef..f771036 100644 --- a/Package.swift +++ b/Package.swift @@ -11,10 +11,18 @@ let package = Package( .library( name: "ContextPanelCore", targets: ["ContextPanelCore"] + ), + .executable( + name: "ContextPanelPreview", + targets: ["ContextPanelPreview"] ) ], targets: [ .target(name: "ContextPanelCore"), + .executableTarget( + name: "ContextPanelPreview", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/Sources/ContextPanelCore/UsageLimit.swift b/Sources/ContextPanelCore/UsageLimit.swift index a59c634..027bdee 100644 --- a/Sources/ContextPanelCore/UsageLimit.swift +++ b/Sources/ContextPanelCore/UsageLimit.swift @@ -1,34 +1,211 @@ import Foundation -public enum Provider: String, CaseIterable, Codable, Equatable, Sendable { +public enum Provider: String, CaseIterable, Codable, Equatable, Identifiable, Sendable { case openAI = "openai" case anthropic case google + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .openAI: + "OpenAI" + case .anthropic: + "Anthropic" + case .google: + "Google" + } + } + + public var shortName: String { + switch self { + case .openAI: + "OAI" + case .anthropic: + "ANT" + case .google: + "GOO" + } + } } -public struct UsageLimit: Codable, Equatable, Sendable { +public enum UsageStatus: String, Codable, Equatable, Sendable { + case healthy + case close + case limited + case stale + case unknown + case failure + case loading +} + +public enum UsageConfidence: String, Codable, Equatable, Sendable { + case official + case observed + case manual + case estimated + case unknown +} + +public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable { + public let id: String public let provider: Provider + public let name: String + public let isEnabled: Bool + + public init(id: String, provider: Provider, name: String, isEnabled: Bool = true) { + self.id = id + self.provider = provider + self.name = name + self.isEnabled = isEnabled + } +} + +public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let provider: Provider + public let accountID: String + public let accountName: String public let label: String - public let used: Int - public let limit: Int + public let used: Int? + public let limit: Int? public let resetsAt: Date? + public let lastUpdatedAt: Date? + public let confidence: UsageConfidence + public let statusOverride: UsageStatus? + public let note: String? - public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) { - precondition(used >= 0, "used must not be negative") - precondition(limit > 0, "limit must be positive") + public init( + id: String? = nil, + provider: Provider, + accountID: String, + accountName: String, + label: String, + used: Int?, + limit: Int?, + resetsAt: Date? = nil, + lastUpdatedAt: Date? = nil, + confidence: UsageConfidence = .official, + statusOverride: UsageStatus? = nil, + note: String? = nil + ) { + if let used { + precondition(used >= 0, "used must not be negative") + } + if let limit { + precondition(limit > 0, "limit must be positive") + } + self.id = id ?? "\(provider.rawValue):\(accountID):\(label)" self.provider = provider + self.accountID = accountID + self.accountName = accountName self.label = label self.used = used self.limit = limit self.resetsAt = resetsAt + self.lastUpdatedAt = lastUpdatedAt + self.confidence = confidence + self.statusOverride = statusOverride + self.note = note + } + + public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) { + self.init( + provider: provider, + accountID: "default", + accountName: provider.displayName, + label: label, + used: used, + limit: limit, + resetsAt: resetsAt, + confidence: .official + ) } - public var remaining: Int { - max(limit - used, 0) + public var remaining: Int? { + guard let used, let limit else { return nil } + return max(limit - used, 0) } - public var usageRatio: Double { - min(Double(used) / Double(limit), 1) + public var usageRatio: Double? { + guard let used, let limit else { return nil } + return min(Double(used) / Double(limit), 1) + } + + public var status: UsageStatus { + if let statusOverride { + return statusOverride + } + guard let ratio = usageRatio else { + return .unknown + } + if ratio >= 1 { + return .limited + } + if ratio >= 0.8 { + return .close + } + return .healthy + } +} + +public struct UsageSnapshot: Codable, Equatable, Sendable { + public let generatedAt: Date + public let limits: [UsageLimit] + + public init(generatedAt: Date, limits: [UsageLimit]) { + self.generatedAt = generatedAt + self.limits = limits + } + + public var mostConstrainedLimits: [UsageLimit] { + limits.sorted { lhs, rhs in + let lhsScore = lhs.constraintScore + let rhsScore = rhs.constraintScore + if lhsScore != rhsScore { + return lhsScore > rhsScore + } + return (lhs.usageRatio ?? -1) > (rhs.usageRatio ?? -1) + } + } + + public var aggregateCapacityRatio: Double { + let ratios = limits.compactMap(\.usageRatio) + guard !ratios.isEmpty else { return 0 } + let averageUsed = ratios.reduce(0, +) / Double(ratios.count) + return max(1 - averageUsed, 0) + } + + public var aggregateStatus: UsageStatus { + mostConstrainedLimits.first?.status ?? .unknown + } +} + +extension UsageStatus { + fileprivate var sortRank: Double { + switch self { + case .limited: + 2 + case .failure: + 1.3 + case .close: + 1 + case .stale: + 0.4 + case .unknown: + 0.3 + case .loading: + 0.2 + case .healthy: + 0 + } + } +} + +extension UsageLimit { + fileprivate var constraintScore: Double { + status.sortRank + (usageRatio ?? 0) } } diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift new file mode 100644 index 0000000..d34f69a --- /dev/null +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -0,0 +1,830 @@ +import ContextPanelCore +import SwiftUI + +@main +struct ContextPanelPreviewApp: App { + var body: some Scene { + WindowGroup { + AppRoot(snapshot: SampleUsageData.snapshot) + .frame(minWidth: 1280, minHeight: 720) + } + } +} + +struct AppRoot: View { + let snapshot: UsageSnapshot + @State private var selectedID: UsageLimit.ID? + + private var selectedLimit: UsageLimit { + if let selectedID, let match = snapshot.limits.first(where: { $0.id == selectedID }) { + return match + } + return snapshot.mostConstrainedLimits[0] + } + + var body: some View { + HStack(spacing: 0) { + AccountsSidebar(snapshot: snapshot, selectedID: $selectedID) + .frame(width: 210) + Divider() + InstrumentDashboard(snapshot: snapshot) + .frame(minWidth: 740) + Divider() + AccountDetail(limit: selectedLimit, generatedAt: snapshot.generatedAt) + .frame(width: 320) + } + .tint(CPTheme.accent) + .onAppear { + selectedID = selectedID ?? snapshot.mostConstrainedLimits.first?.id + } + } +} + +struct AccountsSidebar: View { + let snapshot: UsageSnapshot + @Binding var selectedID: UsageLimit.ID? + + var body: some View { + List(selection: $selectedID) { + Section("Accounts") { + ForEach(Provider.allCases) { provider in + let limits = snapshot.limits.filter { $0.provider == provider } + if !limits.isEmpty { + ProviderSidebarRow(provider: provider, limits: limits) + ForEach(limits) { limit in + SidebarLimitRow(limit: limit) + .tag(limit.id) + } + } + } + } + } + .navigationTitle("Context Panel") + .safeAreaInset(edge: .bottom) { + Button { + } label: { + Label("Add Account", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(12) + } + } +} + +struct ProviderSidebarRow: View { + let provider: Provider + let limits: [UsageLimit] + + var body: some View { + HStack(spacing: 8) { + ProviderGlyph(provider: provider, size: 12) + Text(provider.displayName) + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(CPTheme.secondaryText) + Spacer() + Text("\(limits.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + } + } +} + +struct SidebarLimitRow: View { + let limit: UsageLimit + + var body: some View { + HStack(spacing: 10) { + StatusMark(status: limit.status, size: 7) + VStack(alignment: .leading, spacing: 2) { + Text(limit.accountName) + .font(.system(size: 13, weight: .medium)) + Text(limit.label) + .font(.system(size: 11)) + .foregroundStyle(CPTheme.tertiaryText) + } + Spacer() + Text(limit.compactUsageText) + .font(.system(.caption2, design: .monospaced, weight: .medium)) + .foregroundStyle(CPTheme.secondaryText) + } + .padding(.vertical, 3) + } +} + +struct InstrumentDashboard: View { + let snapshot: UsageSnapshot + + private var constrained: [UsageLimit] { + Array(snapshot.mostConstrainedLimits.prefix(4)) + } + + var body: some View { + ScrollView([.vertical, .horizontal]) { + VStack(alignment: .leading, spacing: 18) { + HeaderCard(snapshot: snapshot) + WidgetPreviewGrid(snapshot: snapshot) + SectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) accounts") + VStack(spacing: 10) { + ForEach(constrained) { limit in + AccountRow(limit: limit) + } + } + SectionHeader(title: "Provider Groups", trailing: "Last update 2m ago") + ProviderGroupGrid(snapshot: snapshot) + } + .padding(24) + .frame(minWidth: 720, alignment: .topLeading) + } + .background(CPTheme.background) + .navigationTitle("Glance") + } +} + +struct HeaderCard: View { + let snapshot: UsageSnapshot + + var body: some View { + HStack(alignment: .center, spacing: 22) { + VStack(alignment: .leading, spacing: 10) { + CPLabel("Context Panel") + Text(snapshot.headline) + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(CPTheme.primaryText) + .lineLimit(2) + Text(snapshot.subheadline) + .font(.system(size: 13)) + .foregroundStyle(CPTheme.secondaryText) + HStack(spacing: 8) { + TagLabel("SwiftUI") + TagLabel("WidgetKit") + TagLabel("Keychain-local") + } + } + Spacer(minLength: 16) + CapacityDial( + value: snapshot.aggregateCapacityRatio, + status: snapshot.aggregateStatus, + label: "\(Int(snapshot.aggregateCapacityRatio * 100))", + sublabel: "capacity", + size: 116 + ) + } + .padding(22) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 14, x: 0, y: 8) + } +} + +struct WidgetPreviewGrid: View { + let snapshot: UsageSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader(title: "Concept A · Instrument", trailing: "Native preview") + HStack(alignment: .top, spacing: 12) { + SmallWidgetPreview(snapshot: snapshot) + MediumWidgetPreview(snapshot: snapshot) + } + LargeWidgetPreview(snapshot: snapshot) + } + } +} + +struct SmallWidgetPreview: View { + let snapshot: UsageSnapshot + + var body: some View { + WidgetShell(width: 220, height: 220) { + VStack(alignment: .leading, spacing: 10) { + WidgetHeader(status: snapshot.aggregateStatus) + Spacer() + Text(snapshot.tightestUsageText) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(CPTheme.primaryText) + .lineLimit(2) + .minimumScaleFactor(0.75) + Text(snapshot.tightestSupportText) + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + .lineLimit(2) + Spacer() + ProviderMiniStatus(snapshot: snapshot) + } + } + } +} + +struct MediumWidgetPreview: View { + let snapshot: UsageSnapshot + + var body: some View { + WidgetShell(width: 460, height: 220) { + HStack(spacing: 18) { + VStack(alignment: .leading) { + WidgetHeader(status: snapshot.aggregateStatus) + Spacer() + CapacityDial( + value: snapshot.aggregateCapacityRatio, + status: snapshot.aggregateStatus, + label: "\(Int(snapshot.aggregateCapacityRatio * 100))", + sublabel: "capacity", + size: 94 + ) + VStack(alignment: .leading, spacing: 2) { + Text("Working room") + .font(.system(size: 18, weight: .semibold)) + Text("1 limited · 2 close") + .font(.system(size: 11)) + .foregroundStyle(CPTheme.tertiaryText) + } + Spacer() + Text("nearest reset · 42m") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + } + .frame(width: 150, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + SectionHeader(title: "Most Constrained", trailing: "4 accounts") + ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in + AccountRow(limit: limit, compact: true) + } + } + } + } + } +} + +struct LargeWidgetPreview: View { + let snapshot: UsageSnapshot + + var body: some View { + WidgetShell(width: 460, height: 460) { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + CPLabel("Context Panel") + Text("You're good for the afternoon.") + .font(.system(size: 25, weight: .semibold)) + .foregroundStyle(CPTheme.primaryText) + Text("Image gen on Team is the only blocker.") + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + } + Spacer() + CapacityDial( + value: snapshot.aggregateCapacityRatio, + status: snapshot.aggregateStatus, + label: "\(Int(snapshot.aggregateCapacityRatio * 100))", + sublabel: "cap", + size: 84 + ) + } + + ProviderGroupGrid(snapshot: snapshot, compact: true) + + Spacer(minLength: 0) + Divider() + HStack { + Sparkline(values: [0.72, 0.68, 0.7, 0.64, 0.62, 0.58, 0.64]) + .frame(width: 120, height: 20) + Text("24h capacity") + .font(.system(size: 10)) + .foregroundStyle(CPTheme.tertiaryText) + Spacer() + Text("next reset in 42m · upd 2m ago") + .font(.system(size: 10)) + .foregroundStyle(CPTheme.tertiaryText) + } + } + } + } +} + +struct ProviderGroupGrid: View { + let snapshot: UsageSnapshot + var compact = false + + var body: some View { + HStack(alignment: .top, spacing: 14) { + ForEach(Provider.allCases) { provider in + let limits = snapshot.limits.filter { $0.provider == provider } + if !limits.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + ProviderGlyph(provider: provider, size: 11) + Text(provider.displayName) + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + Spacer() + StatusMark(status: limits.map(\.status).worstStatus, size: 7) + } + .foregroundStyle(CPTheme.secondaryText) + Divider() + ForEach(limits.prefix(compact ? 3 : 4)) { limit in + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(limit.accountName) + .font(.system(size: compact ? 11 : 12, weight: .medium)) + .lineLimit(1) + Spacer() + Text(limit.percentText) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(CPTheme.secondaryText) + } + Text(limit.label) + .font(.system(size: 10)) + .foregroundStyle(CPTheme.tertiaryText) + .lineLimit(1) + CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + } + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + } + } +} + +struct AccountDetail: View { + let limit: UsageLimit + let generatedAt: Date + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack(spacing: 10) { + ProviderGlyph(provider: limit.provider, size: 16) + VStack(alignment: .leading, spacing: 2) { + Text(limit.accountName) + .font(.system(size: 22, weight: .semibold)) + Text("\(limit.provider.displayName) · \(limit.label)") + .font(.system(size: 13)) + .foregroundStyle(CPTheme.secondaryText) + } + Spacer() + StatusMark(status: limit.status, size: 10) + } + + CapacityDial( + value: 1 - (limit.usageRatio ?? 0), + status: limit.status, + label: limit.remaining.map(String.init) ?? "?", + sublabel: "left", + size: 140 + ) + .frame(maxWidth: .infinity) + + DetailCard(title: "Forecast") { + Text(limit.note ?? "Fast mode looks safe through reset.") + .font(.system(size: 15, weight: .medium)) + Text("Confidence: \(limit.confidence.rawValue)") + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + } + + DetailCard(title: "Normalized limit") { + DetailRow(label: "Used", value: limit.used.map(String.init) ?? "unknown") + DetailRow(label: "Limit", value: limit.limit.map(String.init) ?? "unknown") + DetailRow(label: "Remaining", value: limit.remaining.map(String.init) ?? "unknown") + DetailRow(label: "Status", value: limit.status.rawValue) + DetailRow(label: "Updated", value: "2m ago") + } + + DetailCard(title: "Refresh history") { + Sparkline(values: [0.72, 0.68, 0.70, 0.64, 0.62, 0.58, 0.64]) + .frame(height: 42) + Text("Last good snapshot preserved for stale and failure states.") + .font(.system(size: 12)) + .foregroundStyle(CPTheme.secondaryText) + } + } + .padding(22) + } + .background(CPTheme.background) + .navigationTitle("Details") + } +} + +struct DetailCard: View { + let title: String + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CPLabel(title) + content + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 10)) + } +} + +struct DetailRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .foregroundStyle(CPTheme.secondaryText) + Spacer() + Text(value) + .font(.system(.caption, design: .monospaced, weight: .medium)) + } + .font(.system(size: 13)) + } +} + +struct WidgetShell: View { + let width: CGFloat + let height: CGFloat + @ViewBuilder let content: Content + + var body: some View { + content + .padding(16) + .frame(width: width, height: height) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 20)) + .shadow(color: .black.opacity(0.05), radius: 12, x: 0, y: 6) + } +} + +struct WidgetHeader: View { + let status: UsageStatus + + var body: some View { + HStack { + CPLabel("Context Panel") + Spacer() + StatusMark(status: status, size: 9) + } + } +} + +struct ProviderMiniStatus: View { + let snapshot: UsageSnapshot + + var body: some View { + HStack(spacing: 14) { + ForEach(Provider.allCases) { provider in + let limits = snapshot.limits.filter { $0.provider == provider } + HStack(spacing: 5) { + ProviderGlyph(provider: provider, size: 10) + Text(provider.shortName) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(CPTheme.secondaryText) + } + .opacity(limits.isEmpty ? 0.35 : 1) + } + } + } +} + +struct AccountRow: View { + let limit: UsageLimit + var compact = false + + var body: some View { + HStack(spacing: 10) { + ProviderGlyph(provider: limit.provider, size: compact ? 11 : 13) + .frame(width: 16) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + Text(limit.label) + .font(.system(size: compact ? 12 : 13, weight: .medium)) + .lineLimit(1) + Text("· \(limit.accountName)") + .font(.system(size: compact ? 12 : 13)) + .foregroundStyle(CPTheme.tertiaryText) + .lineLimit(1) + Spacer() + Text(limit.compactUsageText) + .font(.system(size: compact ? 10 : 11, weight: .medium, design: .monospaced)) + .foregroundStyle(CPTheme.secondaryText) + } + HStack(spacing: 8) { + CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + Text(limit.resetText) + .font(.system(size: 10)) + .foregroundStyle(limit.status == .stale ? CPTheme.statusColor(.stale) : CPTheme.tertiaryText) + .lineLimit(1) + } + } + } + .padding(compact ? 0 : 10) + .background(compact ? Color.clear : CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: compact ? 0 : 8, style: .continuous)) + .overlay { + if !compact { + CPTheme.stroke(cornerRadius: 8) + } + } + } +} + +struct CapacityDial: View { + let value: Double + let status: UsageStatus + let label: String + let sublabel: String + var size: CGFloat = 96 + var thickness: CGFloat = 6 + + var body: some View { + ZStack { + Circle() + .stroke(CPTheme.line, lineWidth: thickness) + Circle() + .trim(from: 0, to: min(max(value, 0), 1)) + .stroke( + CPTheme.statusColor(status), + style: StrokeStyle(lineWidth: thickness, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text(label) + .font(.system(size: size > 100 ? 30 : 22, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPTheme.primaryText) + Text(sublabel) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + .textCase(.uppercase) + } + } + .frame(width: size, height: size) + } +} + +struct CapacityBar: View { + let value: Double + let status: UsageStatus + var height: CGFloat = 4 + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(CPTheme.line) + Capsule() + .fill(CPTheme.statusColor(status)) + .frame(width: proxy.size.width * min(max(value, 0), 1)) + } + } + .frame(height: height) + } +} + +struct ProviderGlyph: View { + let provider: Provider + var size: CGFloat = 12 + + var body: some View { + Group { + switch provider { + case .openAI: + RoundedRectangle(cornerRadius: 2, style: .continuous) + .stroke(CPTheme.accent, lineWidth: 1.4) + case .anthropic: + Triangle() + .stroke(CPTheme.accent, lineWidth: 1.4) + case .google: + RoundedRectangle(cornerRadius: 1, style: .continuous) + .rotation(.degrees(45)) + .stroke(CPTheme.accent, lineWidth: 1.4) + } + } + .frame(width: size, height: size) + } +} + +struct StatusMark: View { + let status: UsageStatus + var size: CGFloat = 8 + + var body: some View { + Group { + switch status { + case .healthy: + Circle().fill(CPTheme.statusColor(status)) + case .close: + Circle().trim(from: 0, to: 0.75).stroke(CPTheme.statusColor(status), lineWidth: 2) + case .limited: + RoundedRectangle(cornerRadius: 1).fill(CPTheme.statusColor(status)) + case .stale: + Circle().stroke(CPTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2])) + case .unknown: + Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPTheme.statusColor(status)) + case .failure: + Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPTheme.statusColor(status)) + case .loading: + Circle().stroke(CPTheme.statusColor(status), lineWidth: 1.4) + } + } + .frame(width: size, height: size) + } +} + +struct Sparkline: View { + let values: [Double] + + var body: some View { + GeometryReader { proxy in + Path { path in + guard let first = values.first else { return } + let points = values.enumerated().map { index, value in + CGPoint( + x: proxy.size.width * CGFloat(index) / CGFloat(max(values.count - 1, 1)), + y: proxy.size.height * CGFloat(1 - min(max(value, 0), 1)) + ) + } + path.move(to: CGPoint(x: 0, y: proxy.size.height * CGFloat(1 - first))) + points.dropFirst().forEach { path.addLine(to: $0) } + } + .stroke(CPTheme.accent, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + } + } +} + +struct SectionHeader: View { + let title: String + var trailing: String? = nil + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.9) + .textCase(.uppercase) + .foregroundStyle(CPTheme.tertiaryText) + Spacer() + if let trailing { + Text(trailing) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPTheme.tertiaryText) + } + } + } +} + +struct CPLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1) + .fill(CPTheme.accent) + .rotationEffect(.degrees(45)) + .frame(width: 6, height: 6) + Text(text) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .textCase(.uppercase) + .foregroundStyle(CPTheme.tertiaryText) + } + } +} + +struct TagLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CPTheme.secondaryText) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(CPTheme.accent.opacity(0.08)) + .clipShape(Capsule()) + } +} + +struct Triangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +enum CPTheme { + static let background = Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255) + static let surface = Color.white + static let surface2 = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255) + static let line = Color.black.opacity(0.07) + static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255) + static let secondaryText = primaryText.opacity(0.66) + static let tertiaryText = primaryText.opacity(0.46) + static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255) + + static func statusColor(_ status: UsageStatus) -> Color { + switch status { + case .healthy: + Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255) + case .close: + Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255) + case .limited, .failure: + Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255) + case .stale, .unknown, .loading: + Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255) + } + } + + static func stroke(cornerRadius: CGFloat) -> some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(line, lineWidth: 1) + } +} + +extension UsageSnapshot { + var headline: String { + "Know whether you can keep working." + } + + var subheadline: String { + "OpenAI, Anthropic, and Google capacity with local confidence." + } + + var tightestLimit: UsageLimit? { + mostConstrainedLimits.first + } + + var tightestUsageText: String { + guard let tightestLimit else { return "Set up accounts" } + return tightestLimit.compactUsageText == "?" ? "Unknown limit" : "\(tightestLimit.compactUsageText) left" + } + + var tightestSupportText: String { + guard let tightestLimit else { return "Add OpenAI, Anthropic, or Google." } + return "\(tightestLimit.label) · \(tightestLimit.accountName) — your tightest account" + } +} + +extension UsageLimit { + var compactUsageText: String { + guard let used, let limit else { return status == .failure ? "—" : "?" } + return "\(used)/\(limit)" + } + + var percentText: String { + guard let usageRatio else { return status == .failure ? "—" : "?" } + return "\(Int(usageRatio * 100))%" + } + + var resetText: String { + switch status { + case .failure: + "refresh failed" + case .unknown: + "unknown" + default: + switch label { + case "Image generation": + "42m" + case "Claude Opus": + "1h 15m" + case "GPT-5": + "3h 20m" + case "GPT-5 Thinking": + "tomorrow 9:00" + default: + "tonight" + } + } + } +} + +extension [UsageStatus] { + var worstStatus: UsageStatus { + if contains(.limited) { return .limited } + if contains(.failure) { return .failure } + if contains(.close) { return .close } + if contains(.stale) { return .stale } + if contains(.unknown) { return .unknown } + return .healthy + } +} diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift new file mode 100644 index 0000000..aa4cdc5 --- /dev/null +++ b/Sources/ContextPanelPreview/SampleUsageData.swift @@ -0,0 +1,94 @@ +import ContextPanelCore +import Foundation + +enum SampleUsageData { + static let referenceNow = Date(timeIntervalSinceReferenceDate: 800_000_000) + + static var snapshot: UsageSnapshot { + UsageSnapshot( + generatedAt: referenceNow, + limits: [ + UsageLimit( + provider: .openAI, + accountID: "openai-personal", + accountName: "Personal", + label: "GPT-5", + used: 72, + limit: 100, + resetsAt: referenceNow.addingTimeInterval(12_000), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .manual + ), + UsageLimit( + provider: .openAI, + accountID: "openai-work", + accountName: "Work", + label: "GPT-5 Thinking", + used: 18, + limit: 40, + resetsAt: referenceNow.addingTimeInterval(86_400), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .estimated, + note: "Fast mode looks safe for about 2h." + ), + UsageLimit( + provider: .openAI, + accountID: "openai-team", + accountName: "Team", + label: "Image generation", + used: 49, + limit: 50, + resetsAt: referenceNow.addingTimeInterval(2_520), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .observed + ), + UsageLimit( + provider: .anthropic, + accountID: "anthropic-personal", + accountName: "Personal", + label: "Claude Opus", + used: 38, + limit: 45, + resetsAt: referenceNow.addingTimeInterval(4_500), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .official + ), + UsageLimit( + provider: .anthropic, + accountID: "anthropic-work", + accountName: "Work", + label: "Claude Sonnet", + used: 12, + limit: 100, + resetsAt: referenceNow.addingTimeInterval(21_600), + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .official + ), + UsageLimit( + provider: .google, + accountID: "google-personal", + accountName: "Personal", + label: "Gemini Pro", + used: nil, + limit: nil, + lastUpdatedAt: referenceNow.addingTimeInterval(-120), + confidence: .unknown, + statusOverride: .unknown, + note: "Provider does not expose this limit." + ), + UsageLimit( + provider: .google, + accountID: "google-work", + accountName: "Work", + label: "Gemini Deep Research", + used: nil, + limit: nil, + lastUpdatedAt: referenceNow.addingTimeInterval(-21_600), + confidence: .unknown, + statusOverride: .failure, + note: "Last good snapshot 6h ago." + ) + ] + ) + } +} diff --git a/Tests/ContextPanelCoreTests/UsageLimitTests.swift b/Tests/ContextPanelCoreTests/UsageLimitTests.swift index ec05f1f..838f260 100644 --- a/Tests/ContextPanelCoreTests/UsageLimitTests.swift +++ b/Tests/ContextPanelCoreTests/UsageLimitTests.swift @@ -15,6 +15,53 @@ import Testing #expect(limit.usageRatio == 1) } +@Test func unknownLimitsHaveUnknownStatus() { + let limit = UsageLimit( + provider: .google, + accountID: "google-personal", + accountName: "Personal", + label: "Gemini Pro", + used: nil, + limit: nil, + confidence: .unknown + ) + + #expect(limit.remaining == nil) + #expect(limit.usageRatio == nil) + #expect(limit.status == .unknown) +} + +@Test func snapshotSortsMostConstrainedFirst() { + let snapshot = UsageSnapshot( + generatedAt: Date(), + limits: [ + UsageLimit( + provider: .google, + accountID: "google-work", + accountName: "Work", + label: "Gemini Deep Research", + used: nil, + limit: nil, + confidence: .unknown, + statusOverride: .failure + ), + UsageLimit( + provider: .openAI, + accountID: "openai-team", + accountName: "Team", + label: "Image generation", + used: 49, + limit: 50, + confidence: .observed + ) + ] + ) + let first = snapshot.mostConstrainedLimits.first + + #expect(first?.label == "Image generation") + #expect(snapshot.aggregateCapacityRatio > 0) +} + @Test func providersCoverInitialScope() { #expect(Provider.allCases == [.openAI, .anthropic, .google]) } From 4a8e83d649c1de85d9ba3b61ba93e076fc91209c Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 15:57:50 -0400 Subject: [PATCH 02/34] Add fast mode forecast model --- .../ContextPanelCore/FastModeForecast.swift | 187 ++++++++++++++++++ .../ContextPanelPreviewApp.swift | 23 ++- .../ContextPanelPreview/SampleUsageData.swift | 18 ++ .../FastModeForecastTests.swift | 111 +++++++++++ 4 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 Sources/ContextPanelCore/FastModeForecast.swift create mode 100644 Tests/ContextPanelCoreTests/FastModeForecastTests.swift diff --git a/Sources/ContextPanelCore/FastModeForecast.swift b/Sources/ContextPanelCore/FastModeForecast.swift new file mode 100644 index 0000000..1b52901 --- /dev/null +++ b/Sources/ContextPanelCore/FastModeForecast.swift @@ -0,0 +1,187 @@ +import Foundation + +public enum UsageMode: String, Codable, Equatable, Sendable { + case standard + case fast +} + +public struct BurnRate: Codable, Equatable, Sendable { + public let mode: UsageMode + public let unitsPerHour: Double + + public init(mode: UsageMode, unitsPerHour: Double) { + precondition(unitsPerHour >= 0, "unitsPerHour must not be negative") + + self.mode = mode + self.unitsPerHour = unitsPerHour + } +} + +public struct FastModeForecastInput: Codable, Equatable, Sendable { + public let limit: UsageLimit + public let now: Date + public let standardBurnRate: BurnRate? + public let fastBurnRate: BurnRate? + public let reserveUnits: Double + public let minimumSafeHours: Double + + public init( + limit: UsageLimit, + now: Date, + standardBurnRate: BurnRate?, + fastBurnRate: BurnRate?, + reserveUnits: Double = 5, + minimumSafeHours: Double = 1 + ) { + precondition(reserveUnits >= 0, "reserveUnits must not be negative") + precondition(minimumSafeHours >= 0, "minimumSafeHours must not be negative") + + self.limit = limit + self.now = now + self.standardBurnRate = standardBurnRate + self.fastBurnRate = fastBurnRate + self.reserveUnits = reserveUnits + self.minimumSafeHours = minimumSafeHours + } +} + +public enum FastModeRecommendation: String, Codable, Equatable, Sendable { + case safeThroughReset + case safeForLimitedTime + case saveFastMode + case needsCalibration + case limited +} + +public struct FastModeForecast: Codable, Equatable, Sendable { + public let limitID: UsageLimit.ID + public let accountName: String + public let recommendation: FastModeRecommendation + public let confidence: UsageConfidence + public let remainingUnits: Double? + public let hoursUntilReset: Double? + public let fastModeRunwayHours: Double? + public let projectedFastUseUntilReset: Double? + public let reserveUnits: Double + + public var copy: String { + switch recommendation { + case .safeThroughReset: + "Fast mode looks safe through reset." + case .safeForLimitedTime: + if let fastModeRunwayHours { + "Fast mode safe for about \(Self.format(hours: fastModeRunwayHours))." + } else { + "Fast mode safe for a limited time." + } + case .saveFastMode: + "Save fast mode before reset." + case .needsCalibration: + "Needs calibration before fast mode." + case .limited: + "Limited until reset." + } + } + + public init(input: FastModeForecastInput) { + limitID = input.limit.id + accountName = input.limit.accountName + confidence = input.limit.confidence + reserveUnits = input.reserveUnits + + let remaining = input.limit.remaining.map(Double.init) + remainingUnits = remaining + if let resetsAt = input.limit.resetsAt { + hoursUntilReset = max(resetsAt.timeIntervalSince(input.now) / 3_600, 0) + } else { + hoursUntilReset = nil + } + + guard input.limit.status != .limited else { + recommendation = .limited + fastModeRunwayHours = 0 + projectedFastUseUntilReset = 0 + return + } + + guard + let remaining, + let fastRate = input.fastBurnRate?.unitsPerHour, + fastRate > 0, + let hoursUntilReset + else { + recommendation = .needsCalibration + fastModeRunwayHours = nil + projectedFastUseUntilReset = nil + return + } + + let usableRemaining = max(remaining - input.reserveUnits, 0) + let runway = usableRemaining / fastRate + let projected = fastRate * hoursUntilReset + + fastModeRunwayHours = runway + projectedFastUseUntilReset = projected + + if usableRemaining <= 0 { + recommendation = .saveFastMode + } else if projected <= usableRemaining { + recommendation = .safeThroughReset + } else if runway >= input.minimumSafeHours { + recommendation = .safeForLimitedTime + } else { + recommendation = .saveFastMode + } + } + + private static func format(hours: Double) -> String { + if hours < 1 { + let minutes = max(Int((hours * 60).rounded()), 1) + return "\(minutes)m" + } + if hours < 10 { + let rounded = (hours * 2).rounded() / 2 + if rounded.rounded() == rounded { + return "\(Int(rounded))h" + } + return "\(rounded)h" + } + return "\(Int(hours.rounded()))h" + } +} + +public struct FastModePortfolioForecast: Codable, Equatable, Sendable { + public let forecasts: [FastModeForecast] + + public init(forecasts: [FastModeForecast]) { + self.forecasts = forecasts + } + + public var bestForecast: FastModeForecast? { + forecasts.sorted { lhs, rhs in + lhs.rank > rhs.rank + }.first + } + + public var copy: String { + bestForecast?.copy ?? "Add an OpenAI account to forecast fast mode." + } +} + +extension FastModeForecast { + fileprivate var rank: Double { + let runway = fastModeRunwayHours ?? -1 + switch recommendation { + case .safeThroughReset: + return 1_000 + runway + case .safeForLimitedTime: + return 500 + runway + case .saveFastMode: + return 100 + runway + case .needsCalibration: + return 10 + case .limited: + return 0 + } + } +} diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index d34f69a..043f07f 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -157,6 +157,9 @@ struct HeaderCard: View { Text(snapshot.subheadline) .font(.system(size: 13)) .foregroundStyle(CPTheme.secondaryText) + Text(SampleUsageData.fastModeForecast.copy) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(CPTheme.accent) HStack(spacing: 8) { TagLabel("SwiftUI") TagLabel("WidgetKit") @@ -383,8 +386,8 @@ struct AccountDetail: View { ) .frame(maxWidth: .infinity) - DetailCard(title: "Forecast") { - Text(limit.note ?? "Fast mode looks safe through reset.") + DetailCard(title: "Forecast") { + Text(forecastCopy) .font(.system(size: 15, weight: .medium)) Text("Confidence: \(limit.confidence.rawValue)") .font(.system(size: 12)) @@ -412,6 +415,22 @@ struct AccountDetail: View { .background(CPTheme.background) .navigationTitle("Details") } + + private var forecastCopy: String { + if limit.provider == .openAI, limit.label.contains("GPT-5") { + return FastModeForecast( + input: FastModeForecastInput( + limit: limit, + now: SampleUsageData.referenceNow, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + ) + ).copy + } + return limit.note ?? "No fast-mode forecast for this limit yet." + } } struct DetailCard: View { diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift index aa4cdc5..c4ae20a 100644 --- a/Sources/ContextPanelPreview/SampleUsageData.swift +++ b/Sources/ContextPanelPreview/SampleUsageData.swift @@ -91,4 +91,22 @@ enum SampleUsageData { ] ) } + + static var fastModeForecast: FastModePortfolioForecast { + let forecasts = snapshot.limits + .filter { $0.provider == .openAI && $0.label.contains("GPT-5") } + .map { limit in + FastModeForecast( + input: FastModeForecastInput( + limit: limit, + now: referenceNow, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + ) + ) + } + return FastModePortfolioForecast(forecasts: forecasts) + } } diff --git a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift new file mode 100644 index 0000000..303e97f --- /dev/null +++ b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift @@ -0,0 +1,111 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +private let now = Date(timeIntervalSinceReferenceDate: 900_000_000) + +@Test func forecastReportsSafeThroughResetWhenFastBurnFitsWithReserve() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 20, limit: 100, resetsInHours: 4), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 4), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 10), + reserveUnits: 10 + ) + ) + + #expect(forecast.recommendation == .safeThroughReset) + #expect(forecast.fastModeRunwayHours == 7) + #expect(forecast.copy == "Fast mode looks safe through reset.") +} + +@Test func forecastReportsLimitedFastModeRunwayWhenFastBurnDoesNotReachReset() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 60, limit: 100, resetsInHours: 8), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 15), + reserveUnits: 10 + ) + ) + + #expect(forecast.recommendation == .safeForLimitedTime) + #expect(forecast.copy == "Fast mode safe for about 2h.") +} + +@Test func forecastSavesFastModeWhenRunwayIsBelowMinimumWindow() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 88, limit: 100, resetsInHours: 6), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 1), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 10), + reserveUnits: 5, + minimumSafeHours: 1 + ) + ) + + #expect(forecast.recommendation == .saveFastMode) + #expect(forecast.copy == "Save fast mode before reset.") +} + +@Test func forecastNeedsCalibrationWithoutResetOrFastBurnRate() { + let forecast = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(used: 10, limit: 100, resetsInHours: nil), + now: now, + standardBurnRate: nil, + fastBurnRate: nil + ) + ) + + #expect(forecast.recommendation == .needsCalibration) + #expect(forecast.copy == "Needs calibration before fast mode.") +} + +@Test func portfolioChoosesBestOpenAIAccountForFastMode() { + let personal = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(accountName: "Personal", used: 80, limit: 100, resetsInHours: 12), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 5 + ) + ) + let work = FastModeForecast( + input: FastModeForecastInput( + limit: openAILimit(accountName: "Work", used: 20, limit: 100, resetsInHours: 4), + now: now, + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 5 + ) + ) + + let portfolio = FastModePortfolioForecast(forecasts: [personal, work]) + + #expect(portfolio.bestForecast?.accountName == "Work") + #expect(portfolio.copy == "Fast mode looks safe through reset.") +} + +private func openAILimit( + accountName: String = "Personal", + used: Int, + limit: Int, + resetsInHours: Double? +) -> UsageLimit { + UsageLimit( + provider: .openAI, + accountID: "openai-\(accountName.lowercased())", + accountName: accountName, + label: "GPT-5 Thinking", + used: used, + limit: limit, + resetsAt: resetsInHours.map { now.addingTimeInterval($0 * 3_600) }, + confidence: .estimated + ) +} From 11fd1504a6852cc0b41607bdf4176e1b7f7d758f Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 17:58:10 -0400 Subject: [PATCH 03/34] Design local subscription limit probe --- .github/github-repo-workflow.json | 1 + docs/README.md | 1 + docs/local-limit-probe.md | 196 ++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 docs/local-limit-probe.md diff --git a/.github/github-repo-workflow.json b/.github/github-repo-workflow.json index cb56c14..e647b9c 100644 --- a/.github/github-repo-workflow.json +++ b/.github/github-repo-workflow.json @@ -7,6 +7,7 @@ "productGoals": "docs/product-goals.md", "architecture": "docs/architecture.md", "designDirection": "docs/design-direction.md", + "localLimitProbe": "docs/local-limit-probe.md", "providerUsageAccess": "docs/provider-usage-access.md", "repoSettings": "docs/repo-settings.md" }, diff --git a/docs/README.md b/docs/README.md index 956a1b8..0f724f2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,5 +4,6 @@ - [Product Goals](product-goals.md) - [Architecture](architecture.md) - [Design Direction](design-direction.md) +- [Local Limit Probe Design](local-limit-probe.md) - [Provider Usage Access Research](provider-usage-access.md) - [Repository Settings](repo-settings.md) diff --git a/docs/local-limit-probe.md b/docs/local-limit-probe.md new file mode 100644 index 0000000..94ca918 --- /dev/null +++ b/docs/local-limit-probe.md @@ -0,0 +1,196 @@ +# Local Limit Probe Design + +Last updated: 2026-05-05. + +## Goal + +Context Panel needs to know whether subscription limits are exposed anywhere a +logged-in user can legitimately see them. The first target is OpenAI ChatGPT +subscription usage: weekly Thinking limits, short rolling message windows, reset +times, model availability, and any remaining/used counters. If the approach works +for OpenAI, the same diagnostic shape can be reused for Claude and Gemini. + +The probe is a local diagnostic tool, not a production data integration. It +should help answer: + +- Does the provider expose subscription limits in visible UI text? +- Does the provider expose subscription limits in browser-accessible structured + responses after a normal login? +- Which observations are safe and reliable enough to become Context Panel + signals? +- Which observations should stay manual/calibrated because the provider hides or + changes them? + +## Safety Rules + +- The user logs in directly with the provider. Context Panel never asks for or + stores the provider password. +- The probe never prints, commits, uploads, or logs cookies, bearer tokens, + session IDs, full response bodies, or account identifiers. +- Captured artifacts are local and gitignored by default. +- Raw network/body capture is opt-in and redacted before display. +- The default probe reports only route/method/status/content-type/body-size plus + detected field names or text snippets that match usage-limit patterns. +- Automated message sending is out of scope. The probe observes login/account + pages and model picker state; it does not burn subscription allowance. + +## Recommended Implementation + +Build a local macOS diagnostic surface called **Limit Probe** inside the +companion app or as a development-only target. + +For the first implementation, prefer a native `WKWebView`-based probe because it +lets the user log in normally while keeping the session isolated from Safari or +Chrome. Later, a browser-extension or browser-control probe can be considered if +WKWebView cannot observe enough. + +### Flow + +1. User opens `Limit Probe`. +2. User selects provider: OpenAI first, then Anthropic, then Google. +3. App opens an isolated `WKWebView` at the provider's normal product URL. +4. User logs in normally. +5. Probe shows a checklist: + - Login detected. + - Model picker/account UI reachable. + - Limit/reset text detected. + - Candidate structured responses detected. + - Manual observation needed. +6. User navigates to the relevant UI, such as ChatGPT model picker. +7. Probe scans visible text and sanitized network metadata for usage/reset + signals. +8. User can press `Record Observation` to save a sanitized event. +9. User can press `Export Redacted Report` for a local Markdown/JSON report. + +### OpenAI Targets + +Initial OpenAI pages and states to inspect: + +- ChatGPT app after login. +- Model picker when GPT-5/GPT-5.5/Thinking modes are available. +- Provider UI when a model is close to its limit. +- Provider UI when a model is unavailable or limit-reached. +- Account or plan surfaces that mention current plan and reset. + +Detection patterns: + +- `reset`, `resets`, `refresh`, `available`, `limit`, `usage`, `messages`, + `weekly`, `every 3 hours`, `every 5 hours`, `Thinking`, `fast`, `temporary`. +- Dates and relative durations such as `tomorrow`, `in 42m`, `3h`, `5 hours`, + `7 days`, `weekly`. +- JSON field names containing `limit`, `usage`, `remaining`, `reset`, `cap`, + `quota`, `message`, `model`, or `plan`. + +## Capture Model + +```swift +struct LimitProbeObservation: Codable, Sendable { + var provider: Provider + var accountLabel: String? + var surface: String + var observedAt: Date + var source: ProbeSource + var signal: ProbeSignal + var confidence: UsageConfidence + var sanitizedEvidence: String +} + +enum ProbeSource: String, Codable, Sendable { + case visibleText + case networkMetadata + case redactedResponseShape + case manualUserEntry +} + +enum ProbeSignal: Codable, Sendable { + case resetTime(Date) + case relativeReset(seconds: TimeInterval) + case knownLimit(used: Int?, limit: Int, unit: String) + case modelAvailable(model: String) + case modelUnavailable(model: String, reason: String?) + case plan(name: String) + case unknownLimit +} +``` + +## Sanitized Network Probe + +`WKWebView` does not expose every network body through public APIs. There are +three possible tiers: + +1. **Visible text only**: safest and easiest. JavaScript reads `document.body` + text after login and extracts matching snippets. +2. **JavaScript fetch instrumentation**: inject a user script that wraps + `window.fetch` and `XMLHttpRequest` to record sanitized URL path, method, + status, content type, body size, and matching field names from JSON responses. + Do not store headers or raw bodies. +3. **External browser-control/devtools probe**: use a separate development-only + probe with browser automation/DevTools if WKWebView cannot see enough. This + remains local and diagnostic-only. + +Start with tier 1 and tier 2. Escalate only if OpenAI hides the useful signal +from visible text and simple response-shape inspection. + +## Report Shape + +The exported report should be local and safe to share in a PR or issue after +review: + +```json +{ + "schema_version": 1, + "provider": "openai", + "captured_at": "2026-05-05T00:00:00Z", + "surfaces": [ + { + "surface": "chatgpt-model-picker", + "signals": [ + { + "source": "visibleText", + "signal": "relativeReset", + "evidence": "resets in 3h", + "confidence": "observed" + } + ] + } + ], + "candidate_network_shapes": [ + { + "method": "GET", + "path_hint": "/.../models/...", + "status": 200, + "content_type": "application/json", + "matched_fields": ["model", "limit", "reset"] + } + ], + "redactions": [ + "cookies", + "authorization headers", + "account ids", + "emails", + "raw response bodies" + ] +} +``` + +## Acceptance Criteria For A Prototype + +- User can log in to OpenAI in an isolated local web view. +- Probe can scan visible UI text for limit/reset signals without storing + secrets. +- Probe can record a manual observation when the UI exposes reset or limit state. +- Probe can show whether any structured candidate responses appear, without + revealing raw response bodies or tokens. +- Probe writes only redacted local artifacts under a gitignored directory. +- Findings can update `docs/provider-usage-access.md` with evidence and + confidence. + +## Open Questions + +- Does ChatGPT's login flow work reliably in `WKWebView`, or does it require the + user's default browser? +- Does the model picker expose useful reset text before a limit is reached? +- Can visible text reveal enough to calibrate weekly Thinking limits without + network inspection? +- Are subscription limit signals account-specific enough to distinguish multiple + OpenAI accounts cleanly? From c36ea9e17223556f9c71471f7a0a1d736b162dc5 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 18:29:13 -0400 Subject: [PATCH 04/34] Add OpenAI limit probe prototype --- Package.swift | 8 + Sources/ContextPanelCore/LimitProbe.swift | 194 ++++++++++++++ .../OpenAILimitProbeApp.swift | 245 ++++++++++++++++++ .../LimitProbeTests.swift | 68 +++++ 4 files changed, 515 insertions(+) create mode 100644 Sources/ContextPanelCore/LimitProbe.swift create mode 100644 Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift create mode 100644 Tests/ContextPanelCoreTests/LimitProbeTests.swift diff --git a/Package.swift b/Package.swift index f771036..350ac38 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,10 @@ let package = Package( .executable( name: "ContextPanelPreview", targets: ["ContextPanelPreview"] + ), + .executable( + name: "OpenAILimitProbe", + targets: ["OpenAILimitProbe"] ) ], targets: [ @@ -23,6 +27,10 @@ let package = Package( name: "ContextPanelPreview", dependencies: ["ContextPanelCore"] ), + .executableTarget( + name: "OpenAILimitProbe", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift new file mode 100644 index 0000000..b20f15d --- /dev/null +++ b/Sources/ContextPanelCore/LimitProbe.swift @@ -0,0 +1,194 @@ +import Foundation + +public enum ProbeSource: String, Codable, Equatable, Sendable { + case visibleText + case networkMetadata + case redactedResponseShape + case manualUserEntry +} + +public enum ProbeSignalKind: String, Codable, Equatable, Sendable { + case resetLanguage + case relativeDuration + case messageLimit + case limitReached + case modelAvailability + case planLanguage + case candidateFieldName +} + +public struct LimitProbeObservation: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let provider: Provider + public let observedAt: Date + public let source: ProbeSource + public let signalKind: ProbeSignalKind + public let confidence: UsageConfidence + public let sanitizedEvidence: String + + public init( + id: UUID = UUID(), + provider: Provider, + observedAt: Date, + source: ProbeSource, + signalKind: ProbeSignalKind, + confidence: UsageConfidence, + sanitizedEvidence: String + ) { + self.id = id + self.provider = provider + self.observedAt = observedAt + self.source = source + self.signalKind = signalKind + self.confidence = confidence + self.sanitizedEvidence = EvidenceRedactor.redact(sanitizedEvidence) + } +} + +public struct LimitProbeReport: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let provider: Provider + public let capturedAt: Date + public let observations: [LimitProbeObservation] + public let redactions: [String] + + public init(provider: Provider, capturedAt: Date, observations: [LimitProbeObservation]) { + self.schemaVersion = 1 + self.provider = provider + self.capturedAt = capturedAt + self.observations = observations + self.redactions = [ + "cookies", + "authorization headers", + "bearer tokens", + "session identifiers", + "emails", + "account identifiers", + "raw response bodies" + ] + } + + public var markdownSummary: String { + var lines = [ + "# Limit Probe Report", + "", + "- Provider: \(provider.displayName)", + "- Captured: \(capturedAt.ISO8601Format())", + "- Observations: \(observations.count)", + "", + "## Observations" + ] + + if observations.isEmpty { + lines.append("- No candidate limit signals found.") + } else { + for observation in observations { + lines.append("- `\(observation.signalKind.rawValue)` from `\(observation.source.rawValue)`: \(observation.sanitizedEvidence)") + } + } + + lines.append(contentsOf: [ + "", + "## Redactions", + redactions.map { "- \($0)" }.joined(separator: "\n") + ]) + + return lines.joined(separator: "\n") + } +} + +public enum LimitProbeScanner { + private static let patterns: [(ProbeSignalKind, NSRegularExpression)] = [ + (.resetLanguage, regex(#"(?i)\b(reset|resets|refresh|refreshes|available again)\b.{0,80}"#)), + (.relativeDuration, regex(#"(?i)\b(in\s+)?\d+\s*(m|min|mins|minutes|h|hr|hrs|hour|hours|day|days|week|weeks)\b"#)), + (.messageLimit, regex(#"(?i)\b\d+[\d,]*\s*(messages?|prompts?)\s*(every|per|/)?\s*\d*\s*(hours?|days?|weeks?)?\b"#)), + (.limitReached, regex(#"(?i)\b(limit reached|reached your limit|you.ve reached|unavailable|try again)\b.{0,80}"#)), + (.modelAvailability, regex(#"(?i)\b(GPT|Thinking|fast mode|model picker|available|unavailable)\b.{0,80}"#)), + (.planLanguage, regex(#"(?i)\b(Free|Plus|Pro|Team|Business|Enterprise|Go)\b.{0,80}"#)) + ] + + public static func scanVisibleText( + _ text: String, + provider: Provider, + observedAt: Date = Date() + ) -> [LimitProbeObservation] { + let normalized = text.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + let range = NSRange(normalized.startIndex..() + + for (kind, pattern) in patterns { + for match in pattern.matches(in: normalized, range: range).prefix(8) { + guard let matchRange = Range(match.range, in: normalized) else { continue } + let evidence = String(normalized[matchRange]).trimmingCharacters(in: .whitespacesAndNewlines) + let key = "\(kind.rawValue):\(evidence.lowercased())" + guard !seen.contains(key) else { continue } + seen.insert(key) + observations.append( + LimitProbeObservation( + provider: provider, + observedAt: observedAt, + source: .visibleText, + signalKind: kind, + confidence: .observed, + sanitizedEvidence: evidence + ) + ) + } + } + + return observations + } + + public static func scanResponseShape( + fieldNames: [String], + provider: Provider, + observedAt: Date = Date() + ) -> [LimitProbeObservation] { + let candidates = fieldNames.filter { field in + let lower = field.lowercased() + return ["limit", "usage", "remaining", "reset", "quota", "message", "model", "plan", "cap"].contains { lower.contains($0) } + } + + return Array(Set(candidates)).sorted().map { field in + LimitProbeObservation( + provider: provider, + observedAt: observedAt, + source: .redactedResponseShape, + signalKind: .candidateFieldName, + confidence: .observed, + sanitizedEvidence: field + ) + } + } + + private static func regex(_ pattern: String) -> NSRegularExpression { + do { + return try NSRegularExpression(pattern: pattern) + } catch { + preconditionFailure("Invalid probe regex: \(pattern)") + } + } +} + +public enum EvidenceRedactor { + private static let redactionPatterns: [(String, String)] = [ + (#"(?i)bearer\s+[a-z0-9._\-]+"#, "bearer [redacted]"), + (#"(?i)(authorization|cookie|set-cookie|csrf|session|token)[:=]\s*[^\s,;]+"#, "$1=[redacted]"), + (#"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, "[email redacted]"), + (#"\bsk-[A-Za-z0-9_\-]{12,}\b"#, "[api key redacted]") + ] + + public static func redact(_ value: String) -> String { + var redacted = value + for (pattern, replacement) in redactionPatterns { + redacted = redacted.replacingOccurrences( + of: pattern, + with: replacement, + options: [.regularExpression, .caseInsensitive] + ) + } + return redacted + } +} diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift new file mode 100644 index 0000000..2825413 --- /dev/null +++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift @@ -0,0 +1,245 @@ +import ContextPanelCore +import SwiftUI +import UniformTypeIdentifiers +import WebKit + +@main +struct OpenAILimitProbeApp: App { + var body: some Scene { + WindowGroup { + ProbeRootView() + .frame(minWidth: 1180, minHeight: 760) + } + } +} + +struct ProbeRootView: View { + @StateObject private var model = ProbeModel() + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + header + controls + Divider() + ObservationList(observations: model.observations) + Spacer() + safetyFooter + } + .frame(width: 360) + .padding(18) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ProbeWebView(model: model) + } + .fileExporter( + isPresented: $model.isExportingReport, + document: ProbeReportDocument(report: model.reportMarkdown), + contentType: .plainText, + defaultFilename: "openai-limit-probe-report.md" + ) { _ in } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("OpenAI Limit Probe") + .font(.system(size: 24, weight: .semibold)) + Text("Log in directly with OpenAI, navigate to ChatGPT/model picker, then scan visible text for subscription limit signals.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Button("Open ChatGPT") { + model.loadChatGPT() + } + Button("Scan Visible Text") { + model.scanVisibleText() + } + .keyboardShortcut("s", modifiers: [.command]) + } + + HStack { + Button("Record Manual Observation") { + model.recordManualObservation() + } + Button("Export Redacted Report") { + model.exportReport() + } + } + + TextField("Manual note, e.g. resets tomorrow 9:00 AM", text: $model.manualObservation) + .textFieldStyle(.roundedBorder) + } + } + + private var safetyFooter: some View { + VStack(alignment: .leading, spacing: 6) { + Label("No passwords, cookies, auth headers, or raw response bodies are exported.", systemImage: "lock.shield") + Label("The probe observes visible text only in this prototype.", systemImage: "eye") + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } +} + +struct ObservationList: View { + let observations: [LimitProbeObservation] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Observations") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(observations.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if observations.isEmpty { + ContentUnavailableView( + "No signals yet", + systemImage: "waveform.path.ecg.rectangle", + description: Text("Open ChatGPT, log in, navigate to the model picker, and scan visible text.") + ) + .frame(maxHeight: 220) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(observations) { observation in + VStack(alignment: .leading, spacing: 4) { + Text(observation.signalKind.rawValue) + .font(.system(size: 12, weight: .semibold)) + Text(observation.sanitizedEvidence) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text("\(observation.source.rawValue) · \(observation.confidence.rawValue)") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + } + } + } +} + +@MainActor +final class ProbeModel: ObservableObject { + @Published var observations: [LimitProbeObservation] = [] + @Published var manualObservation = "" + @Published var isExportingReport = false + + let webView: WKWebView + + init() { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + webView = WKWebView(frame: .zero, configuration: configuration) + loadChatGPT() + } + + var reportMarkdown: String { + LimitProbeReport(provider: .openAI, capturedAt: Date(), observations: observations).markdownSummary + } + + func loadChatGPT() { + webView.load(URLRequest(url: URL(string: "https://chatgpt.com/")!)) + } + + func scanVisibleText() { + let script = "document.body ? document.body.innerText : ''" + webView.evaluateJavaScript(script) { [weak self] result, error in + Task { @MainActor in + guard let self else { return } + if let text = result as? String { + let newObservations = LimitProbeScanner.scanVisibleText(text, provider: .openAI) + self.merge(newObservations) + } else if let error { + self.merge([ + LimitProbeObservation( + provider: .openAI, + observedAt: Date(), + source: .visibleText, + signalKind: .limitReached, + confidence: .unknown, + sanitizedEvidence: "Scan failed: \(error.localizedDescription)" + ) + ]) + } + } + } + } + + func recordManualObservation() { + let trimmed = manualObservation.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + merge([ + LimitProbeObservation( + provider: .openAI, + observedAt: Date(), + source: .manualUserEntry, + signalKind: .resetLanguage, + confidence: .manual, + sanitizedEvidence: trimmed + ) + ]) + manualObservation = "" + } + + func exportReport() { + isExportingReport = true + } + + private func merge(_ newObservations: [LimitProbeObservation]) { + var keys = Set(observations.map { "\($0.signalKind.rawValue):\($0.sanitizedEvidence.lowercased())" }) + for observation in newObservations { + let key = "\(observation.signalKind.rawValue):\(observation.sanitizedEvidence.lowercased())" + guard !keys.contains(key) else { continue } + keys.insert(key) + observations.append(observation) + } + } +} + +struct ProbeWebView: NSViewRepresentable { + @ObservedObject var model: ProbeModel + + func makeNSView(context: Context) -> WKWebView { + model.webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +struct ProbeReportDocument: FileDocument { + static var readableContentTypes: [UTType] { [.plainText] } + + var report: String + + init(report: String) { + self.report = report + } + + init(configuration: ReadConfiguration) throws { + report = "" + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(report.utf8)) + } +} diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift new file mode 100644 index 0000000..1b61edb --- /dev/null +++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func visibleTextScannerFindsSubscriptionLimitSignals() { + let text = """ + GPT-5 Thinking unavailable. You have reached your weekly limit. + Try again in 3h. Plus includes 160 messages every 3 hours. + """ + + let observations = LimitProbeScanner.scanVisibleText(text, provider: .openAI) + let kinds = Set(observations.map(\.signalKind)) + + #expect(kinds.contains(.limitReached)) + #expect(kinds.contains(.relativeDuration)) + #expect(kinds.contains(.messageLimit)) + #expect(kinds.contains(.modelAvailability)) + #expect(kinds.contains(.planLanguage)) +} + +@Test func responseShapeScannerOnlyReportsCandidateFieldNames() { + let observations = LimitProbeScanner.scanResponseShape( + fieldNames: ["id", "email", "message_cap", "reset_at", "avatar", "remaining_messages"], + provider: .openAI + ) + + #expect(observations.map(\.sanitizedEvidence).contains("message_cap")) + #expect(observations.map(\.sanitizedEvidence).contains("remaining_messages")) + #expect(!observations.map(\.sanitizedEvidence).contains("email")) +} + +@Test func probeEvidenceRedactsSecretsBeforeStorage() { + let observation = LimitProbeObservation( + provider: .openAI, + observedAt: Date(), + source: .visibleText, + signalKind: .resetLanguage, + confidence: .observed, + sanitizedEvidence: "Authorization: bearer abc.def.ghi user chris@example.com token=secret" + ) + + #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("abc.def.ghi")) + #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("chris@example.com")) + #expect(!observation.sanitizedEvidence.localizedCaseInsensitiveContains("secret")) + #expect(observation.sanitizedEvidence.localizedCaseInsensitiveContains("[email redacted]")) +} + +@Test func markdownReportContainsRedactionStatement() { + let report = LimitProbeReport( + provider: .openAI, + capturedAt: Date(timeIntervalSinceReferenceDate: 1), + observations: [ + LimitProbeObservation( + provider: .openAI, + observedAt: Date(timeIntervalSinceReferenceDate: 1), + source: .visibleText, + signalKind: .relativeDuration, + confidence: .observed, + sanitizedEvidence: "resets in 3h" + ) + ] + ) + + #expect(report.markdownSummary.contains("Limit Probe Report")) + #expect(report.markdownSummary.contains("resets in 3h")) + #expect(report.markdownSummary.contains("authorization headers")) +} From 5dc9c1c47d3a1442522d4b8e28eea63cd27a3cec Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 18:32:26 -0400 Subject: [PATCH 05/34] Make limit probe keyboard accessible --- Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift index 2825413..e1cde15 100644 --- a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift +++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift @@ -71,6 +71,7 @@ struct ProbeRootView: View { Button("Export Redacted Report") { model.exportReport() } + .keyboardShortcut("e", modifiers: [.command]) } TextField("Manual note, e.g. resets tomorrow 9:00 AM", text: $model.manualObservation) From f5f52b89c52133597e1bad06867ca67772f926ee Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 19:02:43 -0400 Subject: [PATCH 06/34] Capture sanitized network probe shapes --- Sources/ContextPanelCore/LimitProbe.swift | 41 ++++ .../OpenAILimitProbeApp.swift | 185 +++++++++++++++++- .../LimitProbeTests.swift | 15 ++ 3 files changed, 236 insertions(+), 5 deletions(-) diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift index b20f15d..33ea397 100644 --- a/Sources/ContextPanelCore/LimitProbe.swift +++ b/Sources/ContextPanelCore/LimitProbe.swift @@ -97,6 +97,37 @@ public struct LimitProbeReport: Codable, Equatable, Sendable { } } +public struct NetworkProbeEvent: Codable, Equatable, Identifiable, Sendable { + public let id: UUID + public let observedAt: Date + public let method: String + public let pathHint: String + public let status: Int? + public let contentType: String? + public let bodySize: Int? + public let matchedFields: [String] + + public init( + id: UUID = UUID(), + observedAt: Date, + method: String, + pathHint: String, + status: Int?, + contentType: String?, + bodySize: Int?, + matchedFields: [String] + ) { + self.id = id + self.observedAt = observedAt + self.method = EvidenceRedactor.redact(method) + self.pathHint = EvidenceRedactor.redactPath(pathHint) + self.status = status + self.contentType = EvidenceRedactor.redact(contentType ?? "") + self.bodySize = bodySize + self.matchedFields = matchedFields.map(EvidenceRedactor.redact).sorted() + } +} + public enum LimitProbeScanner { private static let patterns: [(ProbeSignalKind, NSRegularExpression)] = [ (.resetLanguage, regex(#"(?i)\b(reset|resets|refresh|refreshes|available again)\b.{0,80}"#)), @@ -191,4 +222,14 @@ public enum EvidenceRedactor { } return redacted } + + public static func redactPath(_ value: String) -> String { + guard let components = URLComponents(string: value) else { + return redact(value) + } + let path = components.path + .replacingOccurrences(of: #"/[0-9a-fA-F-]{16,}"#, with: "/[id]", options: .regularExpression) + .replacingOccurrences(of: #"/[A-Za-z0-9_-]{24,}"#, with: "/[id]", options: .regularExpression) + return redact(path.isEmpty ? value : path) + } } diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift index e1cde15..98e32cd 100644 --- a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift +++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift @@ -23,6 +23,7 @@ struct ProbeRootView: View { controls Divider() ObservationList(observations: model.observations) + NetworkEventList(events: model.networkEvents) Spacer() safetyFooter } @@ -82,13 +83,61 @@ struct ProbeRootView: View { private var safetyFooter: some View { VStack(alignment: .leading, spacing: 6) { Label("No passwords, cookies, auth headers, or raw response bodies are exported.", systemImage: "lock.shield") - Label("The probe observes visible text only in this prototype.", systemImage: "eye") + Label("Network capture stores only method/path/status/body size and matching field names.", systemImage: "point.3.connected.trianglepath.dotted") } .font(.system(size: 11)) .foregroundStyle(.secondary) } } +struct NetworkEventList: View { + let events: [NetworkProbeEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Network candidates") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(events.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if events.isEmpty { + Text("No candidate response fields yet. Open model menus or settings, then scan/navigate.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(events.prefix(12)) { event in + VStack(alignment: .leading, spacing: 4) { + Text("\(event.method) \(event.pathHint)") + .font(.system(size: 11, design: .monospaced)) + .lineLimit(2) + Text(event.matchedFields.joined(separator: ", ")) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + Text("status \(event.status.map(String.init) ?? "?") · \(event.bodySize ?? 0)b") + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + .frame(maxHeight: 220) + } + } + } +} + struct ObservationList: View { let observations: [LimitProbeObservation] @@ -142,15 +191,21 @@ struct ObservationList: View { @MainActor final class ProbeModel: ObservableObject { @Published var observations: [LimitProbeObservation] = [] + @Published var networkEvents: [NetworkProbeEvent] = [] @Published var manualObservation = "" @Published var isExportingReport = false - let webView: WKWebView - - init() { + lazy var webView: WKWebView = { let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() - webView = WKWebView(frame: .zero, configuration: configuration) + configuration.userContentController.add(ProbeScriptHandler(owner: self), name: "limitProbe") + configuration.userContentController.addUserScript( + WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + return WKWebView(frame: .zero, configuration: configuration) + }() + + init() { loadChatGPT() } @@ -215,6 +270,126 @@ final class ProbeModel: ObservableObject { observations.append(observation) } } + + fileprivate func record(networkEvent event: NetworkProbeEvent) { + let key = "\(event.method):\(event.pathHint):\(event.matchedFields.joined(separator: ","))" + let existing = Set(networkEvents.map { "\($0.method):\($0.pathHint):\($0.matchedFields.joined(separator: ","))" }) + guard !existing.contains(key), !event.matchedFields.isEmpty else { return } + networkEvents.insert(event, at: 0) + } + + private static let networkProbeScript = #""" + (() => { + if (window.__contextPanelLimitProbeInstalled) return; + window.__contextPanelLimitProbeInstalled = true; + const candidate = /limit|usage|remaining|reset|quota|message|model|plan|cap/i; + + function pathHint(rawUrl) { + try { return new URL(rawUrl, window.location.href).pathname; } + catch (_) { return String(rawUrl || '').split('?')[0].slice(0, 180); } + } + + function collectFields(value, prefix = '', out = new Set(), depth = 0) { + if (!value || depth > 3 || out.size > 80) return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectFields(item, prefix, out, depth + 1)); + return out; + } + if (typeof value !== 'object') return out; + Object.keys(value).forEach(key => { + const full = prefix ? `${prefix}.${key}` : key; + if (candidate.test(full)) out.add(full); + collectFields(value[key], full, out, depth + 1); + }); + return out; + } + + function post(event) { + try { window.webkit.messageHandlers.limitProbe.postMessage(event); } + catch (_) {} + } + + function inspect(method, url, status, contentType, text) { + const body = String(text || ''); + let fields = []; + if (/json/i.test(contentType || '') && body.length < 2_000_000) { + try { fields = Array.from(collectFields(JSON.parse(body))).slice(0, 40); } + catch (_) { fields = []; } + } + if (fields.length === 0) return; + post({ + method: method || 'GET', + pathHint: pathHint(url), + status: status || null, + contentType: contentType || null, + bodySize: body.length, + matchedFields: fields + }); + } + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const response = await originalFetch.apply(this, arguments); + try { + const clone = response.clone(); + const url = typeof input === 'string' ? input : (input && input.url) || ''; + const method = (init && init.method) || (input && input.method) || 'GET'; + const contentType = clone.headers.get('content-type') || ''; + clone.text().then(text => inspect(method, url, clone.status, contentType, text)).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__cpMethod = method; + this.__cpUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { + const contentType = this.getResponseHeader('content-type') || ''; + inspect(this.__cpMethod || 'GET', this.__cpUrl || '', this.status, contentType, this.responseText || ''); + } catch (_) {} + }); + return originalSend.apply(this, arguments); + }; + })(); + """# +} + +final class ProbeScriptHandler: NSObject, WKScriptMessageHandler { + weak var owner: ProbeModel? + + init(owner: ProbeModel) { + self.owner = owner + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + let method = payload["method"] as? String ?? "GET" + let pathHint = payload["pathHint"] as? String ?? "" + let status = payload["status"] as? Int + let contentType = payload["contentType"] as? String + let bodySize = payload["bodySize"] as? Int + let matchedFields = payload["matchedFields"] as? [String] ?? [] + let event = NetworkProbeEvent( + observedAt: Date(), + method: method, + pathHint: pathHint, + status: status, + contentType: contentType, + bodySize: bodySize, + matchedFields: matchedFields + ) + Task { @MainActor [weak owner = self.owner] in + owner?.record(networkEvent: event) + } + } } struct ProbeWebView: NSViewRepresentable { diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift index 1b61edb..48f90df 100644 --- a/Tests/ContextPanelCoreTests/LimitProbeTests.swift +++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift @@ -66,3 +66,18 @@ import Testing #expect(report.markdownSummary.contains("resets in 3h")) #expect(report.markdownSummary.contains("authorization headers")) } + +@Test func networkProbeEventRedactsPathIdentifiers() { + let event = NetworkProbeEvent( + observedAt: Date(), + method: "GET", + pathHint: "https://chatgpt.com/backend-api/conversation/abc123def456abc123def456?token=secret", + status: 200, + contentType: "application/json", + bodySize: 120, + matchedFields: ["reset_at", "remaining_messages"] + ) + + #expect(event.pathHint == "/backend-api/conversation/[id]") + #expect(event.matchedFields == ["remaining_messages", "reset_at"]) +} From e921749457b602d08e7207f92d019153b765daea Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 19:13:43 -0400 Subject: [PATCH 07/34] Redact identifiers from network probe fields --- Sources/ContextPanelCore/LimitProbe.swift | 4 +++- Tests/ContextPanelCoreTests/LimitProbeTests.swift | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift index 33ea397..1ce7ab1 100644 --- a/Sources/ContextPanelCore/LimitProbe.swift +++ b/Sources/ContextPanelCore/LimitProbe.swift @@ -208,7 +208,9 @@ public enum EvidenceRedactor { (#"(?i)bearer\s+[a-z0-9._\-]+"#, "bearer [redacted]"), (#"(?i)(authorization|cookie|set-cookie|csrf|session|token)[:=]\s*[^\s,;]+"#, "$1=[redacted]"), (#"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, "[email redacted]"), - (#"\bsk-[A-Za-z0-9_\-]{12,}\b"#, "[api key redacted]") + (#"\bsk-[A-Za-z0-9_\-]{12,}\b"#, "[api key redacted]"), + (#"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"#, "[id]"), + (#"(?<=\.)[A-Za-z0-9_-]{20,}(?=\.)"#, "[id]") ] public static func redact(_ value: String) -> String { diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift index 48f90df..a064cdb 100644 --- a/Tests/ContextPanelCoreTests/LimitProbeTests.swift +++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift @@ -81,3 +81,17 @@ import Testing #expect(event.pathHint == "/backend-api/conversation/[id]") #expect(event.matchedFields == ["remaining_messages", "reset_at"]) } + +@Test func networkProbeEventRedactsIdentifiersInsideFieldNames() { + let event = NetworkProbeEvent( + observedAt: Date(), + method: "GET", + pathHint: "/api/accounts", + status: 200, + contentType: "application/json", + bodySize: 120, + matchedFields: ["accounts.1e13c5e0-a592-428d-a051-9fe5d6260e38.entitlement.subscription_plan"] + ) + + #expect(event.matchedFields == ["accounts.[id].entitlement.subscription_plan"]) +} From ed09def6f228686cf4e41e58d619a5b751d5bb6b Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 19:30:22 -0400 Subject: [PATCH 08/34] Include network candidates in probe reports --- Sources/ContextPanelCore/LimitProbe.swift | 25 ++++++++++++++- .../OpenAILimitProbeApp.swift | 7 ++++- .../LimitProbeTests.swift | 14 +++++++++ docs/provider-usage-access.md | 31 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift index 1ce7ab1..9e5491e 100644 --- a/Sources/ContextPanelCore/LimitProbe.swift +++ b/Sources/ContextPanelCore/LimitProbe.swift @@ -50,13 +50,20 @@ public struct LimitProbeReport: Codable, Equatable, Sendable { public let provider: Provider public let capturedAt: Date public let observations: [LimitProbeObservation] + public let networkEvents: [NetworkProbeEvent] public let redactions: [String] - public init(provider: Provider, capturedAt: Date, observations: [LimitProbeObservation]) { + public init( + provider: Provider, + capturedAt: Date, + observations: [LimitProbeObservation], + networkEvents: [NetworkProbeEvent] = [] + ) { self.schemaVersion = 1 self.provider = provider self.capturedAt = capturedAt self.observations = observations + self.networkEvents = networkEvents self.redactions = [ "cookies", "authorization headers", @@ -87,6 +94,22 @@ public struct LimitProbeReport: Codable, Equatable, Sendable { } } + lines.append(contentsOf: [ + "", + "## Network Candidates" + ]) + + if networkEvents.isEmpty { + lines.append("- No candidate response shapes found.") + } else { + for event in networkEvents { + let status = event.status.map(String.init) ?? "?" + let bodySize = event.bodySize.map { "\($0)b" } ?? "unknown size" + let fields = event.matchedFields.joined(separator: ", ") + lines.append("- `\(event.method) \(event.pathHint)` status `\(status)`, `\(bodySize)`: \(fields)") + } + } + lines.append(contentsOf: [ "", "## Redactions", diff --git a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift index 98e32cd..3a0c712 100644 --- a/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift +++ b/Sources/OpenAILimitProbe/OpenAILimitProbeApp.swift @@ -210,7 +210,12 @@ final class ProbeModel: ObservableObject { } var reportMarkdown: String { - LimitProbeReport(provider: .openAI, capturedAt: Date(), observations: observations).markdownSummary + LimitProbeReport( + provider: .openAI, + capturedAt: Date(), + observations: observations, + networkEvents: networkEvents + ).markdownSummary } func loadChatGPT() { diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift index a064cdb..2e5e78d 100644 --- a/Tests/ContextPanelCoreTests/LimitProbeTests.swift +++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift @@ -59,11 +59,25 @@ import Testing confidence: .observed, sanitizedEvidence: "resets in 3h" ) + ], + networkEvents: [ + NetworkProbeEvent( + observedAt: Date(timeIntervalSinceReferenceDate: 1), + method: "GET", + pathHint: "/backend-api/accounts/check", + status: 200, + contentType: "application/json", + bodySize: 256, + matchedFields: ["default_account_plan_type", "accounts.[id].entitlement.subscription_plan"] + ) ] ) #expect(report.markdownSummary.contains("Limit Probe Report")) #expect(report.markdownSummary.contains("resets in 3h")) + #expect(report.markdownSummary.contains("Network Candidates")) + #expect(report.markdownSummary.contains("/backend-api/accounts/check")) + #expect(report.markdownSummary.contains("subscription_plan")) #expect(report.markdownSummary.contains("authorization headers")) } diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index f5f3f97..d92bff4 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -80,6 +80,37 @@ The widget should make confidence visible. Good copy examples: - `Save fast mode: projected to run out 18h before reset.` - `Needs calibration: open ChatGPT and set reset time.` +## Local Probe And Every Code Evidence + +The first OpenAI Limit Probe run confirmed the uncomfortable but useful shape of +the problem: + +- ChatGPT visible text exposed plan/model language such as model names, `Pro`, + `Instant`, and `Thinking`, but did not expose a remaining-message counter or a + reset time before exhaustion. +- Sanitized network response-shape scanning found account entitlement and plan + fields, including subscription-plan style field names, but no obvious + `remaining_messages`, `used`, `reset_at`, weekly allowance, or five-hour + allowance fields. +- The probe should remain useful as a diagnostic harness because it can detect + if OpenAI later starts exposing cleaner fields, and it can produce redacted + evidence across multiple accounts. + +The nearby Every Code source is also instructive. It stores and displays +Codex-specific rate-limit snapshots when the Codex backend provides percentage +and reset-window data, and it reacts to `usage_limit_reached` errors that include +`plan_type` and `resets_in_seconds`. That is valuable prior art for Context +Panel's normalized snapshot model, but it is not evidence of a clean ChatGPT +subscription allowance API. Those snapshots are tied to Codex session/backend +traffic and local usage history rather than general ChatGPT web subscription +counters. + +Implication: v1 should not promise exact ChatGPT subscription remaining counts +unless the probe finds a provider-exposed counter. The product can still answer +the fast-mode question by combining user-entered/reset-observed windows, +local-event counting from install time, conservative default allowances, burn-rate +calibration, and explicit confidence labels. + ## Product Decisions - Treat `unknown`, `manual`, `observed`, and `official` as distinct confidence From 56b2f4b662f1b3f69d0c1c918115219bcd561bcc Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 19:40:11 -0400 Subject: [PATCH 09/34] Clarify Every Code rate limit source --- docs/provider-usage-access.md | 40 +++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index d92bff4..fc3c996 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -96,20 +96,32 @@ the problem: if OpenAI later starts exposing cleaner fields, and it can produce redacted evidence across multiple accounts. -The nearby Every Code source is also instructive. It stores and displays -Codex-specific rate-limit snapshots when the Codex backend provides percentage -and reset-window data, and it reacts to `usage_limit_reached` errors that include -`plan_type` and `resets_in_seconds`. That is valuable prior art for Context -Panel's normalized snapshot model, but it is not evidence of a clean ChatGPT -subscription allowance API. Those snapshots are tied to Codex session/backend -traffic and local usage history rather than general ChatGPT web subscription -counters. - -Implication: v1 should not promise exact ChatGPT subscription remaining counts -unless the probe finds a provider-exposed counter. The product can still answer -the fast-mode question by combining user-entered/reset-observed windows, -local-event counting from install time, conservative default allowances, burn-rate -calibration, and explicit confidence labels. +The nearby Every Code source is also instructive. It does not derive Codex +rate-limit snapshots from local token counts. It sends authenticated requests to +the ChatGPT Codex backend and parses server-reported `x-codex-*` response +headers into percentage and reset-window snapshots. The local usage files are a +cache of the latest server snapshot plus local token history, which explains why +the displayed limit pressure reflects cloud and other-machine usage for the same +account. + +Every Code also has a deliberate refresh path: it sends a tiny `"ok"` prompt via +the selected account, waits for a `RateLimits` event from response headers, then +persists the snapshot and updates the `/limits` UI. Separately, when the backend +returns `usage_limit_reached`, it records `plan_type`, `resets_in_seconds`, and +the reached-limit type as a hint. + +That is stronger evidence than visible ChatGPT UI scraping for Codex-style +limits, but it is still product-surface-specific. Context Panel should separate +`OpenAI ChatGPT subscription UI counters` from `OpenAI Codex backend limit +headers`. The latter looks viable as an automated adapter if Context Panel can +reuse the same authenticated account flow safely. + +Implication: v1 should not promise exact general ChatGPT subscription remaining +counts unless the probe finds a provider-exposed counter. For Codex/Fast Mode, +though, we should build a first-class OpenAI Codex adapter around the server +headers Every Code already uses, then fall back to user-entered/reset-observed +windows, local-event counting, conservative defaults, burn-rate calibration, and +explicit confidence labels when those headers are unavailable. ## Product Decisions From ff4beda35ac34515ed75efc35b298332e812c634 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 19:45:55 -0400 Subject: [PATCH 10/34] Focus Codex connector on Every Code cache --- docs/provider-usage-access.md | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index fc3c996..e5f5a25 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -118,10 +118,33 @@ reuse the same authenticated account flow safely. Implication: v1 should not promise exact general ChatGPT subscription remaining counts unless the probe finds a provider-exposed counter. For Codex/Fast Mode, -though, we should build a first-class OpenAI Codex adapter around the server -headers Every Code already uses, then fall back to user-entered/reset-observed -windows, local-event counting, conservative defaults, burn-rate calibration, and -explicit confidence labels when those headers are unavailable. +though, we should build a cache-only OpenAI Codex adapter around Every Code's +local `usage/*.json` snapshots. Every Code updates that cache as the user works, +so Context Panel should read it without making provider requests, touching auth +files, or trying to refresh limits itself. + +Upstream Codex CLI has related rate-limit support, but the source shape is +different. It parses the same Codex rate-limit concepts and exposes an +`account/rateLimits/read` app-server request that fetches live backend snapshots, +including multi-bucket snapshots keyed by `limit_id`. In the checked source, it +does not appear to write the Every Code-style `usage/*.json` disk cache. Treat +upstream Codex as a later live/app-server fallback, not as the v1 cache source. + +### Every Code Cache Connector + +V1 connector scope: + +- Resolve `CODE_HOME`, then `CODEX_HOME`, then default to `~/.code`. +- Read only `$CODE_HOME/usage/*.json`. +- Never read auth files, token files, debug logs, history files, or config files + for the connector. +- Parse `rate_limit.snapshot`, `observed_at`, `primary_next_reset_at`, + `secondary_next_reset_at`, `last_usage_limit_hit_at`, and `plan`. +- Normalize primary and secondary windows as Codex limits with observed + confidence and freshness state. +- Mark the connector stale when `observed_at` or `last_updated` is older than a + conservative threshold; do not trigger refreshes. +- Show account IDs only as short local labels unless the user assigns names. ## Product Decisions From f19f3032846daaac8cb7e99be80bc705915c8080 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 20:05:33 -0400 Subject: [PATCH 11/34] Prioritize live Codex rate limit API --- docs/provider-usage-access.md | 53 ++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index e5f5a25..7936cbe 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -96,13 +96,21 @@ the problem: if OpenAI later starts exposing cleaner fields, and it can produce redacted evidence across multiple accounts. -The nearby Every Code source is also instructive. It does not derive Codex -rate-limit snapshots from local token counts. It sends authenticated requests to -the ChatGPT Codex backend and parses server-reported `x-codex-*` response -headers into percentage and reset-window snapshots. The local usage files are a -cache of the latest server snapshot plus local token history, which explains why -the displayed limit pressure reflects cloud and other-machine usage for the same -account. +Codex-family tooling exposes a stronger path for Codex/Fast Mode. Upstream +Codex CLI has an app-server method, `account/rateLimits/read`, backed by a +backend client call that fetches live snapshots from the ChatGPT Codex backend: +`GET /backend-api/wham/usage` for ChatGPT-backed auth, or `/api/codex/usage` for +Codex API-style deployments. The payload maps into rate-limit snapshots with +primary and secondary windows, reset times, plan type, credits, reached-limit +classification, and additional buckets keyed by `limit_id`. + +Every Code is useful as a fallback and validation source. It does not derive +Codex rate-limit snapshots from local token counts. It sends authenticated +requests to the ChatGPT Codex backend, parses server-reported `x-codex-*` +response headers into percentage and reset-window snapshots, and persists the +latest server snapshot under local usage files. The local files are a cache of +server state plus local token history, which explains why displayed limit +pressure reflects cloud and other-machine usage for the same account. Every Code also has a deliberate refresh path: it sends a tiny `"ok"` prompt via the selected account, waits for a `RateLimits` event from response headers, then @@ -118,21 +126,28 @@ reuse the same authenticated account flow safely. Implication: v1 should not promise exact general ChatGPT subscription remaining counts unless the probe finds a provider-exposed counter. For Codex/Fast Mode, -though, we should build a cache-only OpenAI Codex adapter around Every Code's -local `usage/*.json` snapshots. Every Code updates that cache as the user works, -so Context Panel should read it without making provider requests, touching auth -files, or trying to refresh limits itself. +though, the preferred path is a live OpenAI Codex limits connector using the same +shape as Codex CLI's `account/rateLimits/read`/`get_rate_limits_many()` flow. If +that cannot be made stable or safely testable, fall back to Every Code's local +`usage/*.json` cache or Codex CLI's app-server request. -Upstream Codex CLI has related rate-limit support, but the source shape is -different. It parses the same Codex rate-limit concepts and exposes an -`account/rateLimits/read` app-server request that fetches live backend snapshots, -including multi-bucket snapshots keyed by `limit_id`. In the checked source, it -does not appear to write the Every Code-style `usage/*.json` disk cache. Treat -upstream Codex as a later live/app-server fallback, not as the v1 cache source. +### Codex Limits Connector -### Every Code Cache Connector +Preferred v1 connector scope: -V1 connector scope: +- Fetch live Codex limits using the Codex backend usage endpoint shape. +- Support primary and secondary windows, reset times, plan type, credits, + reached-limit classification, and additional `limit_id` buckets. +- Keep auth handling isolated and redacted; never log tokens, cookies, + authorization headers, account IDs, emails, or raw response bodies. +- Expose a diagnostic probe that reports only sanitized structure, percentages, + reset timing, bucket labels, and staleness. +- Mark this as an OpenAI Codex/Fast Mode source, not a general ChatGPT + subscription counter. + +### Every Code Cache Fallback + +Fallback connector scope: - Resolve `CODE_HOME`, then `CODEX_HOME`, then default to `~/.code`. - Read only `$CODE_HOME/usage/*.json`. From 696dab2a5cb83cde3468acdb5677563ccd076cbf Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Tue, 5 May 2026 20:28:21 -0400 Subject: [PATCH 12/34] Add direct Codex rate limit probe --- Package.swift | 8 + Sources/CodexRateLimitProbe/main.swift | 182 ++++++++++++++++ .../ContextPanelCore/CodexRateLimits.swift | 197 ++++++++++++++++++ .../CodexRateLimitsTests.swift | 88 ++++++++ docs/provider-usage-access.md | 6 +- 5 files changed, 480 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexRateLimitProbe/main.swift create mode 100644 Sources/ContextPanelCore/CodexRateLimits.swift create mode 100644 Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift diff --git a/Package.swift b/Package.swift index 350ac38..ba101d8 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,10 @@ let package = Package( .executable( name: "OpenAILimitProbe", targets: ["OpenAILimitProbe"] + ), + .executable( + name: "CodexRateLimitProbe", + targets: ["CodexRateLimitProbe"] ) ], targets: [ @@ -31,6 +35,10 @@ let package = Package( name: "OpenAILimitProbe", dependencies: ["ContextPanelCore"] ), + .executableTarget( + name: "CodexRateLimitProbe", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/Sources/CodexRateLimitProbe/main.swift b/Sources/CodexRateLimitProbe/main.swift new file mode 100644 index 0000000..d37e68d --- /dev/null +++ b/Sources/CodexRateLimitProbe/main.swift @@ -0,0 +1,182 @@ +import ContextPanelCore +import Foundation + +struct CodexProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct AuthFile: Decodable { + let tokens: TokenData? +} + +struct TokenData: Decodable { + let accessToken: String + let accountID: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case accountID = "account_id" + case idToken = "id_token" + } +} + +struct ProbeConfiguration { + let authPath: String + let endpoint: URL + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var authPath: String? + var endpoint = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--auth": + guard let value = iterator.next() else { + throw CodexProbeError(message: "--auth requires a path") + } + authPath = value + case "--endpoint": + guard let value = iterator.next(), let url = URL(string: value) else { + throw CodexProbeError(message: "--endpoint requires an absolute URL") + } + endpoint = url + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw CodexProbeError(message: "unknown argument: \(argument)") + } + } + + return ProbeConfiguration( + authPath: authPath ?? defaultAuthPath(), + endpoint: endpoint + ) + } + + private static func defaultAuthPath() -> String { + let environment = ProcessInfo.processInfo.environment + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + let codexHome = environment["CODEX_HOME"] ?? "\(home)/.codex" + return "\(codexHome)/auth.json" + } + + private static func printHelp() { + print(""" + Usage: swift run CodexRateLimitProbe [--auth /path/to/auth.json] [--endpoint URL] + + Calls the live Codex usage endpoint directly and prints only a redacted + summary of limit buckets, windows, plan type, and credits. Tokens, + account identifiers, emails, headers, and raw response bodies are never + printed. + """) + } +} + +@main +struct CodexRateLimitProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let auth = try loadAuth(path: configuration.authPath) + let data = try await fetchUsage(endpoint: configuration.endpoint, auth: auth) + let snapshots = try CodexUsagePayloadParser.snapshots(from: data) + printSummary(snapshots: snapshots, endpoint: configuration.endpoint, authPath: configuration.authPath) + } catch { + fputs("CodexRateLimitProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func loadAuth(path: String) throws -> TokenData { + let url = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) + let data = try Data(contentsOf: url) + let auth = try JSONDecoder().decode(AuthFile.self, from: data) + guard let tokens = auth.tokens, !tokens.accessToken.isEmpty else { + throw CodexProbeError(message: "auth file does not contain ChatGPT token auth") + } + return tokens + } + + private static func fetchUsage(endpoint: URL, auth: TokenData) async throws -> Data { + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + request.setValue("Bearer \(auth.accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let accountID = auth.accountID ?? accountID(fromIDToken: auth.idToken) { + request.setValue(accountID, forHTTPHeaderField: "ChatGPT-Account-Id") + } + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw CodexProbeError(message: "usage endpoint returned a non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw CodexProbeError(message: "usage endpoint returned HTTP \(http.statusCode); raw body redacted") + } + return data + } + + private static func accountID(fromIDToken token: String?) -> String? { + guard let token else { return nil } + let parts = token.split(separator: ".") + guard parts.count >= 2 else { return nil } + var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while payload.count % 4 != 0 { + payload.append("=") + } + guard + let data = Data(base64Encoded: payload), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let auth = object["https://api.openai.com/auth"] as? [String: Any] + else { + return nil + } + return auth["chatgpt_account_id"] as? String + } + + private static func printSummary(snapshots: [CodexRateLimitSnapshot], endpoint: URL, authPath: String) { + print("Codex live usage endpoint probe") + print("endpoint: \(endpoint.absoluteString)") + print("auth: \(redactedAuthPath(authPath))") + print("snapshots: \(snapshots.count)") + print("redacted: tokens, account identifiers, emails, headers, raw response bodies") + print("") + + for snapshot in snapshots { + print("- \(snapshot.displayName) [\(snapshot.id)]") + print(" plan: \(snapshot.planType)") + print(" primary: \(format(window: snapshot.primary))") + print(" secondary: \(format(window: snapshot.secondary))") + if let credits = snapshot.credits { + print(" credits: has=\(credits.hasCredits) unlimited=\(credits.unlimited) balance=\(credits.balance ?? "nil")") + } + if let reached = snapshot.rateLimitReachedType { + print(" reached: \(reached.rawValue)") + } + } + } + + private static func format(window: CodexRateLimitWindow?) -> String { + guard let window else { return "none" } + let percent = String(format: "%.0f%%", window.usedPercent) + let duration = window.windowMinutes.map { "\($0)m" } ?? "unknown window" + let reset = window.resetsAt.map { ISO8601DateFormatter().string(from: $0) } ?? "unknown reset" + return "\(percent) used / \(duration) / resets \(reset)" + } + + private static func redactedAuthPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + if expanded.hasPrefix(home) { + return "~" + expanded.dropFirst(home.count) + } + return URL(fileURLWithPath: expanded).lastPathComponent + } +} + diff --git a/Sources/ContextPanelCore/CodexRateLimits.swift b/Sources/ContextPanelCore/CodexRateLimits.swift new file mode 100644 index 0000000..2cd9131 --- /dev/null +++ b/Sources/ContextPanelCore/CodexRateLimits.swift @@ -0,0 +1,197 @@ +import Foundation + +public enum CodexRateLimitReachedType: String, Codable, Equatable, Sendable { + case rateLimitReached = "rate_limit_reached" + case workspaceOwnerCreditsDepleted = "workspace_owner_credits_depleted" + case workspaceMemberCreditsDepleted = "workspace_member_credits_depleted" + case workspaceOwnerUsageLimitReached = "workspace_owner_usage_limit_reached" + case workspaceMemberUsageLimitReached = "workspace_member_usage_limit_reached" + case unknown +} + +public struct CodexRateLimitWindow: Codable, Equatable, Sendable { + public let usedPercent: Double + public let windowMinutes: Int? + public let resetsAt: Date? + + public init(usedPercent: Double, windowMinutes: Int?, resetsAt: Date?) { + self.usedPercent = max(0, min(usedPercent, 100)) + self.windowMinutes = windowMinutes + self.resetsAt = resetsAt + } +} + +public struct CodexCreditsSnapshot: Codable, Equatable, Sendable { + public let hasCredits: Bool + public let unlimited: Bool + public let balance: String? + + public init(hasCredits: Bool, unlimited: Bool, balance: String?) { + self.hasCredits = hasCredits + self.unlimited = unlimited + self.balance = balance + } +} + +public struct CodexRateLimitSnapshot: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let limitName: String? + public let planType: String + public let primary: CodexRateLimitWindow? + public let secondary: CodexRateLimitWindow? + public let credits: CodexCreditsSnapshot? + public let rateLimitReachedType: CodexRateLimitReachedType? + + public init( + id: String, + limitName: String?, + planType: String, + primary: CodexRateLimitWindow?, + secondary: CodexRateLimitWindow?, + credits: CodexCreditsSnapshot?, + rateLimitReachedType: CodexRateLimitReachedType? + ) { + self.id = id + self.limitName = limitName + self.planType = planType + self.primary = primary + self.secondary = secondary + self.credits = credits + self.rateLimitReachedType = rateLimitReachedType + } + + public var displayName: String { + limitName ?? id + } +} + +public enum CodexUsagePayloadParser { + public static func snapshots(from data: Data) throws -> [CodexRateLimitSnapshot] { + let payload = try JSONDecoder().decode(CodexUsagePayload.self, from: data) + return snapshots(from: payload) + } + + private static func snapshots(from payload: CodexUsagePayload) -> [CodexRateLimitSnapshot] { + var snapshots = [ + CodexRateLimitSnapshot( + id: "codex", + limitName: nil, + planType: payload.planType, + primary: payload.rateLimit?.primaryWindow?.normalizedWindow, + secondary: payload.rateLimit?.secondaryWindow?.normalizedWindow, + credits: payload.credits?.normalizedCredits, + rateLimitReachedType: payload.rateLimitReachedType?.normalizedKind + ) + ] + + snapshots.append(contentsOf: payload.additionalRateLimits.map { additional in + CodexRateLimitSnapshot( + id: additional.meteredFeature, + limitName: additional.limitName, + planType: payload.planType, + primary: additional.rateLimit?.primaryWindow?.normalizedWindow, + secondary: additional.rateLimit?.secondaryWindow?.normalizedWindow, + credits: nil, + rateLimitReachedType: nil + ) + }) + + return snapshots + } +} + +private struct CodexUsagePayload: Decodable { + let planType: String + let rateLimit: CodexRateLimitDetails? + let credits: CodexCreditsDetails? + let additionalRateLimits: [CodexAdditionalRateLimitDetails] + let rateLimitReachedType: CodexReachedType? + + enum CodingKeys: String, CodingKey { + case planType = "plan_type" + case rateLimit = "rate_limit" + case credits + case additionalRateLimits = "additional_rate_limits" + case rateLimitReachedType = "rate_limit_reached_type" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + planType = try container.decodeIfPresent(String.self, forKey: .planType) ?? "unknown" + rateLimit = try container.decodeIfPresent(CodexRateLimitDetails.self, forKey: .rateLimit) + credits = try container.decodeIfPresent(CodexCreditsDetails.self, forKey: .credits) + additionalRateLimits = try container.decodeIfPresent([CodexAdditionalRateLimitDetails].self, forKey: .additionalRateLimits) ?? [] + rateLimitReachedType = try container.decodeIfPresent(CodexReachedType.self, forKey: .rateLimitReachedType) + } +} + +private struct CodexRateLimitDetails: Decodable { + let primaryWindow: CodexWindowSnapshot? + let secondaryWindow: CodexWindowSnapshot? + + enum CodingKeys: String, CodingKey { + case primaryWindow = "primary_window" + case secondaryWindow = "secondary_window" + } +} + +private struct CodexWindowSnapshot: Decodable { + let usedPercent: Double + let limitWindowSeconds: Int? + let resetAt: Int? + + enum CodingKeys: String, CodingKey { + case usedPercent = "used_percent" + case limitWindowSeconds = "limit_window_seconds" + case resetAt = "reset_at" + } + + var normalizedWindow: CodexRateLimitWindow { + CodexRateLimitWindow( + usedPercent: usedPercent, + windowMinutes: limitWindowSeconds.map { max(($0 + 59) / 60, 0) }, + resetsAt: resetAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + } +} + +private struct CodexCreditsDetails: Decodable { + let hasCredits: Bool + let unlimited: Bool + let balance: String? + + enum CodingKeys: String, CodingKey { + case hasCredits = "has_credits" + case unlimited + case balance + } + + var normalizedCredits: CodexCreditsSnapshot { + CodexCreditsSnapshot(hasCredits: hasCredits, unlimited: unlimited, balance: balance) + } +} + +private struct CodexAdditionalRateLimitDetails: Decodable { + let limitName: String + let meteredFeature: String + let rateLimit: CodexRateLimitDetails? + + enum CodingKeys: String, CodingKey { + case limitName = "limit_name" + case meteredFeature = "metered_feature" + case rateLimit = "rate_limit" + } +} + +private struct CodexReachedType: Decodable { + let kind: CodexRateLimitReachedType + + enum CodingKeys: String, CodingKey { + case kind = "type" + } + + var normalizedKind: CodexRateLimitReachedType { + kind + } +} + diff --git a/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift new file mode 100644 index 0000000..65acbd2 --- /dev/null +++ b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift @@ -0,0 +1,88 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func codexUsagePayloadParserNormalizesPrimarySecondaryAndAdditionalWindows() throws { + let json = #""" + { + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 45, + "limit_window_seconds": 18000, + "reset_after_seconds": 14000, + "reset_at": 1788393600 + }, + "secondary_window": { + "used_percent": 36, + "limit_window_seconds": 604800, + "reset_after_seconds": 500000, + "reset_at": 1788998400 + } + }, + "credits": { + "has_credits": true, + "unlimited": false, + "balance": "0" + }, + "additional_rate_limits": [ + { + "limit_name": "GPT-5.3-Codex-Spark", + "metered_feature": "codex_bengalfox", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 1, + "limit_window_seconds": 18000, + "reset_after_seconds": 1000, + "reset_at": 1788400000 + }, + "secondary_window": null + } + } + ], + "rate_limit_reached_type": { + "type": "workspace_member_usage_limit_reached" + } + } + """# + + let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8)) + + #expect(snapshots.count == 2) + #expect(snapshots[0].id == "codex") + #expect(snapshots[0].planType == "pro") + #expect(snapshots[0].primary?.usedPercent == 45) + #expect(snapshots[0].primary?.windowMinutes == 300) + #expect(snapshots[0].secondary?.windowMinutes == 10080) + #expect(snapshots[0].credits == CodexCreditsSnapshot(hasCredits: true, unlimited: false, balance: "0")) + #expect(snapshots[0].rateLimitReachedType == .workspaceMemberUsageLimitReached) + #expect(snapshots[1].id == "codex_bengalfox") + #expect(snapshots[1].limitName == "GPT-5.3-Codex-Spark") + #expect(snapshots[1].primary?.usedPercent == 1) + #expect(snapshots[1].credits == nil) +} + +@Test func codexUsagePayloadParserHandlesMissingLimitDetails() throws { + let json = #""" + { + "plan_type": "plus", + "rate_limit": null, + "additional_rate_limits": null, + "credits": null + } + """# + + let snapshots = try CodexUsagePayloadParser.snapshots(from: Data(json.utf8)) + + #expect(snapshots.count == 1) + #expect(snapshots[0].id == "codex") + #expect(snapshots[0].planType == "plus") + #expect(snapshots[0].primary == nil) + #expect(snapshots[0].secondary == nil) +} + diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index 7936cbe..aec879c 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -135,7 +135,8 @@ that cannot be made stable or safely testable, fall back to Every Code's local Preferred v1 connector scope: -- Fetch live Codex limits using the Codex backend usage endpoint shape. +- Fetch live Codex limits directly from the Codex backend usage endpoint shape: + `GET https://chatgpt.com/backend-api/wham/usage` for ChatGPT-backed auth. - Support primary and secondary windows, reset times, plan type, credits, reached-limit classification, and additional `limit_id` buckets. - Keep auth handling isolated and redacted; never log tokens, cookies, @@ -144,6 +145,9 @@ Preferred v1 connector scope: reset timing, bucket labels, and staleness. - Mark this as an OpenAI Codex/Fast Mode source, not a general ChatGPT subscription counter. +- Do not require the Codex CLI binary or app server at runtime. The local + `CodexRateLimitProbe` executable exists to prove the direct call path against + an existing Codex `auth.json` while printing only redacted summaries. ### Every Code Cache Fallback From 577773b339d69f5ae09ef579e3003a744f606a05 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 09:30:35 -0400 Subject: [PATCH 13/34] Model OpenAI usage as percent pressure --- Sources/ContextPanelCore/LimitProbe.swift | 6 +- Sources/ContextPanelCore/UsageLimit.swift | 13 ++++ .../ContextPanelPreview/SampleUsageData.swift | 7 ++- .../FastModeForecastTests.swift | 1 + .../LimitProbeTests.swift | 15 ++--- .../UsageLimitTests.swift | 17 +++++ docs/local-limit-probe.md | 13 ++-- docs/provider-usage-access.md | 62 ++++++++++--------- 8 files changed, 87 insertions(+), 47 deletions(-) diff --git a/Sources/ContextPanelCore/LimitProbe.swift b/Sources/ContextPanelCore/LimitProbe.swift index 9e5491e..144c9e0 100644 --- a/Sources/ContextPanelCore/LimitProbe.swift +++ b/Sources/ContextPanelCore/LimitProbe.swift @@ -10,7 +10,7 @@ public enum ProbeSource: String, Codable, Equatable, Sendable { public enum ProbeSignalKind: String, Codable, Equatable, Sendable { case resetLanguage case relativeDuration - case messageLimit + case usagePressure case limitReached case modelAvailability case planLanguage @@ -155,7 +155,7 @@ public enum LimitProbeScanner { private static let patterns: [(ProbeSignalKind, NSRegularExpression)] = [ (.resetLanguage, regex(#"(?i)\b(reset|resets|refresh|refreshes|available again)\b.{0,80}"#)), (.relativeDuration, regex(#"(?i)\b(in\s+)?\d+\s*(m|min|mins|minutes|h|hr|hrs|hour|hours|day|days|week|weeks)\b"#)), - (.messageLimit, regex(#"(?i)\b\d+[\d,]*\s*(messages?|prompts?)\s*(every|per|/)?\s*\d*\s*(hours?|days?|weeks?)?\b"#)), + (.usagePressure, regex(#"(?i)\b\d+[\d,]*\s*(%|percent|tokens?)\s*(used|every|per|/)?\s*\d*\s*(hours?|days?|weeks?)?\b"#)), (.limitReached, regex(#"(?i)\b(limit reached|reached your limit|you.ve reached|unavailable|try again)\b.{0,80}"#)), (.modelAvailability, regex(#"(?i)\b(GPT|Thinking|fast mode|model picker|available|unavailable)\b.{0,80}"#)), (.planLanguage, regex(#"(?i)\b(Free|Plus|Pro|Team|Business|Enterprise|Go)\b.{0,80}"#)) @@ -202,7 +202,7 @@ public enum LimitProbeScanner { ) -> [LimitProbeObservation] { let candidates = fieldNames.filter { field in let lower = field.lowercased() - return ["limit", "usage", "remaining", "reset", "quota", "message", "model", "plan", "cap"].contains { lower.contains($0) } + return ["limit", "usage", "percent", "token", "remaining", "reset", "quota", "model", "plan", "cap"].contains { lower.contains($0) } } return Array(Set(candidates)).sorted().map { field in diff --git a/Sources/ContextPanelCore/UsageLimit.swift b/Sources/ContextPanelCore/UsageLimit.swift index 027bdee..d344745 100644 --- a/Sources/ContextPanelCore/UsageLimit.swift +++ b/Sources/ContextPanelCore/UsageLimit.swift @@ -48,6 +48,15 @@ public enum UsageConfidence: String, Codable, Equatable, Sendable { case unknown } +public enum UsageUnit: String, Codable, Equatable, Sendable { + case percent + case tokens + case requests + case credits + case units + case unknown +} + public struct ProviderAccount: Codable, Equatable, Identifiable, Sendable { public let id: String public let provider: Provider @@ -68,6 +77,7 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { public let accountID: String public let accountName: String public let label: String + public let unit: UsageUnit public let used: Int? public let limit: Int? public let resetsAt: Date? @@ -82,6 +92,7 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { accountID: String, accountName: String, label: String, + unit: UsageUnit = .units, used: Int?, limit: Int?, resetsAt: Date? = nil, @@ -102,6 +113,7 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { self.accountID = accountID self.accountName = accountName self.label = label + self.unit = unit self.used = used self.limit = limit self.resetsAt = resetsAt @@ -117,6 +129,7 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { accountID: "default", accountName: provider.displayName, label: label, + unit: .units, used: used, limit: limit, resetsAt: resetsAt, diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift index c4ae20a..6bcc23a 100644 --- a/Sources/ContextPanelPreview/SampleUsageData.swift +++ b/Sources/ContextPanelPreview/SampleUsageData.swift @@ -13,6 +13,7 @@ enum SampleUsageData { accountID: "openai-personal", accountName: "Personal", label: "GPT-5", + unit: .percent, used: 72, limit: 100, resetsAt: referenceNow.addingTimeInterval(12_000), @@ -24,8 +25,9 @@ enum SampleUsageData { accountID: "openai-work", accountName: "Work", label: "GPT-5 Thinking", + unit: .percent, used: 18, - limit: 40, + limit: 100, resetsAt: referenceNow.addingTimeInterval(86_400), lastUpdatedAt: referenceNow.addingTimeInterval(-120), confidence: .estimated, @@ -36,8 +38,9 @@ enum SampleUsageData { accountID: "openai-team", accountName: "Team", label: "Image generation", + unit: .percent, used: 49, - limit: 50, + limit: 100, resetsAt: referenceNow.addingTimeInterval(2_520), lastUpdatedAt: referenceNow.addingTimeInterval(-120), confidence: .observed diff --git a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift index 303e97f..808d0dc 100644 --- a/Tests/ContextPanelCoreTests/FastModeForecastTests.swift +++ b/Tests/ContextPanelCoreTests/FastModeForecastTests.swift @@ -103,6 +103,7 @@ private func openAILimit( accountID: "openai-\(accountName.lowercased())", accountName: accountName, label: "GPT-5 Thinking", + unit: .percent, used: used, limit: limit, resetsAt: resetsInHours.map { now.addingTimeInterval($0 * 3_600) }, diff --git a/Tests/ContextPanelCoreTests/LimitProbeTests.swift b/Tests/ContextPanelCoreTests/LimitProbeTests.swift index 2e5e78d..dc63c59 100644 --- a/Tests/ContextPanelCoreTests/LimitProbeTests.swift +++ b/Tests/ContextPanelCoreTests/LimitProbeTests.swift @@ -6,7 +6,7 @@ import Testing @Test func visibleTextScannerFindsSubscriptionLimitSignals() { let text = """ GPT-5 Thinking unavailable. You have reached your weekly limit. - Try again in 3h. Plus includes 160 messages every 3 hours. + Try again in 3h. Pro shows 52 percent used in the 5 hour window. """ let observations = LimitProbeScanner.scanVisibleText(text, provider: .openAI) @@ -14,20 +14,21 @@ import Testing #expect(kinds.contains(.limitReached)) #expect(kinds.contains(.relativeDuration)) - #expect(kinds.contains(.messageLimit)) + #expect(kinds.contains(.usagePressure)) #expect(kinds.contains(.modelAvailability)) #expect(kinds.contains(.planLanguage)) } @Test func responseShapeScannerOnlyReportsCandidateFieldNames() { let observations = LimitProbeScanner.scanResponseShape( - fieldNames: ["id", "email", "message_cap", "reset_at", "avatar", "remaining_messages"], + fieldNames: ["id", "email", "used_percent", "reset_at", "avatar", "token_pressure"], provider: .openAI ) - #expect(observations.map(\.sanitizedEvidence).contains("message_cap")) - #expect(observations.map(\.sanitizedEvidence).contains("remaining_messages")) + #expect(observations.map(\.sanitizedEvidence).contains("reset_at")) #expect(!observations.map(\.sanitizedEvidence).contains("email")) + #expect(observations.map(\.sanitizedEvidence).contains("used_percent")) + #expect(observations.map(\.sanitizedEvidence).contains("token_pressure")) } @Test func probeEvidenceRedactsSecretsBeforeStorage() { @@ -89,11 +90,11 @@ import Testing status: 200, contentType: "application/json", bodySize: 120, - matchedFields: ["reset_at", "remaining_messages"] + matchedFields: ["reset_at", "used_percent"] ) #expect(event.pathHint == "/backend-api/conversation/[id]") - #expect(event.matchedFields == ["remaining_messages", "reset_at"]) + #expect(event.matchedFields == ["reset_at", "used_percent"]) } @Test func networkProbeEventRedactsIdentifiersInsideFieldNames() { diff --git a/Tests/ContextPanelCoreTests/UsageLimitTests.swift b/Tests/ContextPanelCoreTests/UsageLimitTests.swift index 838f260..5fb142f 100644 --- a/Tests/ContextPanelCoreTests/UsageLimitTests.swift +++ b/Tests/ContextPanelCoreTests/UsageLimitTests.swift @@ -65,3 +65,20 @@ import Testing @Test func providersCoverInitialScope() { #expect(Provider.allCases == [.openAI, .anthropic, .google]) } + +@Test func usageLimitCanRepresentPercentPressure() { + let limit = UsageLimit( + provider: .openAI, + accountID: "openai-personal", + accountName: "Personal", + label: "Codex weekly", + unit: .percent, + used: 38, + limit: 100, + confidence: .official + ) + + #expect(limit.unit == .percent) + #expect(limit.remaining == 62) + #expect(limit.usageRatio == 0.38) +} diff --git a/docs/local-limit-probe.md b/docs/local-limit-probe.md index 94ca918..2a3753e 100644 --- a/docs/local-limit-probe.md +++ b/docs/local-limit-probe.md @@ -6,8 +6,8 @@ Last updated: 2026-05-05. Context Panel needs to know whether subscription limits are exposed anywhere a logged-in user can legitimately see them. The first target is OpenAI ChatGPT -subscription usage: weekly Thinking limits, short rolling message windows, reset -times, model availability, and any remaining/used counters. If the approach works +subscription usage: weekly limits, short rolling windows, reset times, model +availability, and any percent or token pressure signals. If the approach works for OpenAI, the same diagnostic shape can be reused for Claude and Gemini. The probe is a local diagnostic tool, not a production data integration. It @@ -74,12 +74,13 @@ Initial OpenAI pages and states to inspect: Detection patterns: -- `reset`, `resets`, `refresh`, `available`, `limit`, `usage`, `messages`, - `weekly`, `every 3 hours`, `every 5 hours`, `Thinking`, `fast`, `temporary`. +- `reset`, `resets`, `refresh`, `available`, `limit`, `usage`, `percent`, + `tokens`, `weekly`, `every 3 hours`, `every 5 hours`, `Thinking`, `fast`, + `temporary`. - Dates and relative durations such as `tomorrow`, `in 42m`, `3h`, `5 hours`, `7 days`, `weekly`. -- JSON field names containing `limit`, `usage`, `remaining`, `reset`, `cap`, - `quota`, `message`, `model`, or `plan`. +- JSON field names containing `limit`, `usage`, `used_percent`, `token`, + `remaining`, `reset`, `cap`, `quota`, `model`, or `plan`. ## Capture Model diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index aec879c..fe0aec4 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -9,15 +9,15 @@ Context Panel should model two different worlds: - API/provider-console usage, where official usage APIs, cost APIs, quota APIs, rate-limit headers, or Cloud Monitoring data can provide real measurements. - Consumer chat subscription usage, where providers often expose limits and reset - timing in product UI but do not expose a stable public API for remaining - message allowance. + timing in product UI but do not expose a stable public API for the underlying + percent or token pressure. The OpenAI account use case needs special treatment. For ChatGPT-style weekly -message budgets, official OpenAI help currently documents weekly Thinking limits -and reset behavior, but not a reliable API for personal message usage counts. -Context Panel should therefore support local forecasting: multiple OpenAI -accounts, reset windows, manually or locally observed usage, burn-rate history, -and a clear answer to "am I safe to turn on fast mode?" +subscription limits, current product surfaces have moved away from a visible +message counter. Context Panel should model OpenAI usage as percent or token +pressure over reset windows: multiple OpenAI accounts, reset windows, observed +usage pressure, burn-rate history, and a clear answer to "am I safe to turn on +fast mode?" ## Forecast Requirement @@ -54,7 +54,7 @@ instead of pretending an estimate is exact. | Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence | | --- | --- | --- | --- | --- | --- | | OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | -| OpenAI ChatGPT accounts | No stable public API found for personal ChatGPT message allowance. Help docs say some model budgets expose reset date in the model picker and, for that documented budget, there is no way to check messages used. Current GPT-5.5 Thinking docs document weekly limits and pop-up behavior at exhaustion. | Weekly Thinking limits exist for Plus/Business. Older OpenAI help explicitly says weekly limits reset seven days after first use and the reset date is visible by hovering the model name. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, and local observation history. | Start with manual/assisted local tracking: account profile, plan/bucket defaults, user-entered or UI-observed reset time, local message counter, and forecast confidence. Avoid credential sharing and avoid automated extraction that could violate terms. | Medium for reset; low for used count without local tracking | +| OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | | Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | | Claude subscriptions and Claude Code seats | Public docs describe usage limits across Claude.ai, Claude Code, and Claude Desktop, but no stable public API for personal subscription allowance was found. Claude Code can show session cost for API-key usage. | Pro/Max/Team usage has session-based reset behavior; Claude Code Enterprise seats show reset time when a limit is reached. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Defer automated subscription tracking unless a supported local/official signal is found. Support manual observation later; prioritize Anthropic API first. | Medium for displayed limits; low for automation | | Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | @@ -62,15 +62,19 @@ instead of pretending an estimate is exact. ## OpenAI Fast-Mode Forecasting -For the user's immediate OpenAI need, the best product path is not a hidden -provider API. It is an honest local predictor: +For the user's immediate OpenAI need, the best product path is a live Codex +percent-window connector backed by an honest local predictor: 1. Add each OpenAI account separately. -2. Record plan and relevant buckets, such as GPT-5.5 Thinking weekly allowance. -3. Capture reset time from the UI when available, or let the user enter it. -4. Start a local usage ledger from the moment Context Panel is installed. -5. Allow manual correction when the provider UI reveals a reset or limit state. -6. Estimate standard and fast-mode burn rates from local usage history. +2. Record plan and relevant buckets, such as Codex/Fast Mode weekly and + five-hour windows. +3. Fetch current percent-used pressure and reset times when the Codex endpoint + is available. +4. Capture reset time from the UI when no endpoint signal exists, or let the + user enter it. +5. Start a local usage ledger from the moment Context Panel is installed. +6. Estimate standard and fast-mode burn rates in percent or tokens per hour + from local usage history. 7. Recommend when to enable fast mode only when the forecast has enough margin. The widget should make confidence visible. Good copy examples: @@ -86,12 +90,12 @@ The first OpenAI Limit Probe run confirmed the uncomfortable but useful shape of the problem: - ChatGPT visible text exposed plan/model language such as model names, `Pro`, - `Instant`, and `Thinking`, but did not expose a remaining-message counter or a + `Instant`, and `Thinking`, but did not expose a percent/token counter or a reset time before exhaustion. - Sanitized network response-shape scanning found account entitlement and plan fields, including subscription-plan style field names, but no obvious - `remaining_messages`, `used`, `reset_at`, weekly allowance, or five-hour - allowance fields. + `used_percent`, token pressure, `reset_at`, weekly allowance, or five-hour + allowance fields outside the Codex usage surface. - The probe should remain useful as a diagnostic harness because it can detect if OpenAI later starts exposing cleaner fields, and it can produce redacted evidence across multiple accounts. @@ -120,16 +124,16 @@ the reached-limit type as a hint. That is stronger evidence than visible ChatGPT UI scraping for Codex-style limits, but it is still product-surface-specific. Context Panel should separate -`OpenAI ChatGPT subscription UI counters` from `OpenAI Codex backend limit -headers`. The latter looks viable as an automated adapter if Context Panel can -reuse the same authenticated account flow safely. +`OpenAI ChatGPT product UI hints` from `OpenAI Codex backend percent windows`. +The latter looks viable as an automated adapter if Context Panel can reuse the +same authenticated account flow safely. -Implication: v1 should not promise exact general ChatGPT subscription remaining -counts unless the probe finds a provider-exposed counter. For Codex/Fast Mode, -though, the preferred path is a live OpenAI Codex limits connector using the same -shape as Codex CLI's `account/rateLimits/read`/`get_rate_limits_many()` flow. If -that cannot be made stable or safely testable, fall back to Every Code's local -`usage/*.json` cache or Codex CLI's app-server request. +Implication: v1 should not promise exact general ChatGPT subscription counters. +For Codex/Fast Mode, though, the preferred path is a live OpenAI Codex limits +connector using the same shape as Codex CLI's +`account/rateLimits/read`/`get_rate_limits_many()` flow. If that cannot be made +stable or safely testable, fall back to Every Code's local `usage/*.json` cache +or Codex CLI's app-server request. ### Codex Limits Connector @@ -143,8 +147,8 @@ Preferred v1 connector scope: authorization headers, account IDs, emails, or raw response bodies. - Expose a diagnostic probe that reports only sanitized structure, percentages, reset timing, bucket labels, and staleness. -- Mark this as an OpenAI Codex/Fast Mode source, not a general ChatGPT - subscription counter. +- Mark this as an OpenAI Codex/Fast Mode percent-window source, not a general + ChatGPT subscription counter. - Do not require the Codex CLI binary or app server at runtime. The local `CodexRateLimitProbe` executable exists to prove the direct call path against an existing Codex `auth.json` while printing only redacted summaries. From d1580bd750dc051b69ee9cd1f9b225ed6f58a947 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 10:46:17 -0400 Subject: [PATCH 14/34] Add Gemini and Claude provider probes --- Package.swift | 16 + README.md | 17 ++ Sources/ClaudeLimitProbe/main.swift | 148 +++++++++ .../ContextPanelCore/ClaudeLocalStatus.swift | 129 ++++++++ .../GeminiCodeAssistQuota.swift | 128 ++++++++ Sources/GeminiQuotaProbe/main.swift | 281 ++++++++++++++++++ .../ClaudeLocalStatusTests.swift | 73 +++++ .../GeminiCodeAssistQuotaTests.swift | 48 +++ docs/provider-usage-access.md | 60 +++- 9 files changed, 898 insertions(+), 2 deletions(-) create mode 100644 Sources/ClaudeLimitProbe/main.swift create mode 100644 Sources/ContextPanelCore/ClaudeLocalStatus.swift create mode 100644 Sources/ContextPanelCore/GeminiCodeAssistQuota.swift create mode 100644 Sources/GeminiQuotaProbe/main.swift create mode 100644 Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift create mode 100644 Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift diff --git a/Package.swift b/Package.swift index ba101d8..f9a6172 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,14 @@ let package = Package( .executable( name: "CodexRateLimitProbe", targets: ["CodexRateLimitProbe"] + ), + .executable( + name: "GeminiQuotaProbe", + targets: ["GeminiQuotaProbe"] + ), + .executable( + name: "ClaudeLimitProbe", + targets: ["ClaudeLimitProbe"] ) ], targets: [ @@ -39,6 +47,14 @@ let package = Package( name: "CodexRateLimitProbe", dependencies: ["ContextPanelCore"] ), + .executableTarget( + name: "GeminiQuotaProbe", + dependencies: ["ContextPanelCore"] + ), + .executableTarget( + name: "ClaudeLimitProbe", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/README.md b/README.md index a85a7d6..b2a74d3 100644 --- a/README.md +++ b/README.md @@ -51,3 +51,20 @@ Useful entry points: - [Product Goals](docs/product-goals.md) - [Architecture](docs/architecture.md) - [Repository Settings](docs/repo-settings.md) + +## Local Provider Probes + +The package includes development probes for validating provider limit signals +without printing secrets or raw provider responses: + +```sh +swift run CodexRateLimitProbe --auth ~/.codex/auth.json +swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json +swift run ClaudeLimitProbe +``` + +The Codex and Gemini probes can return live percent-window quota buckets for +their respective CLI-backed accounts. The Claude probe intentionally reports +only local auth/subscription metadata and local stats-cache freshness because a +live personal subscription allowance is not exposed through a clean local signal +yet. diff --git a/Sources/ClaudeLimitProbe/main.swift b/Sources/ClaudeLimitProbe/main.swift new file mode 100644 index 0000000..976d954 --- /dev/null +++ b/Sources/ClaudeLimitProbe/main.swift @@ -0,0 +1,148 @@ +import ContextPanelCore +import Foundation + +struct ClaudeProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct ProbeConfiguration { + let claudeBinary: String + let statsPath: String + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var claudeBinary = "claude" + var statsPath: String? + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--claude-bin": + guard let value = iterator.next() else { + throw ClaudeProbeError(message: "--claude-bin requires a path or executable name") + } + claudeBinary = value + case "--stats": + guard let value = iterator.next() else { + throw ClaudeProbeError(message: "--stats requires a path") + } + statsPath = value + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw ClaudeProbeError(message: "unknown argument: \(argument)") + } + } + + return ProbeConfiguration( + claudeBinary: claudeBinary, + statsPath: statsPath ?? defaultStatsPath() + ) + } + + private static func defaultStatsPath() -> String { + let environment = ProcessInfo.processInfo.environment + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + return "\(home)/.claude/stats-cache.json" + } + + private static func printHelp() { + print(""" + Usage: swift run ClaudeLimitProbe [--claude-bin claude] [--stats ~/.claude/stats-cache.json] + + Prints a redacted Claude local status summary. This probe intentionally + does not read Keychain secrets, token files, raw transcripts, emails, + account IDs, org IDs, or provider response bodies. + """) + } +} + +@main +struct ClaudeLimitProbe { + static func main() { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let authStatus = try loadAuthStatus(claudeBinary: configuration.claudeBinary) + let statsSummary = try loadStatsSummary(path: configuration.statsPath) + printSummary(authStatus: authStatus, statsSummary: statsSummary, statsPath: configuration.statsPath) + } catch { + fputs("ClaudeLimitProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func loadAuthStatus(claudeBinary: String) throws -> ClaudeAuthStatus { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [claudeBinary, "auth", "status", "--json"] + + let output = Pipe() + let error = Pipe() + process.standardOutput = output + process.standardError = error + + try process.run() + process.waitUntilExit() + + let data = output.fileHandleForReading.readDataToEndOfFile() + if process.terminationStatus != 0 { + throw ClaudeProbeError(message: "claude auth status failed; stderr redacted") + } + return try ClaudeAuthStatusParser.status(from: data) + } + + private static func loadStatsSummary(path: String) throws -> ClaudeStatsCacheSummary? { + let expanded = NSString(string: path).expandingTildeInPath + guard FileManager.default.fileExists(atPath: expanded) else { + return nil + } + let data = try Data(contentsOf: URL(fileURLWithPath: expanded)) + return try ClaudeStatsCacheParser.summary(from: data) + } + + private static func printSummary( + authStatus: ClaudeAuthStatus, + statsSummary: ClaudeStatsCacheSummary?, + statsPath: String + ) { + print("Claude local status probe") + print("auth logged in: \(authStatus.loggedIn)") + print("auth method: \(authStatus.authMethod)") + print("api provider: \(authStatus.apiProvider ?? "unknown")") + print("subscription type: \(authStatus.subscriptionType ?? "unknown")") + print("stats cache: \(redactedPath(statsPath))") + print("redacted: tokens, Keychain secrets, account identifiers, org identifiers, emails, raw transcripts, raw provider responses") + print("") + + guard let statsSummary else { + print("local stats cache: absent") + print("live subscription allowance: not exposed by this probe") + return + } + + print("local stats cache: present") + print("version: \(statsSummary.version.map(String.init) ?? "unknown")") + print("last computed: \(format(date: statsSummary.lastComputedDate))") + print("first session: \(format(date: statsSummary.firstSessionDate))") + print("total sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")") + print("total messages: \(statsSummary.totalMessages.map(String.init) ?? "unknown")") + print("model usage buckets: \(statsSummary.modelUsageCount)") + print("daily activity buckets: \(statsSummary.dailyActivityCount)") + print("live subscription allowance: not exposed by this probe") + } + + private static func format(date: Date?) -> String { + date.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown" + } + + private static func redactedPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + if expanded.hasPrefix(home) { + return "~" + expanded.dropFirst(home.count) + } + return URL(fileURLWithPath: expanded).lastPathComponent + } +} diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift new file mode 100644 index 0000000..8b80c47 --- /dev/null +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -0,0 +1,129 @@ +import Foundation + +public struct ClaudeAuthStatus: Codable, Equatable, Sendable { + public let loggedIn: Bool + public let authMethod: String + public let apiProvider: String? + public let subscriptionType: String? + + public init(loggedIn: Bool, authMethod: String, apiProvider: String?, subscriptionType: String?) { + self.loggedIn = loggedIn + self.authMethod = authMethod + self.apiProvider = apiProvider + self.subscriptionType = subscriptionType + } +} + +public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { + public let version: Int? + public let lastComputedDate: Date? + public let totalSessions: Int? + public let totalMessages: Int? + public let firstSessionDate: Date? + public let modelUsageCount: Int + public let dailyActivityCount: Int + + public init( + version: Int?, + lastComputedDate: Date?, + totalSessions: Int?, + totalMessages: Int?, + firstSessionDate: Date?, + modelUsageCount: Int, + dailyActivityCount: Int + ) { + self.version = version + self.lastComputedDate = lastComputedDate + self.totalSessions = totalSessions + self.totalMessages = totalMessages + self.firstSessionDate = firstSessionDate + self.modelUsageCount = modelUsageCount + self.dailyActivityCount = dailyActivityCount + } +} + +public enum ClaudeAuthStatusParser { + public static func status(from data: Data) throws -> ClaudeAuthStatus { + let payload = try JSONDecoder().decode(ClaudeAuthStatusPayload.self, from: data) + return ClaudeAuthStatus( + loggedIn: payload.loggedIn, + authMethod: payload.authMethod, + apiProvider: payload.apiProvider, + subscriptionType: payload.subscriptionType + ) + } +} + +public enum ClaudeStatsCacheParser { + public static func summary(from data: Data) throws -> ClaudeStatsCacheSummary { + let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeStatsCachePayload.self, from: data) + return ClaudeStatsCacheSummary( + version: payload.version, + lastComputedDate: payload.lastComputedDate, + totalSessions: payload.totalSessions, + totalMessages: payload.totalMessages, + firstSessionDate: payload.firstSessionDate, + modelUsageCount: payload.modelUsage?.count ?? 0, + dailyActivityCount: payload.dailyActivity?.count ?? 0 + ) + } +} + +private struct ClaudeAuthStatusPayload: Decodable { + let loggedIn: Bool + let authMethod: String + let apiProvider: String? + let subscriptionType: String? +} + +private struct ClaudeStatsCachePayload: Decodable { + let version: Int? + let lastComputedDate: Date? + let dailyActivity: ClaudeCountedCollection? + let modelUsage: [String: ClaudeDiscardedValue]? + let totalSessions: Int? + let totalMessages: Int? + let firstSessionDate: Date? +} + +private enum ClaudeCountedCollection: Decodable { + case dictionary([String: ClaudeDiscardedValue]) + case array([ClaudeDiscardedValue]) + + var count: Int { + switch self { + case let .dictionary(values): + values.count + case let .array(values): + values.count + } + } + + init(from decoder: Decoder) throws { + if let dictionary = try? [String: ClaudeDiscardedValue](from: decoder) { + self = .dictionary(dictionary) + return + } + self = .array(try [ClaudeDiscardedValue](from: decoder)) + } +} + +private struct ClaudeDiscardedValue: Decodable {} + +extension JSONDecoder { + static var contextPanelFlexibleDates: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = ContextPanelDateFormatting.date(from: value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected ISO 8601 date string" + ) + } + return decoder + } +} diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift new file mode 100644 index 0000000..f5022cd --- /dev/null +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -0,0 +1,128 @@ +import Foundation + +public struct GeminiQuotaBucket: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let modelID: String + public let remainingFraction: Double? + public let remainingAmount: Int? + public let resetsAt: Date? + + public init(modelID: String, remainingFraction: Double?, remainingAmount: Int?, resetsAt: Date?) { + self.id = modelID + self.modelID = modelID + self.remainingFraction = remainingFraction.map { max(0, min($0, 1)) } + self.remainingAmount = remainingAmount + self.resetsAt = resetsAt + } + + public var usedPercent: Double? { + remainingFraction.map { max(0, min((1 - $0) * 100, 100)) } + } + + public func usageLimit(accountID: String, accountName: String, observedAt: Date) -> UsageLimit { + UsageLimit( + provider: .google, + accountID: accountID, + accountName: accountName, + label: modelID, + unit: .percent, + used: usedPercent.map { Int($0.rounded()) }, + limit: usedPercent == nil ? nil : 100, + resetsAt: resetsAt, + lastUpdatedAt: observedAt, + confidence: .observed, + note: remainingAmount.map { "remaining amount: \($0)" } + ) + } +} + +public enum GeminiQuotaPayloadParser { + public static func buckets(from data: Data) throws -> [GeminiQuotaBucket] { + let payload = try JSONDecoder.contextPanelISO8601.decode(GeminiQuotaPayload.self, from: data) + return payload.buckets.map(\.normalizedBucket) + } +} + +private struct GeminiQuotaPayload: Decodable { + let buckets: [GeminiQuotaBucketPayload] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + buckets = try container.decodeIfPresent([GeminiQuotaBucketPayload].self, forKey: .buckets) ?? [] + } + + enum CodingKeys: String, CodingKey { + case buckets + } +} + +private struct GeminiQuotaBucketPayload: Decodable { + let modelID: String + let remainingFraction: Double? + let remainingAmount: Int? + let resetTime: Date? + + enum CodingKeys: String, CodingKey { + case modelID = "modelId" + case remainingFraction + case remainingAmount + case resetTime + } + + var normalizedBucket: GeminiQuotaBucket { + GeminiQuotaBucket( + modelID: modelID, + remainingFraction: remainingFraction, + remainingAmount: remainingAmount, + resetsAt: resetTime + ) + } +} + +extension JSONDecoder { + static var contextPanelISO8601: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + if let date = ContextPanelDateFormatting.date(from: value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Expected ISO 8601 date string" + ) + } + return decoder + } +} + +public enum ContextPanelDateFormatting { + public static func string(from date: Date) -> String { + internetDateFormatter().string(from: date) + } + + public static func date(from value: String) -> Date? { + internetDateFormatterWithFractionalSeconds().date(from: value) + ?? internetDateFormatter().date(from: value) + ?? dateOnlyFormatter().date(from: value) + } + + private static func internetDateFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + } + + private static func internetDateFormatterWithFractionalSeconds() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + + private static func dateOnlyFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate] + return formatter + } +} diff --git a/Sources/GeminiQuotaProbe/main.swift b/Sources/GeminiQuotaProbe/main.swift new file mode 100644 index 0000000..e69d68e --- /dev/null +++ b/Sources/GeminiQuotaProbe/main.swift @@ -0,0 +1,281 @@ +import ContextPanelCore +import Foundation + +struct GeminiProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct GeminiOAuthCredentials: Codable { + let accessToken: String? + let refreshToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + } +} + +struct GeminiRefreshResponse: Decodable { + let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } +} + +struct GeminiLoadCodeAssistResponse: Decodable { + let cloudaicompanionProject: String? + let currentTier: RedactedTier? + let paidTier: RedactedTier? +} + +struct RedactedTier: Decodable { + let name: String? +} + +struct ProbeConfiguration { + let authPath: String + let tokenEndpoint: URL + let codeAssistEndpoint: URL + let clientID: String + let clientSecret: String + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var authPath: String? + var tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")! + var codeAssistEndpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")! + var clientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] + var clientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--auth": + guard let value = iterator.next() else { + throw GeminiProbeError(message: "--auth requires a path") + } + authPath = value + case "--token-endpoint": + guard let value = iterator.next(), let url = URL(string: value) else { + throw GeminiProbeError(message: "--token-endpoint requires an absolute URL") + } + tokenEndpoint = url + case "--code-assist-endpoint": + guard let value = iterator.next(), let url = URL(string: value) else { + throw GeminiProbeError(message: "--code-assist-endpoint requires an absolute URL") + } + codeAssistEndpoint = url + case "--client-id": + guard let value = iterator.next(), !value.isEmpty else { + throw GeminiProbeError(message: "--client-id requires a value") + } + clientID = value + case "--client-secret": + guard let value = iterator.next(), !value.isEmpty else { + throw GeminiProbeError(message: "--client-secret requires a value") + } + clientSecret = value + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw GeminiProbeError(message: "unknown argument: \(argument)") + } + } + + guard let clientID, !clientID.isEmpty else { + throw GeminiProbeError(message: "set GEMINI_OAUTH_CLIENT_ID or pass --client-id") + } + guard let clientSecret, !clientSecret.isEmpty else { + throw GeminiProbeError(message: "set GEMINI_OAUTH_CLIENT_SECRET or pass --client-secret") + } + + return ProbeConfiguration( + authPath: authPath ?? defaultAuthPath(), + tokenEndpoint: tokenEndpoint, + codeAssistEndpoint: codeAssistEndpoint, + clientID: clientID, + clientSecret: clientSecret + ) + } + + private static func defaultAuthPath() -> String { + let environment = ProcessInfo.processInfo.environment + let home = environment["HOME"] ?? FileManager.default.homeDirectoryForCurrentUser.path + let geminiHome = environment["GEMINI_CLI_HOME"] ?? "\(home)/.gemini" + return "\(geminiHome)/oauth_creds.json" + } + + private static func printHelp() { + print(""" + Usage: swift run GeminiQuotaProbe [--auth /path/to/oauth_creds.json] + + Requires GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET, or the + equivalent --client-id and --client-secret flags. Use values from the + locally installed Gemini CLI; do not commit them to this repository. + + Uses Gemini CLI OAuth credentials to refresh an access token, asks the + Gemini Code Assist backend for the active project, then prints a redacted + quota summary. Tokens, account identifiers, project IDs, emails, + headers, and raw response bodies are never printed. + """) + } +} + +@main +struct GeminiQuotaProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let credentials = try loadCredentials(path: configuration.authPath) + let accessToken = try await refreshedAccessToken( + credentials: credentials, + endpoint: configuration.tokenEndpoint, + clientID: configuration.clientID, + clientSecret: configuration.clientSecret + ) + let loadResponse = try await loadCodeAssist( + accessToken: accessToken, + endpoint: configuration.codeAssistEndpoint + ) + guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else { + throw GeminiProbeError(message: "Code Assist did not return an active project; raw body redacted") + } + let quotaData = try await retrieveUserQuota( + accessToken: accessToken, + project: project, + endpoint: configuration.codeAssistEndpoint + ) + let buckets = try GeminiQuotaPayloadParser.buckets(from: quotaData) + printSummary( + buckets: buckets, + authPath: configuration.authPath, + endpoint: configuration.codeAssistEndpoint, + tierName: loadResponse.currentTier?.name ?? loadResponse.paidTier?.name + ) + } catch { + fputs("GeminiQuotaProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func loadCredentials(path: String) throws -> GeminiOAuthCredentials { + let url = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) + let data = try Data(contentsOf: url) + let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: data) + guard credentials.refreshToken?.isEmpty == false else { + throw GeminiProbeError(message: "Gemini OAuth file does not contain a refresh token") + } + return credentials + } + + private static func refreshedAccessToken( + credentials: GeminiOAuthCredentials, + endpoint: URL, + clientID: String, + clientSecret: String + ) async throws -> String { + guard let refreshToken = credentials.refreshToken else { + throw GeminiProbeError(message: "Gemini OAuth file does not contain a refresh token") + } + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = formEncoded([ + "client_id": clientID, + "client_secret": clientSecret, + "refresh_token": refreshToken, + "grant_type": "refresh_token", + ]) + + let data = try await send(request: request, redactedDescription: "OAuth refresh") + return try JSONDecoder().decode(GeminiRefreshResponse.self, from: data).accessToken + } + + private static func loadCodeAssist(accessToken: String, endpoint: URL) async throws -> GeminiLoadCodeAssistResponse { + var request = URLRequest(url: endpoint.appending(path: ":loadCodeAssist")) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: [ + "cloudaicompanionProject": NSNull(), + "metadata": [ + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": NSNull(), + ], + ]) + + let data = try await send(request: request, redactedDescription: "Code Assist load") + return try JSONDecoder().decode(GeminiLoadCodeAssistResponse.self, from: data) + } + + private static func retrieveUserQuota(accessToken: String, project: String, endpoint: URL) async throws -> Data { + var request = URLRequest(url: endpoint.appending(path: ":retrieveUserQuota")) + request.httpMethod = "POST" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try JSONSerialization.data(withJSONObject: ["project": project]) + return try await send(request: request, redactedDescription: "Code Assist quota") + } + + private static func send(request: URLRequest, redactedDescription: String) async throws -> Data { + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw GeminiProbeError(message: "\(redactedDescription) returned a non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw GeminiProbeError(message: "\(redactedDescription) returned HTTP \(http.statusCode); raw body redacted") + } + return data + } + + private static func formEncoded(_ values: [String: String]) -> Data { + values + .map { key, value in + "\(escape(key))=\(escape(value))" + } + .joined(separator: "&") + .data(using: .utf8) ?? Data() + } + + private static func escape(_ value: String) -> String { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "&+=") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private static func printSummary(buckets: [GeminiQuotaBucket], authPath: String, endpoint: URL, tierName: String?) { + print("Gemini Code Assist quota probe") + print("endpoint: \(endpoint.absoluteString)") + print("auth: \(redactedPath(authPath))") + print("tier: \(tierName ?? "unknown")") + print("buckets: \(buckets.count)") + print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies") + print("") + + for bucket in buckets.sorted(by: { $0.modelID < $1.modelID }) { + let remaining = bucket.remainingFraction.map { String(format: "%.1f%% remaining", $0 * 100) } ?? "unknown remaining" + let used = bucket.usedPercent.map { String(format: "%.1f%% used", $0) } ?? "unknown used" + let amount = bucket.remainingAmount.map { "remaining amount \($0)" } ?? "remaining amount absent" + let reset = bucket.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset" + print("- \(bucket.modelID): \(used), \(remaining), \(amount), resets \(reset)") + } + } + + private static func redactedPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + if expanded.hasPrefix(home) { + return "~" + expanded.dropFirst(home.count) + } + return URL(fileURLWithPath: expanded).lastPathComponent + } +} diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift new file mode 100644 index 0000000..e92f043 --- /dev/null +++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func claudeAuthStatusParserReducesStatusToNonSecretFields() throws { + let json = #""" + { + "loggedIn": true, + "authMethod": "claude.ai", + "apiProvider": "firstParty", + "email": "friend@example.com", + "orgId": "org_secret", + "subscriptionType": "pro" + } + """# + + let status = try ClaudeAuthStatusParser.status(from: Data(json.utf8)) + + #expect(status.loggedIn) + #expect(status.authMethod == "claude.ai") + #expect(status.apiProvider == "firstParty") + #expect(status.subscriptionType == "pro") +} + +@Test func claudeStatsCacheParserSummarizesLocalActivityOnly() throws { + let json = #""" + { + "version": 2, + "lastComputedDate": "2026-05-06T14:00:00Z", + "dailyActivity": { + "2026-05-06": { "messageCount": 5 } + }, + "modelUsage": { + "claude-sonnet-4-6": { "inputTokens": 12 }, + "claude-opus-4-6": { "inputTokens": 3 } + }, + "totalSessions": 7, + "totalMessages": 42, + "firstSessionDate": "2026-05-01" + } + """# + + let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8)) + + #expect(summary.version == 2) + #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-05-06T14:00:00Z") + #expect(summary.totalSessions == 7) + #expect(summary.totalMessages == 42) + #expect(summary.modelUsageCount == 2) + #expect(summary.dailyActivityCount == 1) +} + +@Test func claudeStatsCacheParserAcceptsArrayShapedDailyActivity() throws { + let json = #""" + { + "lastComputedDate": "2026-04-26", + "dailyActivity": [ + { "date": "2026-04-25" }, + { "date": "2026-04-26" } + ], + "modelUsage": {}, + "totalSessions": 1, + "totalMessages": 2 + } + """# + + let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8)) + + #expect(ContextPanelDateFormatting.string(from: summary.lastComputedDate!) == "2026-04-26T00:00:00Z") + #expect(summary.dailyActivityCount == 2) + #expect(summary.modelUsageCount == 0) +} diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift new file mode 100644 index 0000000..7eecf04 --- /dev/null +++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func geminiQuotaPayloadParserNormalizesBucketsAsPercentPressure() throws { + let json = #""" + { + "buckets": [ + { + "modelId": "gemini-3-flash-preview", + "remainingFraction": 0.965, + "resetTime": "2026-05-06T16:04:50Z" + }, + { + "modelId": "gemini-3.1-pro-preview", + "remainingFraction": 1, + "remainingAmount": 12, + "resetTime": "2026-05-07T14:19:35Z" + } + ] + } + """# + + let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8)) + + #expect(buckets.count == 2) + #expect(buckets[0].modelID == "gemini-3-flash-preview") + #expect(buckets[0].remainingFraction == 0.965) + #expect(abs((buckets[0].usedPercent ?? 0) - 3.5) < 0.0001) + #expect(ContextPanelDateFormatting.string(from: buckets[0].resetsAt!) == "2026-05-06T16:04:50Z") + #expect(buckets[1].remainingAmount == 12) + + let limit = buckets[0].usageLimit(accountID: "local", accountName: "Gemini CLI", observedAt: Date(timeIntervalSince1970: 0)) + #expect(limit.provider == .google) + #expect(limit.unit == .percent) + #expect(limit.used == 4) + #expect(limit.limit == 100) + #expect(limit.confidence == .observed) +} + +@Test func geminiQuotaPayloadParserHandlesMissingBuckets() throws { + let json = #"{"notBuckets": true}"# + + let buckets = try GeminiQuotaPayloadParser.buckets(from: Data(json.utf8)) + + #expect(buckets.isEmpty) +} diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index fe0aec4..7f20f0b 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -1,6 +1,6 @@ # Provider Usage Access Research -Last verified: 2026-05-05. +Last verified: 2026-05-06. ## Summary @@ -56,7 +56,8 @@ instead of pretending an estimate is exact. | OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | | OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | | Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | -| Claude subscriptions and Claude Code seats | Public docs describe usage limits across Claude.ai, Claude Code, and Claude Desktop, but no stable public API for personal subscription allowance was found. Claude Code can show session cost for API-key usage. | Pro/Max/Team usage has session-based reset behavior; Claude Code Enterprise seats show reset time when a limit is reached. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Defer automated subscription tracking unless a supported local/official signal is found. Support manual observation later; prioritize Anthropic API first. | Medium for displayed limits; low for automation | +| Claude subscriptions and Claude Code seats | Public docs describe usage limits across Claude.ai, Claude Code, and Claude Desktop, but no stable public API for personal subscription allowance was found. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. | Pro/Max/Team usage has session-based reset behavior; Claude Code Enterprise seats show reset time when a limit is reached. Local cache does not expose live remaining allowance. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata and local activity freshness. Defer live subscription pressure unless a supported signal is found. Prioritize Anthropic API first for org/API usage. | Medium for auth/subscription metadata and local history; low for live subscription automation | +| Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | | Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | | Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | @@ -153,6 +154,58 @@ Preferred v1 connector scope: `CodexRateLimitProbe` executable exists to prove the direct call path against an existing Codex `auth.json` while printing only redacted summaries. +### Gemini Code Assist Connector + +The local Gemini CLI path gives Context Panel a second viable live connector. +The CLI stores OAuth credentials under `~/.gemini/oauth_creds.json`, while the +active account metadata lives separately under `~/.gemini/google_accounts.json`. +The quota values are not persisted as a durable local cache; Gemini CLI keeps +quota state in memory and refreshes it from the Code Assist backend. + +Preferred v1 connector scope: + +- Resolve `GEMINI_CLI_HOME`, then default to `~/.gemini`. +- Read `oauth_creds.json` only to refresh an access token locally; never print, + store, or upload token values. +- Call the Gemini Code Assist load path to resolve the active project internally; + never print or persist the raw project identifier. +- Call the Gemini Code Assist quota path and normalize buckets by model ID, + remaining fraction, optional remaining amount, and reset time. +- Represent each bucket as percent pressure: `used = round((1 - remaining) * + 100)`, `limit = 100`, `unit = percent`. +- Mark confidence as observed because this is a product backend surface rather + than a public quota API contract. + +The local `GeminiQuotaProbe` executable proves this path with redacted output. +On 2026-05-06 it returned seven live model buckets for the local Gemini CLI +account, including Gemini 2.5 and Gemini 3 preview models, with percent +remaining and reset timestamps. + +### Claude Local Status Connector + +Claude currently has a weaker local connector story for subscription pressure. +The official Claude Code authentication docs say macOS credentials are stored in +the encrypted macOS Keychain. Context Panel should not read Keychain secrets or +try to extract subscription OAuth tokens. + +Preferred v1 connector scope: + +- Call `claude auth status --json` and keep only non-secret fields such as + `loggedIn`, `authMethod`, `apiProvider`, and `subscriptionType`. +- Read `~/.claude/stats-cache.json` only as local historical activity, not live + subscription allowance. +- Summarize local stats by freshness and counts; do not read raw transcript + JSONL files, prompts, account UUIDs, emails, organization IDs, or token blobs. +- Show Claude subscription allowance as unknown unless a provider UI/runtime + state exposes a reset or limit banner that the user can confirm. +- Prefer Anthropic's official Admin Usage, Cost, and Rate Limits APIs when the + user connects an organization/API credential with sufficient permission. + +The local `ClaudeLimitProbe` executable proves the conservative path. On +2026-05-06 it confirmed the local Claude CLI is logged in with subscription +metadata and has a local stats cache, but no live subscription allowance is +exposed by the probe. + ### Every Code Cache Fallback Fallback connector scope: @@ -187,8 +240,11 @@ Fallback connector scope: - [OpenAI o3 and o4-mini usage limits](https://help.openai.com/en/articles/9824962-openai-o1and-o1-mini-usage-limits-on-chatgpt-and-the-api) - [Anthropic Usage and Cost API](https://platform.claude.com/docs/en/build-with-claude/usage-cost-api) - [Anthropic API rate limits](https://docs.anthropic.com/en/api/rate-limits) +- [Claude Code authentication](https://code.claude.com/docs/en/authentication) - [Claude usage and length limits](https://support.claude.com/en/articles/11647753-how-do-usage-and-length-limits-work) - [Models, usage, and limits in Claude Code](https://support.claude.com/en/articles/14552983-models-usage-and-limits-in-claude-code) +- [Gemini CLI authentication](https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html) +- [Gemini CLI quotas and pricing](https://google-gemini.github.io/gemini-cli/docs/quota-and-pricing.html) - [Gemini API billing](https://ai.google.dev/gemini-api/docs/billing/) - [Gemini API rate limits](https://ai.google.dev/gemini-api/docs/rate-limits) - [Google Service Usage consumer quota metrics](https://cloud.google.com/service-usage/docs/reference/rest/v1beta1/services.consumerQuotaMetrics/list) From d25f22f3fffd0327611f42bcebb80f063572c64d Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 10:47:44 -0400 Subject: [PATCH 15/34] Document Gemini probe credential inputs --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b2a74d3..b2e64f7 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,8 @@ without printing secrets or raw provider responses: ```sh swift run CodexRateLimitProbe --auth ~/.codex/auth.json -swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json +GEMINI_OAUTH_CLIENT_ID=... GEMINI_OAUTH_CLIENT_SECRET=... \ + swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json swift run ClaudeLimitProbe ``` @@ -68,3 +69,6 @@ their respective CLI-backed accounts. The Claude probe intentionally reports only local auth/subscription metadata and local stats-cache freshness because a live personal subscription allowance is not exposed through a clean local signal yet. + +For Gemini, use the OAuth client values from the locally installed Gemini CLI; +they are intentionally not checked into this repository. From fa349eb8b2226a4f9ce06043bb8dd262bfad11a3 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 11:35:46 -0400 Subject: [PATCH 16/34] Extract provider connector runtime --- README.md | 3 + Sources/ClaudeLimitProbe/main.swift | 93 ++------ Sources/CodexRateLimitProbe/main.swift | 104 ++------- .../ContextPanelCore/ClaudeLocalStatus.swift | 129 +++++++++++ .../ContextPanelCore/CodexRateLimits.swift | 212 ++++++++++++++++++ .../GeminiCodeAssistQuota.swift | 210 +++++++++++++++++ .../ContextPanelCore/ProviderConnector.swift | 199 ++++++++++++++++ Sources/GeminiQuotaProbe/main.swift | 202 +++-------------- .../ProviderConnectorTests.swift | 167 ++++++++++++++ docs/architecture.md | 24 ++ 10 files changed, 1011 insertions(+), 332 deletions(-) create mode 100644 Sources/ContextPanelCore/ProviderConnector.swift create mode 100644 Tests/ContextPanelCoreTests/ProviderConnectorTests.swift diff --git a/README.md b/README.md index b2e64f7..33a7419 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,6 @@ yet. For Gemini, use the OAuth client values from the locally installed Gemini CLI; they are intentionally not checked into this repository. + +The probes call the same `ContextPanelCore` connectors the app will use, so +passing probe output is also a smoke test for the production connector runtime. diff --git a/Sources/ClaudeLimitProbe/main.swift b/Sources/ClaudeLimitProbe/main.swift index 976d954..ad0e971 100644 --- a/Sources/ClaudeLimitProbe/main.swift +++ b/Sources/ClaudeLimitProbe/main.swift @@ -61,88 +61,43 @@ struct ProbeConfiguration { @main struct ClaudeLimitProbe { - static func main() { + static func main() async { do { let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) - let authStatus = try loadAuthStatus(claudeBinary: configuration.claudeBinary) - let statsSummary = try loadStatsSummary(path: configuration.statsPath) - printSummary(authStatus: authStatus, statsSummary: statsSummary, statsPath: configuration.statsPath) + let connector = ClaudeLocalStatusConnector(accounts: [ + ClaudeAccountConfiguration( + claudeBinary: configuration.claudeBinary, + statsPath: configuration.statsPath + ) + ]) + let result = await connector.refresh(now: Date()) + printSummary(result: result, statsPath: configuration.statsPath) } catch { fputs("ClaudeLimitProbe failed: \(error.localizedDescription)\n", stderr) Foundation.exit(1) } } - private static func loadAuthStatus(claudeBinary: String) throws -> ClaudeAuthStatus { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/env") - process.arguments = [claudeBinary, "auth", "status", "--json"] - - let output = Pipe() - let error = Pipe() - process.standardOutput = output - process.standardError = error - - try process.run() - process.waitUntilExit() - - let data = output.fileHandleForReading.readDataToEndOfFile() - if process.terminationStatus != 0 { - throw ClaudeProbeError(message: "claude auth status failed; stderr redacted") - } - return try ClaudeAuthStatusParser.status(from: data) - } - - private static func loadStatsSummary(path: String) throws -> ClaudeStatsCacheSummary? { - let expanded = NSString(string: path).expandingTildeInPath - guard FileManager.default.fileExists(atPath: expanded) else { - return nil - } - let data = try Data(contentsOf: URL(fileURLWithPath: expanded)) - return try ClaudeStatsCacheParser.summary(from: data) - } - - private static func printSummary( - authStatus: ClaudeAuthStatus, - statsSummary: ClaudeStatsCacheSummary?, - statsPath: String - ) { + private static func printSummary(result: ConnectorRefreshResult, statsPath: String) { print("Claude local status probe") - print("auth logged in: \(authStatus.loggedIn)") - print("auth method: \(authStatus.authMethod)") - print("api provider: \(authStatus.apiProvider ?? "unknown")") - print("subscription type: \(authStatus.subscriptionType ?? "unknown")") - print("stats cache: \(redactedPath(statsPath))") + print("stats cache: \(ConnectorRedactor.redactedPath(statsPath))") + print("accounts: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") print("redacted: tokens, Keychain secrets, account identifiers, org identifiers, emails, raw transcripts, raw provider responses") print("") - guard let statsSummary else { - print("local stats cache: absent") - print("live subscription allowance: not exposed by this probe") - return + for report in result.reports { + print("- \(report.accountName): \(report.status.rawValue)") + if let errorMessage = report.errorMessage { + print(" error: \(errorMessage)") + } + for limit in report.limits { + print(" - \(limit.label): \(limit.status.rawValue)") + if let note = limit.note { + print(" \(note)") + } + } } - - print("local stats cache: present") - print("version: \(statsSummary.version.map(String.init) ?? "unknown")") - print("last computed: \(format(date: statsSummary.lastComputedDate))") - print("first session: \(format(date: statsSummary.firstSessionDate))") - print("total sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")") - print("total messages: \(statsSummary.totalMessages.map(String.init) ?? "unknown")") - print("model usage buckets: \(statsSummary.modelUsageCount)") - print("daily activity buckets: \(statsSummary.dailyActivityCount)") print("live subscription allowance: not exposed by this probe") } - - private static func format(date: Date?) -> String { - date.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown" - } - - private static func redactedPath(_ path: String) -> String { - let expanded = NSString(string: path).expandingTildeInPath - let home = FileManager.default.homeDirectoryForCurrentUser.path - if expanded.hasPrefix(home) { - return "~" + expanded.dropFirst(home.count) - } - return URL(fileURLWithPath: expanded).lastPathComponent - } } diff --git a/Sources/CodexRateLimitProbe/main.swift b/Sources/CodexRateLimitProbe/main.swift index d37e68d..f82d9df 100644 --- a/Sources/CodexRateLimitProbe/main.swift +++ b/Sources/CodexRateLimitProbe/main.swift @@ -7,22 +7,6 @@ struct CodexProbeError: LocalizedError { var errorDescription: String? { message } } -struct AuthFile: Decodable { - let tokens: TokenData? -} - -struct TokenData: Decodable { - let accessToken: String - let accountID: String? - let idToken: String? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case accountID = "account_id" - case idToken = "id_token" - } -} - struct ProbeConfiguration { let authPath: String let endpoint: URL @@ -82,92 +66,41 @@ struct CodexRateLimitProbe { static func main() async { do { let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) - let auth = try loadAuth(path: configuration.authPath) - let data = try await fetchUsage(endpoint: configuration.endpoint, auth: auth) - let snapshots = try CodexUsagePayloadParser.snapshots(from: data) - printSummary(snapshots: snapshots, endpoint: configuration.endpoint, authPath: configuration.authPath) + let connector = CodexRateLimitConnector(accounts: [ + CodexAccountConfiguration(authPath: configuration.authPath, endpoint: configuration.endpoint) + ]) + let result = await connector.refresh(now: Date()) + printSummary(result: result, endpoint: configuration.endpoint, authPath: configuration.authPath) } catch { fputs("CodexRateLimitProbe failed: \(error.localizedDescription)\n", stderr) Foundation.exit(1) } } - private static func loadAuth(path: String) throws -> TokenData { - let url = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) - let data = try Data(contentsOf: url) - let auth = try JSONDecoder().decode(AuthFile.self, from: data) - guard let tokens = auth.tokens, !tokens.accessToken.isEmpty else { - throw CodexProbeError(message: "auth file does not contain ChatGPT token auth") - } - return tokens - } - - private static func fetchUsage(endpoint: URL, auth: TokenData) async throws -> Data { - var request = URLRequest(url: endpoint) - request.httpMethod = "GET" - request.setValue("Bearer \(auth.accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("codex-cli", forHTTPHeaderField: "User-Agent") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let accountID = auth.accountID ?? accountID(fromIDToken: auth.idToken) { - request.setValue(accountID, forHTTPHeaderField: "ChatGPT-Account-Id") - } - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw CodexProbeError(message: "usage endpoint returned a non-HTTP response") - } - guard (200..<300).contains(http.statusCode) else { - throw CodexProbeError(message: "usage endpoint returned HTTP \(http.statusCode); raw body redacted") - } - return data - } - - private static func accountID(fromIDToken token: String?) -> String? { - guard let token else { return nil } - let parts = token.split(separator: ".") - guard parts.count >= 2 else { return nil } - var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") - while payload.count % 4 != 0 { - payload.append("=") - } - guard - let data = Data(base64Encoded: payload), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let auth = object["https://api.openai.com/auth"] as? [String: Any] - else { - return nil - } - return auth["chatgpt_account_id"] as? String - } - - private static func printSummary(snapshots: [CodexRateLimitSnapshot], endpoint: URL, authPath: String) { + private static func printSummary(result: ConnectorRefreshResult, endpoint: URL, authPath: String) { print("Codex live usage endpoint probe") print("endpoint: \(endpoint.absoluteString)") print("auth: \(redactedAuthPath(authPath))") - print("snapshots: \(snapshots.count)") + print("accounts: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") print("redacted: tokens, account identifiers, emails, headers, raw response bodies") print("") - for snapshot in snapshots { - print("- \(snapshot.displayName) [\(snapshot.id)]") - print(" plan: \(snapshot.planType)") - print(" primary: \(format(window: snapshot.primary))") - print(" secondary: \(format(window: snapshot.secondary))") - if let credits = snapshot.credits { - print(" credits: has=\(credits.hasCredits) unlimited=\(credits.unlimited) balance=\(credits.balance ?? "nil")") + for report in result.reports { + print("- \(report.accountName): \(report.status.rawValue)") + if let errorMessage = report.errorMessage { + print(" error: \(errorMessage)") } - if let reached = snapshot.rateLimitReachedType { - print(" reached: \(reached.rawValue)") + for limit in report.limits { + print(" - \(limit.label): \(format(limit: limit))") } } } - private static func format(window: CodexRateLimitWindow?) -> String { - guard let window else { return "none" } - let percent = String(format: "%.0f%%", window.usedPercent) - let duration = window.windowMinutes.map { "\($0)m" } ?? "unknown window" - let reset = window.resetsAt.map { ISO8601DateFormatter().string(from: $0) } ?? "unknown reset" - return "\(percent) used / \(duration) / resets \(reset)" + private static func format(limit: UsageLimit) -> String { + let used = limit.used.map { "\($0)% used" } ?? "unknown used" + let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset" + return "\(used) / resets \(reset)" } private static func redactedAuthPath(_ path: String) -> String { @@ -179,4 +112,3 @@ struct CodexRateLimitProbe { return URL(fileURLWithPath: expanded).lastPathComponent } } - diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift index 8b80c47..f35687e 100644 --- a/Sources/ContextPanelCore/ClaudeLocalStatus.swift +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -69,6 +69,135 @@ public enum ClaudeStatsCacheParser { } } +public struct ClaudeAccountConfiguration: Equatable, Sendable { + public let accountName: String + public let claudeBinary: String + public let statsPath: String + + public init(accountName: String = "Claude", claudeBinary: String = "claude", statsPath: String? = nil) { + self.accountName = accountName + self.claudeBinary = claudeBinary + self.statsPath = statsPath ?? "\(FileManager.default.homeDirectoryForCurrentUser.path)/.claude/stats-cache.json" + } +} + +public struct ClaudeLocalStatusConnector: ProviderConnector { + public let provider: Provider = .anthropic + + private let accounts: [ClaudeAccountConfiguration] + private let processClient: any ConnectorProcessClient + private let fileLoader: @Sendable (String) throws -> Data + private let fileExists: @Sendable (String) -> Bool + + public init( + accounts: [ClaudeAccountConfiguration], + processClient: any ConnectorProcessClient = DefaultConnectorProcessClient(), + fileLoader: @escaping @Sendable (String) throws -> Data = { path in + try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) + }, + fileExists: @escaping @Sendable (String) -> Bool = { path in + FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + } + ) { + self.accounts = accounts + self.processClient = processClient + self.fileLoader = fileLoader + self.fileExists = fileExists + } + + public func refresh(now: Date) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + reports.reserveCapacity(accounts.count) + for account in accounts { + reports.append(refresh(account: account, now: now)) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } + + private func refresh(account: ClaudeAccountConfiguration, now: Date) -> ProviderConnectorReport { + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.statsPath) + + do { + let authStatus = try loadAuthStatus(claudeBinary: account.claudeBinary) + let statsSummary = try loadStatsSummary(path: account.statsPath) + let limits = claudeLocalStatusLimits( + authStatus: authStatus, + statsSummary: statsSummary, + accountID: localAccountID, + accountName: account.accountName, + observedAt: now + ) + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: limits, + status: authStatus.loggedIn ? .unknown : .failure, + errorMessage: authStatus.loggedIn ? nil : "Claude CLI is not logged in" + ) + } catch { + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: [], + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + private func loadAuthStatus(claudeBinary: String) throws -> ClaudeAuthStatus { + let result = try processClient.run(executable: claudeBinary, arguments: ["auth", "status", "--json"]) + guard result.exitCode == 0 else { + throw ConnectorError.processFailure(operation: "claude auth status", exitCode: result.exitCode) + } + return try ClaudeAuthStatusParser.status(from: result.stdout) + } + + private func loadStatsSummary(path: String) throws -> ClaudeStatsCacheSummary? { + guard fileExists(path) else { return nil } + return try ClaudeStatsCacheParser.summary(from: try fileLoader(path)) + } +} + +public func claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus, + statsSummary: ClaudeStatsCacheSummary?, + accountID: String, + accountName: String, + observedAt: Date +) -> [UsageLimit] { + var noteParts = [ + "auth: \(authStatus.authMethod)", + "provider: \(authStatus.apiProvider ?? "unknown")", + "subscription: \(authStatus.subscriptionType ?? "unknown")", + ] + if let statsSummary { + noteParts.append("sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")") + noteParts.append("messages: \(statsSummary.totalMessages.map(String.init) ?? "unknown")") + } else { + noteParts.append("stats cache: absent") + } + + return [UsageLimit( + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude subscription allowance", + unit: .unknown, + used: nil, + limit: nil, + resetsAt: nil, + lastUpdatedAt: statsSummary?.lastComputedDate ?? observedAt, + confidence: .unknown, + statusOverride: authStatus.loggedIn ? .unknown : .failure, + note: noteParts.joined(separator: "; ") + )] +} + private struct ClaudeAuthStatusPayload: Decodable { let loggedIn: Bool let authMethod: String diff --git a/Sources/ContextPanelCore/CodexRateLimits.swift b/Sources/ContextPanelCore/CodexRateLimits.swift index 2cd9131..07139b7 100644 --- a/Sources/ContextPanelCore/CodexRateLimits.swift +++ b/Sources/ContextPanelCore/CodexRateLimits.swift @@ -65,6 +65,179 @@ public struct CodexRateLimitSnapshot: Codable, Equatable, Identifiable, Sendable } } +public struct CodexAuthTokens: Codable, Equatable, Sendable { + public let accessToken: String + public let accountID: String? + public let idToken: String? + + public init(accessToken: String, accountID: String?, idToken: String?) { + self.accessToken = accessToken + self.accountID = accountID + self.idToken = idToken + } +} + +public enum CodexAuthFileParser { + public static func tokens(from data: Data) throws -> CodexAuthTokens { + let payload = try JSONDecoder().decode(CodexAuthFilePayload.self, from: data) + guard let tokens = payload.tokens, !tokens.accessToken.isEmpty else { + throw ConnectorError.invalidAuth("auth file does not contain ChatGPT token auth") + } + return CodexAuthTokens( + accessToken: tokens.accessToken, + accountID: tokens.accountID, + idToken: tokens.idToken + ) + } +} + +public enum CodexAccountIDExtractor { + public static func accountID(fromIDToken token: String?) -> String? { + guard let token else { return nil } + let parts = token.split(separator: ".") + guard parts.count >= 2 else { return nil } + var payload = String(parts[1]).replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + while payload.count % 4 != 0 { + payload.append("=") + } + guard + let data = Data(base64Encoded: payload), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let auth = object["https://api.openai.com/auth"] as? [String: Any] + else { + return nil + } + return auth["chatgpt_account_id"] as? String + } +} + +public struct CodexAccountConfiguration: Equatable, Sendable { + public let authPath: String + public let accountName: String + public let endpoint: URL + + public init( + authPath: String, + accountName: String? = nil, + endpoint: URL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + ) { + self.authPath = authPath + self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath) + self.endpoint = endpoint + } +} + +public struct CodexRateLimitConnector: ProviderConnector { + public let provider: Provider = .openAI + + private let accounts: [CodexAccountConfiguration] + private let httpClient: any ConnectorHTTPClient + private let fileLoader: @Sendable (String) throws -> Data + + public init( + accounts: [CodexAccountConfiguration], + httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(), + fileLoader: @escaping @Sendable (String) throws -> Data = { path in + try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) + } + ) { + self.accounts = accounts + self.httpClient = httpClient + self.fileLoader = fileLoader + } + + public func refresh(now: Date) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + reports.reserveCapacity(accounts.count) + for account in accounts { + reports.append(await refresh(account: account, now: now)) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } + + private func refresh(account: CodexAccountConfiguration, now: Date) async -> ProviderConnectorReport { + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath) + + do { + let auth = try CodexAuthFileParser.tokens(from: try fileLoader(account.authPath)) + let data = try await fetchUsage(endpoint: account.endpoint, auth: auth) + let snapshots = try CodexUsagePayloadParser.snapshots(from: data) + let limits = snapshots.flatMap { snapshot in + codexUsageLimits( + from: snapshot, + accountID: localAccountID, + accountName: account.accountName, + observedAt: now + ) + } + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: limits + ) + } catch { + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: [], + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + private func fetchUsage(endpoint: URL, auth: CodexAuthTokens) async throws -> Data { + var headers = [ + "Authorization": "Bearer \(auth.accessToken)", + "User-Agent": "context-panel", + "Accept": "application/json", + ] + if let accountID = auth.accountID ?? CodexAccountIDExtractor.accountID(fromIDToken: auth.idToken) { + headers["ChatGPT-Account-Id"] = accountID + } + + let response = try await httpClient.data(for: ConnectorHTTPRequest(url: endpoint, method: "GET", headers: headers)) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Codex usage endpoint", statusCode: response.statusCode) + } + return response.data + } +} + +public func codexUsageLimits( + from snapshot: CodexRateLimitSnapshot, + accountID: String, + accountName: String, + observedAt: Date +) -> [UsageLimit] { + var limits: [UsageLimit] = [] + if let primary = snapshot.primary { + limits.append(codexUsageLimit( + snapshot: snapshot, + window: primary, + windowName: "primary", + accountID: accountID, + accountName: accountName, + observedAt: observedAt + )) + } + if let secondary = snapshot.secondary { + limits.append(codexUsageLimit( + snapshot: snapshot, + window: secondary, + windowName: "secondary", + accountID: accountID, + accountName: accountName, + observedAt: observedAt + )) + } + return limits +} + public enum CodexUsagePayloadParser { public static func snapshots(from data: Data) throws -> [CodexRateLimitSnapshot] { let payload = try JSONDecoder().decode(CodexUsagePayload.self, from: data) @@ -125,6 +298,22 @@ private struct CodexUsagePayload: Decodable { } } +private struct CodexAuthFilePayload: Decodable { + let tokens: CodexAuthTokenPayload? +} + +private struct CodexAuthTokenPayload: Decodable { + let accessToken: String + let accountID: String? + let idToken: String? + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case accountID = "account_id" + case idToken = "id_token" + } +} + private struct CodexRateLimitDetails: Decodable { let primaryWindow: CodexWindowSnapshot? let secondaryWindow: CodexWindowSnapshot? @@ -195,3 +384,26 @@ private struct CodexReachedType: Decodable { } } +private func codexUsageLimit( + snapshot: CodexRateLimitSnapshot, + window: CodexRateLimitWindow, + windowName: String, + accountID: String, + accountName: String, + observedAt: Date +) -> UsageLimit { + let duration = window.windowMinutes.map { "\($0)m" } ?? "rolling" + return UsageLimit( + provider: .openAI, + accountID: accountID, + accountName: accountName, + label: "\(snapshot.displayName) \(windowName) \(duration)", + unit: .percent, + used: Int(window.usedPercent.rounded()), + limit: 100, + resetsAt: window.resetsAt, + lastUpdatedAt: observedAt, + confidence: .observed, + note: "plan: \(snapshot.planType)" + ) +} diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift index f5022cd..f1e7fb3 100644 --- a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -43,6 +43,193 @@ public enum GeminiQuotaPayloadParser { } } +public struct GeminiOAuthCredentials: Codable, Equatable, Sendable { + public let accessToken: String? + public let refreshToken: String? + + public init(accessToken: String?, refreshToken: String?) { + self.accessToken = accessToken + self.refreshToken = refreshToken + } + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case refreshToken = "refresh_token" + } +} + +public struct GeminiRefreshResponse: Decodable, Equatable, Sendable { + public let accessToken: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + } +} + +public struct GeminiCodeAssistTier: Decodable, Equatable, Sendable { + public let name: String? +} + +public struct GeminiLoadCodeAssistResponse: Decodable, Equatable, Sendable { + public let cloudaicompanionProject: String? + public let currentTier: GeminiCodeAssistTier? + public let paidTier: GeminiCodeAssistTier? +} + +public struct GeminiAccountConfiguration: Equatable, Sendable { + public let authPath: String + public let accountName: String + public let tokenEndpoint: URL + public let codeAssistEndpoint: URL + public let clientID: String + public let clientSecret: String + + public init( + authPath: String, + accountName: String? = nil, + tokenEndpoint: URL = URL(string: "https://oauth2.googleapis.com/token")!, + codeAssistEndpoint: URL = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")!, + clientID: String, + clientSecret: String + ) { + self.authPath = authPath + self.accountName = accountName ?? ConnectorRedactor.redactedPath(authPath) + self.tokenEndpoint = tokenEndpoint + self.codeAssistEndpoint = codeAssistEndpoint + self.clientID = clientID + self.clientSecret = clientSecret + } +} + +public struct GeminiCodeAssistConnector: ProviderConnector { + public let provider: Provider = .google + + private let accounts: [GeminiAccountConfiguration] + private let httpClient: any ConnectorHTTPClient + private let fileLoader: @Sendable (String) throws -> Data + + public init( + accounts: [GeminiAccountConfiguration], + httpClient: any ConnectorHTTPClient = URLSessionConnectorHTTPClient(), + fileLoader: @escaping @Sendable (String) throws -> Data = { path in + try Data(contentsOf: URL(fileURLWithPath: NSString(string: path).expandingTildeInPath)) + } + ) { + self.accounts = accounts + self.httpClient = httpClient + self.fileLoader = fileLoader + } + + public func refresh(now: Date) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + reports.reserveCapacity(accounts.count) + for account in accounts { + reports.append(await refresh(account: account, now: now)) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } + + private func refresh(account: GeminiAccountConfiguration, now: Date) async -> ProviderConnectorReport { + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.authPath) + + do { + let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: try fileLoader(account.authPath)) + let accessToken = try await refreshedAccessToken(credentials: credentials, account: account) + let loadResponse = try await loadCodeAssist(accessToken: accessToken, endpoint: account.codeAssistEndpoint) + guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else { + throw ConnectorError.decodingFailure("Code Assist did not return an active project; raw body redacted") + } + let quotaData = try await retrieveUserQuota( + accessToken: accessToken, + project: project, + endpoint: account.codeAssistEndpoint + ) + let buckets = try GeminiQuotaPayloadParser.buckets(from: quotaData) + let limits = buckets.map { + $0.usageLimit(accountID: localAccountID, accountName: account.accountName, observedAt: now) + } + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: limits + ) + } catch { + return ProviderConnectorReport( + provider: provider, + accountID: localAccountID, + accountName: account.accountName, + generatedAt: now, + limits: [], + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + private func refreshedAccessToken(credentials: GeminiOAuthCredentials, account: GeminiAccountConfiguration) async throws -> String { + guard let refreshToken = credentials.refreshToken, !refreshToken.isEmpty else { + throw ConnectorError.invalidAuth("Gemini OAuth file does not contain a refresh token") + } + let body = formEncoded([ + "client_id": account.clientID, + "client_secret": account.clientSecret, + "refresh_token": refreshToken, + "grant_type": "refresh_token", + ]) + let response = try await httpClient.data(for: ConnectorHTTPRequest( + url: account.tokenEndpoint, + method: "POST", + headers: [ + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + ], + body: body + )) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Gemini OAuth refresh", statusCode: response.statusCode) + } + return try JSONDecoder().decode(GeminiRefreshResponse.self, from: response.data).accessToken + } + + private func loadCodeAssist(accessToken: String, endpoint: URL) async throws -> GeminiLoadCodeAssistResponse { + let body = try JSONSerialization.data(withJSONObject: [ + "cloudaicompanionProject": NSNull(), + "metadata": [ + "ideType": "IDE_UNSPECIFIED", + "platform": "PLATFORM_UNSPECIFIED", + "pluginType": "GEMINI", + "duetProject": NSNull(), + ], + ]) + let response = try await httpClient.data(for: ConnectorHTTPRequest( + url: endpoint.appending(path: ":loadCodeAssist"), + method: "POST", + headers: jsonHeaders(accessToken: accessToken), + body: body + )) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Gemini Code Assist load", statusCode: response.statusCode) + } + return try JSONDecoder().decode(GeminiLoadCodeAssistResponse.self, from: response.data) + } + + private func retrieveUserQuota(accessToken: String, project: String, endpoint: URL) async throws -> Data { + let body = try JSONSerialization.data(withJSONObject: ["project": project]) + let response = try await httpClient.data(for: ConnectorHTTPRequest( + url: endpoint.appending(path: ":retrieveUserQuota"), + method: "POST", + headers: jsonHeaders(accessToken: accessToken), + body: body + )) + guard (200..<300).contains(response.statusCode) else { + throw ConnectorError.httpFailure(operation: "Gemini Code Assist quota", statusCode: response.statusCode) + } + return response.data + } +} + private struct GeminiQuotaPayload: Decodable { let buckets: [GeminiQuotaBucketPayload] @@ -126,3 +313,26 @@ public enum ContextPanelDateFormatting { return formatter } } + +func formEncoded(_ values: [String: String]) -> Data { + values + .map { key, value in + "\(urlFormEscape(key))=\(urlFormEscape(value))" + } + .joined(separator: "&") + .data(using: .utf8) ?? Data() +} + +func urlFormEscape(_ value: String) -> String { + var allowed = CharacterSet.urlQueryAllowed + allowed.remove(charactersIn: "&+=") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value +} + +private func jsonHeaders(accessToken: String) -> [String: String] { + [ + "Authorization": "Bearer \(accessToken)", + "Content-Type": "application/json", + "Accept": "application/json", + ] +} diff --git a/Sources/ContextPanelCore/ProviderConnector.swift b/Sources/ContextPanelCore/ProviderConnector.swift new file mode 100644 index 0000000..d1f9045 --- /dev/null +++ b/Sources/ContextPanelCore/ProviderConnector.swift @@ -0,0 +1,199 @@ +import Foundation + +public enum ConnectorError: LocalizedError, Equatable, Sendable { + case missingAuth(String) + case invalidAuth(String) + case httpFailure(operation: String, statusCode: Int) + case nonHTTPResponse(String) + case processFailure(operation: String, exitCode: Int32) + case decodingFailure(String) + + public var errorDescription: String? { + switch self { + case let .missingAuth(message), let .invalidAuth(message), let .nonHTTPResponse(message), let .decodingFailure(message): + message + case let .httpFailure(operation, statusCode): + "\(operation) returned HTTP \(statusCode); raw body redacted" + case let .processFailure(operation, exitCode): + "\(operation) failed with exit code \(exitCode); stderr redacted" + } + } +} + +public struct ProviderConnectorReport: Equatable, Sendable { + public let provider: Provider + public let accountID: String + public let accountName: String + public let generatedAt: Date + public let limits: [UsageLimit] + public let status: UsageStatus + public let errorMessage: String? + + public init( + provider: Provider, + accountID: String, + accountName: String, + generatedAt: Date, + limits: [UsageLimit], + status: UsageStatus? = nil, + errorMessage: String? = nil + ) { + self.provider = provider + self.accountID = accountID + self.accountName = accountName + self.generatedAt = generatedAt + self.limits = limits + self.status = status ?? UsageSnapshot(generatedAt: generatedAt, limits: limits).aggregateStatus + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } +} + +public struct ConnectorRefreshResult: Equatable, Sendable { + public let generatedAt: Date + public let reports: [ProviderConnectorReport] + + public init(generatedAt: Date, reports: [ProviderConnectorReport]) { + self.generatedAt = generatedAt + self.reports = reports + } + + public var snapshot: UsageSnapshot { + UsageSnapshot(generatedAt: generatedAt, limits: reports.flatMap(\.limits)) + } +} + +public protocol ProviderConnector: Sendable { + var provider: Provider { get } + + func refresh(now: Date) async -> ConnectorRefreshResult +} + +public struct ProviderConnectorRuntime: Sendable { + private let connectors: [any ProviderConnector] + + public init(connectors: [any ProviderConnector]) { + self.connectors = connectors + } + + public func refreshAll(now: Date = Date()) async -> ConnectorRefreshResult { + var reports: [ProviderConnectorReport] = [] + for connector in connectors { + let result = await connector.refresh(now: now) + reports.append(contentsOf: result.reports) + } + return ConnectorRefreshResult(generatedAt: now, reports: reports) + } +} + +public struct ConnectorHTTPRequest: Sendable { + public let url: URL + public let method: String + public let headers: [String: String] + public let body: Data? + + public init(url: URL, method: String, headers: [String: String] = [:], body: Data? = nil) { + self.url = url + self.method = method + self.headers = headers + self.body = body + } +} + +public struct ConnectorHTTPResponse: Sendable { + public let statusCode: Int + public let data: Data + + public init(statusCode: Int, data: Data) { + self.statusCode = statusCode + self.data = data + } +} + +public protocol ConnectorHTTPClient: Sendable { + func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse +} + +public struct URLSessionConnectorHTTPClient: ConnectorHTTPClient { + public init() {} + + public func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse { + var urlRequest = URLRequest(url: request.url) + urlRequest.httpMethod = request.method + urlRequest.httpBody = request.body + for (key, value) in request.headers { + urlRequest.setValue(value, forHTTPHeaderField: key) + } + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + guard let http = response as? HTTPURLResponse else { + throw ConnectorError.nonHTTPResponse("provider request returned a non-HTTP response") + } + return ConnectorHTTPResponse(statusCode: http.statusCode, data: data) + } +} + +public struct ConnectorProcessResult: Sendable { + public let exitCode: Int32 + public let stdout: Data + + public init(exitCode: Int32, stdout: Data) { + self.exitCode = exitCode + self.stdout = stdout + } +} + +public protocol ConnectorProcessClient: Sendable { + func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult +} + +public struct DefaultConnectorProcessClient: ConnectorProcessClient { + public init() {} + + public func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executable] + arguments + + let output = Pipe() + let error = Pipe() + process.standardOutput = output + process.standardError = error + + try process.run() + process.waitUntilExit() + + return ConnectorProcessResult( + exitCode: process.terminationStatus, + stdout: output.fileHandleForReading.readDataToEndOfFile() + ) + } +} + +public enum ConnectorRedactor { + public static func redact(_ value: String) -> String { + EvidenceRedactor.redact(value) + } + + public static func redactedPath(_ path: String) -> String { + let expanded = NSString(string: path).expandingTildeInPath + let home = FileManager.default.homeDirectoryForCurrentUser.path + if expanded.hasPrefix(home) { + return "~" + expanded.dropFirst(home.count) + } + return URL(fileURLWithPath: expanded).lastPathComponent + } + + public static func localAccountID(provider: Provider, path: String) -> String { + "\(provider.rawValue)-\(fnv1a(path))" + } + + private static func fnv1a(_ value: String) -> String { + var hash: UInt64 = 14_695_981_039_346_656_037 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} + diff --git a/Sources/GeminiQuotaProbe/main.swift b/Sources/GeminiQuotaProbe/main.swift index e69d68e..c6c8520 100644 --- a/Sources/GeminiQuotaProbe/main.swift +++ b/Sources/GeminiQuotaProbe/main.swift @@ -6,41 +6,8 @@ struct GeminiProbeError: LocalizedError { var errorDescription: String? { message } } - -struct GeminiOAuthCredentials: Codable { - let accessToken: String? - let refreshToken: String? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - } -} - -struct GeminiRefreshResponse: Decodable { - let accessToken: String - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - } -} - -struct GeminiLoadCodeAssistResponse: Decodable { - let cloudaicompanionProject: String? - let currentTier: RedactedTier? - let paidTier: RedactedTier? -} - -struct RedactedTier: Decodable { - let name: String? -} - struct ProbeConfiguration { - let authPath: String - let tokenEndpoint: URL - let codeAssistEndpoint: URL - let clientID: String - let clientSecret: String + let account: GeminiAccountConfiguration static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { var authPath: String? @@ -92,13 +59,13 @@ struct ProbeConfiguration { throw GeminiProbeError(message: "set GEMINI_OAUTH_CLIENT_SECRET or pass --client-secret") } - return ProbeConfiguration( + return ProbeConfiguration(account: GeminiAccountConfiguration( authPath: authPath ?? defaultAuthPath(), tokenEndpoint: tokenEndpoint, codeAssistEndpoint: codeAssistEndpoint, clientID: clientID, clientSecret: clientSecret - ) + )) } private static func defaultAuthPath() -> String { @@ -116,10 +83,9 @@ struct ProbeConfiguration { equivalent --client-id and --client-secret flags. Use values from the locally installed Gemini CLI; do not commit them to this repository. - Uses Gemini CLI OAuth credentials to refresh an access token, asks the - Gemini Code Assist backend for the active project, then prints a redacted - quota summary. Tokens, account identifiers, project IDs, emails, - headers, and raw response bodies are never printed. + Uses the production Gemini Code Assist connector and prints only a + redacted quota summary. Tokens, account identifiers, project IDs, + emails, headers, and raw response bodies are never printed. """) } } @@ -129,153 +95,35 @@ struct GeminiQuotaProbe { static func main() async { do { let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) - let credentials = try loadCredentials(path: configuration.authPath) - let accessToken = try await refreshedAccessToken( - credentials: credentials, - endpoint: configuration.tokenEndpoint, - clientID: configuration.clientID, - clientSecret: configuration.clientSecret - ) - let loadResponse = try await loadCodeAssist( - accessToken: accessToken, - endpoint: configuration.codeAssistEndpoint - ) - guard let project = loadResponse.cloudaicompanionProject, !project.isEmpty else { - throw GeminiProbeError(message: "Code Assist did not return an active project; raw body redacted") - } - let quotaData = try await retrieveUserQuota( - accessToken: accessToken, - project: project, - endpoint: configuration.codeAssistEndpoint - ) - let buckets = try GeminiQuotaPayloadParser.buckets(from: quotaData) - printSummary( - buckets: buckets, - authPath: configuration.authPath, - endpoint: configuration.codeAssistEndpoint, - tierName: loadResponse.currentTier?.name ?? loadResponse.paidTier?.name - ) + let connector = GeminiCodeAssistConnector(accounts: [configuration.account]) + let result = await connector.refresh(now: Date()) + printSummary(result: result, account: configuration.account) } catch { fputs("GeminiQuotaProbe failed: \(error.localizedDescription)\n", stderr) Foundation.exit(1) } } - private static func loadCredentials(path: String) throws -> GeminiOAuthCredentials { - let url = URL(fileURLWithPath: NSString(string: path).expandingTildeInPath) - let data = try Data(contentsOf: url) - let credentials = try JSONDecoder().decode(GeminiOAuthCredentials.self, from: data) - guard credentials.refreshToken?.isEmpty == false else { - throw GeminiProbeError(message: "Gemini OAuth file does not contain a refresh token") - } - return credentials - } - - private static func refreshedAccessToken( - credentials: GeminiOAuthCredentials, - endpoint: URL, - clientID: String, - clientSecret: String - ) async throws -> String { - guard let refreshToken = credentials.refreshToken else { - throw GeminiProbeError(message: "Gemini OAuth file does not contain a refresh token") - } - - var request = URLRequest(url: endpoint) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = formEncoded([ - "client_id": clientID, - "client_secret": clientSecret, - "refresh_token": refreshToken, - "grant_type": "refresh_token", - ]) - - let data = try await send(request: request, redactedDescription: "OAuth refresh") - return try JSONDecoder().decode(GeminiRefreshResponse.self, from: data).accessToken - } - - private static func loadCodeAssist(accessToken: String, endpoint: URL) async throws -> GeminiLoadCodeAssistResponse { - var request = URLRequest(url: endpoint.appending(path: ":loadCodeAssist")) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = try JSONSerialization.data(withJSONObject: [ - "cloudaicompanionProject": NSNull(), - "metadata": [ - "ideType": "IDE_UNSPECIFIED", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI", - "duetProject": NSNull(), - ], - ]) - - let data = try await send(request: request, redactedDescription: "Code Assist load") - return try JSONDecoder().decode(GeminiLoadCodeAssistResponse.self, from: data) - } - - private static func retrieveUserQuota(accessToken: String, project: String, endpoint: URL) async throws -> Data { - var request = URLRequest(url: endpoint.appending(path: ":retrieveUserQuota")) - request.httpMethod = "POST" - request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.httpBody = try JSONSerialization.data(withJSONObject: ["project": project]) - return try await send(request: request, redactedDescription: "Code Assist quota") - } - - private static func send(request: URLRequest, redactedDescription: String) async throws -> Data { - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { - throw GeminiProbeError(message: "\(redactedDescription) returned a non-HTTP response") - } - guard (200..<300).contains(http.statusCode) else { - throw GeminiProbeError(message: "\(redactedDescription) returned HTTP \(http.statusCode); raw body redacted") - } - return data - } - - private static func formEncoded(_ values: [String: String]) -> Data { - values - .map { key, value in - "\(escape(key))=\(escape(value))" - } - .joined(separator: "&") - .data(using: .utf8) ?? Data() - } - - private static func escape(_ value: String) -> String { - var allowed = CharacterSet.urlQueryAllowed - allowed.remove(charactersIn: "&+=") - return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value - } - - private static func printSummary(buckets: [GeminiQuotaBucket], authPath: String, endpoint: URL, tierName: String?) { + private static func printSummary(result: ConnectorRefreshResult, account: GeminiAccountConfiguration) { print("Gemini Code Assist quota probe") - print("endpoint: \(endpoint.absoluteString)") - print("auth: \(redactedPath(authPath))") - print("tier: \(tierName ?? "unknown")") - print("buckets: \(buckets.count)") + print("endpoint: \(account.codeAssistEndpoint.absoluteString)") + print("auth: \(ConnectorRedactor.redactedPath(account.authPath))") + print("accounts: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies") print("") - for bucket in buckets.sorted(by: { $0.modelID < $1.modelID }) { - let remaining = bucket.remainingFraction.map { String(format: "%.1f%% remaining", $0 * 100) } ?? "unknown remaining" - let used = bucket.usedPercent.map { String(format: "%.1f%% used", $0) } ?? "unknown used" - let amount = bucket.remainingAmount.map { "remaining amount \($0)" } ?? "remaining amount absent" - let reset = bucket.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset" - print("- \(bucket.modelID): \(used), \(remaining), \(amount), resets \(reset)") - } - } - - private static func redactedPath(_ path: String) -> String { - let expanded = NSString(string: path).expandingTildeInPath - let home = FileManager.default.homeDirectoryForCurrentUser.path - if expanded.hasPrefix(home) { - return "~" + expanded.dropFirst(home.count) + for report in result.reports { + print("- \(report.accountName): \(report.status.rawValue)") + if let errorMessage = report.errorMessage { + print(" error: \(errorMessage)") + } + for limit in report.limits.sorted(by: { $0.label < $1.label }) { + let used = limit.used.map { "\($0)% used" } ?? "unknown used" + let remaining = limit.remaining.map { "\($0)% remaining" } ?? "unknown remaining" + let reset = limit.resetsAt.map { ContextPanelDateFormatting.string(from: $0) } ?? "unknown reset" + print(" - \(limit.label): \(used), \(remaining), resets \(reset)") + } } - return URL(fileURLWithPath: expanded).lastPathComponent } } diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift new file mode 100644 index 0000000..3306354 --- /dev/null +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func codexConnectorRefreshesMultipleAccountsIntoNormalizedLimits() async throws { + let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)! + let usage = #""" + { + "plan_type": "pro", + "rate_limit": { + "primary_window": { "used_percent": 50, "limit_window_seconds": 18000, "reset_at": 1788393600 }, + "secondary_window": { "used_percent": 25, "limit_window_seconds": 604800, "reset_at": 1788998400 } + } + } + """#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 200, data: usage)]) + let connector = CodexRateLimitConnector( + accounts: [ + CodexAccountConfiguration(authPath: "/tmp/openai-a.json", accountName: "OpenAI A"), + CodexAccountConfiguration(authPath: "/tmp/openai-b.json", accountName: "OpenAI B"), + ], + httpClient: http, + fileLoader: { _ in auth } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 2) + #expect(result.snapshot.limits.count == 4) + #expect(Set(result.snapshot.limits.map(\.accountName)) == ["OpenAI A", "OpenAI B"]) + #expect(result.snapshot.limits.allSatisfy { $0.provider == .openAI && $0.unit == .percent }) + #expect(result.snapshot.limits.contains { $0.used == 50 && $0.label.contains("300m") }) + #expect(http.requests.count == 2) +} + +@Test func codexConnectorRedactsHTTPFailures() async { + let auth = #"{"tokens":{"access_token":"token-secret"}}"#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ConnectorHTTPResponse(statusCode: 401, data: Data("secret body".utf8))]) + let connector = CodexRateLimitConnector( + accounts: [CodexAccountConfiguration(authPath: "/tmp/openai.json", accountName: "OpenAI")], + httpClient: http, + fileLoader: { _ in auth } + ) + + let result = await connector.refresh(now: Date()) + + #expect(result.reports.count == 1) + #expect(result.reports[0].status == .failure) + #expect(result.reports[0].errorMessage?.contains("HTTP 401") == true) + #expect(result.reports[0].errorMessage?.contains("secret body") == false) + #expect(result.snapshot.limits.isEmpty) +} + +@Test func geminiConnectorRefreshesQuotaBuckets() async throws { + let credentials = #"{"refresh_token":"refresh-secret"}"#.data(using: .utf8)! + let refresh = #"{"access_token":"access-secret"}"#.data(using: .utf8)! + let load = #"{"cloudaicompanionProject":"project-secret","currentTier":{"name":"Gemini Code Assist"}}"#.data(using: .utf8)! + let quota = #""" + { + "buckets": [ + { "modelId": "gemini-3-flash-preview", "remainingFraction": 0.75, "resetTime": "2026-05-06T16:04:50Z" } + ] + } + """#.data(using: .utf8)! + let http = StubHTTPClient(responses: [ + ConnectorHTTPResponse(statusCode: 200, data: refresh), + ConnectorHTTPResponse(statusCode: 200, data: load), + ConnectorHTTPResponse(statusCode: 200, data: quota), + ]) + let connector = GeminiCodeAssistConnector( + accounts: [GeminiAccountConfiguration(authPath: "/tmp/gemini.json", accountName: "Gemini", clientID: "client", clientSecret: "secret")], + httpClient: http, + fileLoader: { _ in credentials } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 1) + #expect(result.snapshot.limits.count == 1) + #expect(result.snapshot.limits[0].provider == .google) + #expect(result.snapshot.limits[0].label == "gemini-3-flash-preview") + #expect(result.snapshot.limits[0].used == 25) + #expect(http.requests.map(\.method) == ["POST", "POST", "POST"]) + #expect(http.requests[2].body.flatMap { String(data: $0, encoding: .utf8) }?.contains("project-secret") == true) +} + +@Test func claudeConnectorReportsUnknownLiveAllowanceFromLocalStatus() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration(accountName: "Claude", claudeBinary: "claude", statsPath: "/tmp/stats.json")], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { _ in stats }, + fileExists: { _ in true } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) + + #expect(result.reports.count == 1) + #expect(result.reports[0].status == .unknown) + #expect(result.snapshot.limits.count == 1) + #expect(result.snapshot.limits[0].provider == .anthropic) + #expect(result.snapshot.limits[0].status == .unknown) + #expect(result.snapshot.limits[0].note?.contains("subscription: pro") == true) +} + +@Test func providerConnectorRuntimeAggregatesConnectorSnapshots() async { + let connectorA = StubConnector(provider: .openAI, report: ProviderConnectorReport( + provider: .openAI, + accountID: "a", + accountName: "A", + generatedAt: Date(timeIntervalSince1970: 0), + limits: [UsageLimit(provider: .openAI, label: "A", used: 1, limit: 100)] + )) + let connectorB = StubConnector(provider: .google, report: ProviderConnectorReport( + provider: .google, + accountID: "b", + accountName: "B", + generatedAt: Date(timeIntervalSince1970: 0), + limits: [UsageLimit(provider: .google, label: "B", used: 2, limit: 100)] + )) + + let result = await ProviderConnectorRuntime(connectors: [connectorA, connectorB]).refreshAll(now: Date(timeIntervalSince1970: 10)) + + #expect(result.reports.count == 2) + #expect(result.snapshot.generatedAt == Date(timeIntervalSince1970: 10)) + #expect(Set(result.snapshot.limits.map(\.provider)) == [.openAI, .google]) +} + +private final class StubHTTPClient: ConnectorHTTPClient, @unchecked Sendable { + private var responses: [ConnectorHTTPResponse] + private(set) var requests: [ConnectorHTTPRequest] = [] + + init(responses: [ConnectorHTTPResponse]) { + self.responses = responses + } + + func data(for request: ConnectorHTTPRequest) async throws -> ConnectorHTTPResponse { + requests.append(request) + guard !responses.isEmpty else { + return ConnectorHTTPResponse(statusCode: 500, data: Data()) + } + if responses.count == 1 { + return responses[0] + } + return responses.removeFirst() + } +} + +private struct StubProcessClient: ConnectorProcessClient { + let result: ConnectorProcessResult + + func run(executable: String, arguments: [String]) throws -> ConnectorProcessResult { + result + } +} + +private struct StubConnector: ProviderConnector { + let provider: Provider + let report: ProviderConnectorReport + + func refresh(now: Date) async -> ConnectorRefreshResult { + ConnectorRefreshResult(generatedAt: now, reports: [report]) + } +} + diff --git a/docs/architecture.md b/docs/architecture.md index c262388..7b97edb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,6 +18,30 @@ Context Panel is expected to split into a few native boundaries: The first committed code lives in `ContextPanelCore` so provider, account, and UI work can share the same vocabulary from the start. +## Connector Runtime + +`ContextPanelCore` owns the provider connector contract. A connector refreshes +one or more configured local accounts and returns a `ConnectorRefreshResult`, +which carries provider/account reports plus a normalized `UsageSnapshot` for UI +and storage code. + +MVP connectors: + +- `CodexRateLimitConnector`: reads Codex-style auth roots such as `~/.code` or + `~/.codex`, calls the live Codex usage endpoint, and normalizes primary, + secondary, and additional percent-window buckets. +- `GeminiCodeAssistConnector`: reads Gemini CLI OAuth credentials, uses + explicitly supplied OAuth client inputs, resolves the active Code Assist + project internally, and normalizes model quota buckets as percent pressure. +- `ClaudeLocalStatusConnector`: runs `claude auth status --json` and summarizes + `~/.claude/stats-cache.json`; live personal subscription allowance remains + unknown unless a clean provider signal appears. + +Connector implementations must keep secrets out of normalized state. Do not +persist or print tokens, account IDs, project IDs, organization IDs, emails, +headers, or raw response bodies. Errors should mention status and operation but +not provider response content. + Widget interactions should keep the widget simple. Tapping the widget should open the app to the relevant provider or account detail; mutation and setup stay inside the app. From ecc6ab5895ff574ee85a1f6ef6edabf1e5a3ed2d Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 12:10:18 -0400 Subject: [PATCH 17/34] Add local snapshot store --- Package.swift | 8 + README.md | 3 + Sources/ContextPanelCore/SnapshotStore.swift | 237 ++++++++++++++++++ Sources/SnapshotStoreProbe/main.swift | 108 ++++++++ .../SnapshotStoreTests.swift | 149 +++++++++++ docs/architecture.md | 12 + 6 files changed, 517 insertions(+) create mode 100644 Sources/ContextPanelCore/SnapshotStore.swift create mode 100644 Sources/SnapshotStoreProbe/main.swift create mode 100644 Tests/ContextPanelCoreTests/SnapshotStoreTests.swift diff --git a/Package.swift b/Package.swift index f9a6172..ea15abd 100644 --- a/Package.swift +++ b/Package.swift @@ -31,6 +31,10 @@ let package = Package( .executable( name: "ClaudeLimitProbe", targets: ["ClaudeLimitProbe"] + ), + .executable( + name: "SnapshotStoreProbe", + targets: ["SnapshotStoreProbe"] ) ], targets: [ @@ -55,6 +59,10 @@ let package = Package( name: "ClaudeLimitProbe", dependencies: ["ContextPanelCore"] ), + .executableTarget( + name: "SnapshotStoreProbe", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/README.md b/README.md index 33a7419..72e47ae 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ swift run CodexRateLimitProbe --auth ~/.codex/auth.json GEMINI_OAUTH_CLIENT_ID=... GEMINI_OAUTH_CLIENT_SECRET=... \ swift run GeminiQuotaProbe --auth ~/.gemini/oauth_creds.json swift run ClaudeLimitProbe +swift run SnapshotStoreProbe --codex-auth ~/.codex/auth.json --include-claude ``` The Codex and Gemini probes can return live percent-window quota buckets for @@ -75,3 +76,5 @@ they are intentionally not checked into this repository. The probes call the same `ContextPanelCore` connectors the app will use, so passing probe output is also a smoke test for the production connector runtime. +`SnapshotStoreProbe` additionally writes and reloads the local JSON cache shape +that the app and widget will consume. diff --git a/Sources/ContextPanelCore/SnapshotStore.swift b/Sources/ContextPanelCore/SnapshotStore.swift new file mode 100644 index 0000000..91969b6 --- /dev/null +++ b/Sources/ContextPanelCore/SnapshotStore.swift @@ -0,0 +1,237 @@ +import Foundation + +public struct StoredUsageSnapshot: Codable, Equatable, Sendable { + public let schemaVersion: Int + public let savedAt: Date + public let snapshot: UsageSnapshot + public let reports: [StoredProviderReport] + + public init(savedAt: Date, snapshot: UsageSnapshot, reports: [StoredProviderReport] = []) { + self.schemaVersion = 1 + self.savedAt = savedAt + self.snapshot = snapshot + self.reports = reports + } + + public init(savedAt: Date, refreshResult: ConnectorRefreshResult) { + self.init( + savedAt: savedAt, + snapshot: refreshResult.snapshot, + reports: refreshResult.reports.map(StoredProviderReport.init(report:)) + ) + } +} + +public struct StoredProviderReport: Codable, Equatable, Sendable { + public let provider: Provider + public let accountID: String + public let accountName: String + public let generatedAt: Date + public let status: UsageStatus + public let errorMessage: String? + + public init( + provider: Provider, + accountID: String, + accountName: String, + generatedAt: Date, + status: UsageStatus, + errorMessage: String? + ) { + self.provider = provider + self.accountID = accountID + self.accountName = accountName + self.generatedAt = generatedAt + self.status = status + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } + + public init(report: ProviderConnectorReport) { + self.init( + provider: report.provider, + accountID: report.accountID, + accountName: report.accountName, + generatedAt: report.generatedAt, + status: report.status, + errorMessage: report.errorMessage + ) + } +} + +public enum SnapshotStoreError: LocalizedError, Equatable, Sendable { + case unsupportedSchema(version: Int) + case corruptStore(String) + + public var errorDescription: String? { + switch self { + case let .unsupportedSchema(version): + "Unsupported snapshot schema version \(version)" + case let .corruptStore(message): + message + } + } +} + +public struct SnapshotStoreLoadResult: Equatable, Sendable { + public let snapshot: StoredUsageSnapshot? + public let status: UsageStatus + public let errorMessage: String? + + public init(snapshot: StoredUsageSnapshot?, status: UsageStatus, errorMessage: String? = nil) { + self.snapshot = snapshot + self.status = status + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } +} + +public struct SnapshotStoreQuery: Equatable, Sendable { + public let provider: Provider? + public let accountID: String? + public let since: Date? + public let limit: Int? + + public init(provider: Provider? = nil, accountID: String? = nil, since: Date? = nil, limit: Int? = nil) { + self.provider = provider + self.accountID = accountID + self.since = since + self.limit = limit + } +} + +public struct SnapshotStoreStalenessPolicy: Equatable, Sendable { + public let maximumAge: TimeInterval + + public init(maximumAge: TimeInterval = 15 * 60) { + precondition(maximumAge >= 0, "maximumAge must not be negative") + self.maximumAge = maximumAge + } + + public func status(for storedSnapshot: StoredUsageSnapshot?, now: Date) -> UsageStatus { + guard let storedSnapshot else { return .unknown } + if now.timeIntervalSince(storedSnapshot.savedAt) > maximumAge { + return .stale + } + return storedSnapshot.snapshot.aggregateStatus + } +} + +public struct JSONSnapshotStore: Sendable { + public let rootDirectory: URL + + public init(rootDirectory: URL) { + self.rootDirectory = rootDirectory + } + + public var currentSnapshotURL: URL { + rootDirectory.appending(path: "current-snapshot.json") + } + + public var historyDirectoryURL: URL { + rootDirectory.appending(path: "history", directoryHint: .isDirectory) + } + + public func save(_ storedSnapshot: StoredUsageSnapshot) throws { + try ensureDirectories() + let data = try Self.makeEncoder().encode(storedSnapshot) + try data.write(to: currentSnapshotURL, options: [.atomic]) + let historyURL = historyURL(for: storedSnapshot.savedAt) + try data.write(to: historyURL, options: [.atomic]) + } + + public func loadCurrent() -> SnapshotStoreLoadResult { + guard FileManager.default.fileExists(atPath: currentSnapshotURL.path) else { + return SnapshotStoreLoadResult(snapshot: nil, status: .unknown) + } + + do { + let snapshot = try loadSnapshot(from: currentSnapshotURL) + return SnapshotStoreLoadResult(snapshot: snapshot, status: snapshot.snapshot.aggregateStatus) + } catch { + return SnapshotStoreLoadResult( + snapshot: nil, + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + public func loadCurrent(policy: SnapshotStoreStalenessPolicy, now: Date = Date()) -> SnapshotStoreLoadResult { + let result = loadCurrent() + guard result.status != .failure else { return result } + return SnapshotStoreLoadResult( + snapshot: result.snapshot, + status: policy.status(for: result.snapshot, now: now), + errorMessage: result.errorMessage + ) + } + + public func loadHistory(query: SnapshotStoreQuery = SnapshotStoreQuery()) -> [StoredUsageSnapshot] { + guard let urls = try? FileManager.default.contentsOfDirectory( + at: historyDirectoryURL, + includingPropertiesForKeys: nil + ) else { + return [] + } + + let snapshots = urls + .filter { $0.pathExtension == "json" } + .compactMap { try? loadSnapshot(from: $0) } + .filter { snapshot in + if let since = query.since, snapshot.savedAt < since { return false } + if let provider = query.provider, !snapshot.snapshot.limits.contains(where: { $0.provider == provider }) { + return false + } + if let accountID = query.accountID, !snapshot.snapshot.limits.contains(where: { $0.accountID == accountID }) { + return false + } + return true + } + .sorted { $0.savedAt > $1.savedAt } + + if let limit = query.limit { + return Array(snapshots.prefix(max(limit, 0))) + } + return snapshots + } + + private func ensureDirectories() throws { + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: historyDirectoryURL, withIntermediateDirectories: true) + } + + private func loadSnapshot(from url: URL) throws -> StoredUsageSnapshot { + let data = try Data(contentsOf: url) + let snapshot = try Self.makeDecoder().decode(StoredUsageSnapshot.self, from: data) + guard snapshot.schemaVersion == 1 else { + throw SnapshotStoreError.unsupportedSchema(version: snapshot.schemaVersion) + } + return snapshot + } + + private func historyURL(for date: Date) -> URL { + let timestamp = ContextPanelDateFormatting.historyFileTimestamp(from: date) + return historyDirectoryURL.appending(path: "\(timestamp).json") + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +extension ContextPanelDateFormatting { + static func historyFileTimestamp(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: date) + .replacingOccurrences(of: ":", with: "-") + } +} diff --git a/Sources/SnapshotStoreProbe/main.swift b/Sources/SnapshotStoreProbe/main.swift new file mode 100644 index 0000000..7da8de4 --- /dev/null +++ b/Sources/SnapshotStoreProbe/main.swift @@ -0,0 +1,108 @@ +import ContextPanelCore +import Foundation + +struct SnapshotStoreProbeError: LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +struct ProbeConfiguration { + let outputDirectory: URL + let codexAuthPath: String? + let includeClaude: Bool + + static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { + var outputDirectory: URL? + var codexAuthPath: String? + var includeClaude = false + var iterator = arguments.dropFirst().makeIterator() + + while let argument = iterator.next() { + switch argument { + case "--output": + guard let value = iterator.next() else { + throw SnapshotStoreProbeError(message: "--output requires a directory path") + } + outputDirectory = URL(fileURLWithPath: NSString(string: value).expandingTildeInPath) + case "--codex-auth": + guard let value = iterator.next() else { + throw SnapshotStoreProbeError(message: "--codex-auth requires a path") + } + codexAuthPath = value + case "--include-claude": + includeClaude = true + case "--help", "-h": + printHelp() + Foundation.exit(0) + default: + throw SnapshotStoreProbeError(message: "unknown argument: \(argument)") + } + } + + return ProbeConfiguration( + outputDirectory: outputDirectory ?? defaultOutputDirectory(), + codexAuthPath: codexAuthPath, + includeClaude: includeClaude + ) + } + + private static func defaultOutputDirectory() -> URL { + FileManager.default.temporaryDirectory + .appending(path: "context-panel-snapshot-store", directoryHint: .isDirectory) + } + + private static func printHelp() { + print(""" + Usage: swift run SnapshotStoreProbe [--output /tmp/context-panel-store] [--codex-auth ~/.codex/auth.json] [--include-claude] + + Refreshes selected local connectors, writes the normalized snapshot to + the JSON snapshot store, then reloads it. The store contains normalized + usage state only; tokens, account identifiers, project IDs, emails, + headers, and raw provider responses are not persisted. + """) + } +} + +@main +struct SnapshotStoreProbe { + static func main() async { + do { + let configuration = try ProbeConfiguration.fromArguments(CommandLine.arguments) + let connectors = makeConnectors(configuration: configuration) + guard !connectors.isEmpty else { + throw SnapshotStoreProbeError(message: "no connectors selected") + } + + let result = await ProviderConnectorRuntime(connectors: connectors).refreshAll() + let store = JSONSnapshotStore(rootDirectory: configuration.outputDirectory) + try store.save(StoredUsageSnapshot(savedAt: Date(), refreshResult: result)) + let loaded = store.loadCurrent(policy: SnapshotStoreStalenessPolicy()) + + print("Context Panel snapshot store probe") + print("store: \(ConnectorRedactor.redactedPath(configuration.outputDirectory.path))") + print("reports: \(result.reports.count)") + print("limits: \(result.snapshot.limits.count)") + print("load status: \(loaded.status.rawValue)") + print("history entries: \(store.loadHistory().count)") + print("redacted: tokens, account identifiers, project IDs, emails, headers, raw response bodies") + } catch { + fputs("SnapshotStoreProbe failed: \(error.localizedDescription)\n", stderr) + Foundation.exit(1) + } + } + + private static func makeConnectors(configuration: ProbeConfiguration) -> [any ProviderConnector] { + var connectors: [any ProviderConnector] = [] + if let codexAuthPath = configuration.codexAuthPath { + connectors.append(CodexRateLimitConnector(accounts: [ + CodexAccountConfiguration(authPath: codexAuthPath) + ])) + } + if configuration.includeClaude { + connectors.append(ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()])) + } + return connectors + } +} + diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift new file mode 100644 index 0000000..9209266 --- /dev/null +++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func jsonSnapshotStoreRoundTripsCurrentSnapshotAndReports() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let savedAt = Date(timeIntervalSince1970: 100) + let refresh = ConnectorRefreshResult(generatedAt: savedAt, reports: [ProviderConnectorReport( + provider: .openAI, + accountID: "local-openai", + accountName: "OpenAI", + generatedAt: savedAt, + limits: [usageLimit(provider: .openAI, accountID: "local-openai", used: 30, savedAt: savedAt)] + )]) + + try store.save(StoredUsageSnapshot(savedAt: savedAt, refreshResult: refresh)) + + let result = store.loadCurrent() + + #expect(result.status == .healthy) + #expect(result.snapshot?.schemaVersion == 1) + #expect(result.snapshot?.savedAt == savedAt) + #expect(result.snapshot?.snapshot.limits.count == 1) + #expect(result.snapshot?.reports.count == 1) + #expect(result.snapshot?.reports[0].provider == .openAI) + #expect(FileManager.default.fileExists(atPath: store.currentSnapshotURL.path)) + #expect(store.loadHistory().count == 1) +} + +@Test func jsonSnapshotStoreFiltersHistoryByProviderAccountAndLimit() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let first = Date(timeIntervalSince1970: 100) + let second = Date(timeIntervalSince1970: 200) + + try store.save(StoredUsageSnapshot(savedAt: first, snapshot: UsageSnapshot( + generatedAt: first, + limits: [usageLimit(provider: .openAI, accountID: "a", used: 10, savedAt: first)] + ))) + try store.save(StoredUsageSnapshot(savedAt: second, snapshot: UsageSnapshot( + generatedAt: second, + limits: [usageLimit(provider: .google, accountID: "b", used: 20, savedAt: second)] + ))) + + #expect(store.loadHistory().map(\.savedAt) == [second, first]) + #expect(store.loadHistory(query: SnapshotStoreQuery(provider: .openAI)).map(\.savedAt) == [first]) + #expect(store.loadHistory(query: SnapshotStoreQuery(accountID: "b")).map(\.savedAt) == [second]) + #expect(store.loadHistory(query: SnapshotStoreQuery(limit: 1)).count == 1) +} + +@Test func jsonSnapshotStoreReportsMissingCurrentAsUnknown() throws { + let store = JSONSnapshotStore(rootDirectory: try temporaryDirectory()) + + let result = store.loadCurrent() + + #expect(result.snapshot == nil) + #expect(result.status == .unknown) +} + +@Test func jsonSnapshotStoreReportsCorruptCurrentAsFailureWithoutThrowing() throws { + let root = try temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data("not json".utf8).write(to: root.appending(path: "current-snapshot.json")) + + let result = JSONSnapshotStore(rootDirectory: root).loadCurrent() + + #expect(result.snapshot == nil) + #expect(result.status == .failure) + #expect(result.errorMessage?.isEmpty == false) +} + +@Test func jsonSnapshotStoreAppliesStalenessPolicy() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let savedAt = Date(timeIntervalSince1970: 100) + try store.save(StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [usageLimit(provider: .google, accountID: "g", used: 20, savedAt: savedAt)] + ))) + + let fresh = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 120)) + let stale = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 60), now: Date(timeIntervalSince1970: 200)) + + #expect(fresh.status == .healthy) + #expect(stale.status == .stale) +} + +@Test func jsonSnapshotStoreRejectsUnsupportedSchema() throws { + let root = try temporaryDirectory() + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let current = root.appending(path: "current-snapshot.json") + let json = #""" + { + "schemaVersion": 99, + "savedAt": "1970-01-01T00:00:00Z", + "snapshot": { "generatedAt": "1970-01-01T00:00:00Z", "limits": [] }, + "reports": [] + } + """# + try Data(json.utf8).write(to: current) + + let result = JSONSnapshotStore(rootDirectory: root).loadCurrent() + + #expect(result.status == .failure) + #expect(result.errorMessage?.contains("Unsupported snapshot schema") == true) +} + +@Test func storedProviderReportRedactsErrorMessages() { + let report = ProviderConnectorReport( + provider: .openAI, + accountID: "local", + accountName: "OpenAI", + generatedAt: Date(timeIntervalSince1970: 0), + limits: [], + status: .failure, + errorMessage: "failed for user@example.com with bearer sk-secret" + ) + + let stored = StoredProviderReport(report: report) + + #expect(stored.errorMessage?.contains("user@example.com") == false) + #expect(stored.errorMessage?.contains("sk-secret") == false) +} + +private func usageLimit(provider: Provider, accountID: String, used: Int, savedAt: Date) -> UsageLimit { + UsageLimit( + provider: provider, + accountID: accountID, + accountName: accountID, + label: "usage", + unit: .percent, + used: used, + limit: 100, + resetsAt: savedAt.addingTimeInterval(3_600), + lastUpdatedAt: savedAt, + confidence: .observed + ) +} + +private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "context-panel-tests") + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + diff --git a/docs/architecture.md b/docs/architecture.md index 7b97edb..aedd25d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,18 @@ persist or print tokens, account IDs, project IDs, organization IDs, emails, headers, or raw response bodies. Errors should mention status and operation but not provider response content. +## Snapshot Store + +The MVP cache is a local JSON store. It writes one current snapshot file plus a +history directory of timestamped snapshots. The schema is intentionally simple: +`StoredUsageSnapshot` includes a schema version, save time, normalized +`UsageSnapshot`, and redacted provider refresh reports. + +The widget should read `current-snapshot.json` and apply a staleness policy. It +must not read provider credential files or make provider network calls. The app +owns connector refreshes, account setup, diagnostics, and future migration from +JSON to a richer store if history queries become more complex. + Widget interactions should keep the widget simple. Tapping the widget should open the app to the relevant provider or account detail; mutation and setup stay inside the app. From b15860e9c1e31e256fdfb4471a66e8bfbbcf37e5 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 12:14:53 -0400 Subject: [PATCH 18/34] Wire preview app to snapshot cache --- .../ContextPanelPreviewApp.swift | 200 ++++++++++++++++-- 1 file changed, 179 insertions(+), 21 deletions(-) diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 043f07f..7d1da7f 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -5,42 +5,48 @@ import SwiftUI struct ContextPanelPreviewApp: App { var body: some Scene { WindowGroup { - AppRoot(snapshot: SampleUsageData.snapshot) + AppRoot() .frame(minWidth: 1280, minHeight: 720) } } } struct AppRoot: View { - let snapshot: UsageSnapshot + @StateObject private var model = ContextPanelAppModel() @State private var selectedID: UsageLimit.ID? + private var snapshot: UsageSnapshot { + model.currentSnapshot + } + private var selectedLimit: UsageLimit { if let selectedID, let match = snapshot.limits.first(where: { $0.id == selectedID }) { return match } - return snapshot.mostConstrainedLimits[0] + return snapshot.mostConstrainedLimits.first ?? SampleUsageData.snapshot.mostConstrainedLimits[0] } var body: some View { HStack(spacing: 0) { - AccountsSidebar(snapshot: snapshot, selectedID: $selectedID) + AccountsSidebar(model: model, snapshot: snapshot, selectedID: $selectedID) .frame(width: 210) Divider() - InstrumentDashboard(snapshot: snapshot) + InstrumentDashboard(model: model, snapshot: snapshot) .frame(minWidth: 740) Divider() - AccountDetail(limit: selectedLimit, generatedAt: snapshot.generatedAt) + AccountDetail(model: model, limit: selectedLimit, generatedAt: snapshot.generatedAt) .frame(width: 320) } .tint(CPTheme.accent) .onAppear { + model.loadSnapshot() selectedID = selectedID ?? snapshot.mostConstrainedLimits.first?.id } } } struct AccountsSidebar: View { + @ObservedObject var model: ContextPanelAppModel let snapshot: UsageSnapshot @Binding var selectedID: UsageLimit.ID? @@ -61,12 +67,22 @@ struct AccountsSidebar: View { } .navigationTitle("Context Panel") .safeAreaInset(edge: .bottom) { - Button { - } label: { - Label("Add Account", systemImage: "plus") - .frame(maxWidth: .infinity) + VStack(spacing: 8) { + Button { + Task { await model.refreshLocalConnectors() } + } label: { + Label(model.isRefreshing ? "Refreshing" : "Refresh", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity) + } + .disabled(model.isRefreshing) + + Button { + } label: { + Label("Add Account", systemImage: "plus") + .frame(maxWidth: .infinity) + } } - .buttonStyle(.borderedProminent) + .buttonStyle(.bordered) .controlSize(.large) .padding(12) } @@ -83,11 +99,11 @@ struct ProviderSidebarRow: View { Text(provider.displayName) .font(.system(size: 12, weight: .semibold)) .textCase(.uppercase) - .foregroundStyle(CPTheme.secondaryText) + .foregroundStyle(.secondary) Spacer() Text("\(limits.count)") .font(.system(.caption, design: .monospaced, weight: .medium)) - .foregroundStyle(CPTheme.tertiaryText) + .foregroundStyle(.tertiary) } } } @@ -103,18 +119,19 @@ struct SidebarLimitRow: View { .font(.system(size: 13, weight: .medium)) Text(limit.label) .font(.system(size: 11)) - .foregroundStyle(CPTheme.tertiaryText) + .foregroundStyle(.secondary) } Spacer() Text(limit.compactUsageText) .font(.system(.caption2, design: .monospaced, weight: .medium)) - .foregroundStyle(CPTheme.secondaryText) + .foregroundStyle(.secondary) } .padding(.vertical, 3) } } struct InstrumentDashboard: View { + @ObservedObject var model: ContextPanelAppModel let snapshot: UsageSnapshot private var constrained: [UsageLimit] { @@ -124,7 +141,8 @@ struct InstrumentDashboard: View { var body: some View { ScrollView([.vertical, .horizontal]) { VStack(alignment: .leading, spacing: 18) { - HeaderCard(snapshot: snapshot) + HeaderCard(model: model, snapshot: snapshot) + SetupStatusStrip(model: model) WidgetPreviewGrid(snapshot: snapshot) SectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) accounts") VStack(spacing: 10) { @@ -144,6 +162,7 @@ struct InstrumentDashboard: View { } struct HeaderCard: View { + @ObservedObject var model: ContextPanelAppModel let snapshot: UsageSnapshot var body: some View { @@ -163,7 +182,7 @@ struct HeaderCard: View { HStack(spacing: 8) { TagLabel("SwiftUI") TagLabel("WidgetKit") - TagLabel("Keychain-local") + TagLabel(model.storeStatus.rawValue) } } Spacer(minLength: 16) @@ -183,6 +202,63 @@ struct HeaderCard: View { } } +struct SetupStatusStrip: View { + @ObservedObject var model: ContextPanelAppModel + + var body: some View { + HStack(spacing: 12) { + SetupStatusItem( + title: "Snapshot cache", + value: model.storeStatus == .healthy ? "Ready" : model.storeStatus.rawValue, + status: model.storeStatus + ) + SetupStatusItem( + title: "History", + value: "\(model.historyCount) entries", + status: model.historyCount > 0 ? .healthy : .unknown + ) + SetupStatusItem( + title: "Last refresh", + value: model.lastRefreshText, + status: model.isRefreshing ? .loading : .healthy + ) + Spacer(minLength: 12) + if let errorMessage = model.errorMessage { + Text(errorMessage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPTheme.statusColor(.failure)) + .lineLimit(1) + } + } + .padding(14) + .background(CPTheme.surface) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 10)) + } +} + +struct SetupStatusItem: View { + let title: String + let value: String + let status: UsageStatus + + var body: some View { + HStack(spacing: 8) { + StatusMark(status: status, size: 8) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(CPTheme.tertiaryText) + .textCase(.uppercase) + Text(value) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPTheme.secondaryText) + .lineLimit(1) + } + } + } +} + struct WidgetPreviewGrid: View { let snapshot: UsageSnapshot @@ -358,6 +434,7 @@ struct ProviderGroupGrid: View { } struct AccountDetail: View { + @ObservedObject var model: ContextPanelAppModel let limit: UsageLimit let generatedAt: Date @@ -399,16 +476,22 @@ struct AccountDetail: View { DetailRow(label: "Limit", value: limit.limit.map(String.init) ?? "unknown") DetailRow(label: "Remaining", value: limit.remaining.map(String.init) ?? "unknown") DetailRow(label: "Status", value: limit.status.rawValue) - DetailRow(label: "Updated", value: "2m ago") + DetailRow(label: "Updated", value: limit.lastUpdatedAt.map(model.relativeTime) ?? "unknown") } DetailCard(title: "Refresh history") { Sparkline(values: [0.72, 0.68, 0.70, 0.64, 0.62, 0.58, 0.64]) .frame(height: 42) - Text("Last good snapshot preserved for stale and failure states.") + Text("\(model.historyCount) cached snapshots. Last good snapshot is preserved for stale and failure states.") .font(.system(size: 12)) .foregroundStyle(CPTheme.secondaryText) } + + DetailCard(title: "Setup") { + DetailRow(label: "Store", value: ConnectorRedactor.redactedPath(model.store.rootDirectory.path)) + DetailRow(label: "Codex", value: ConnectorRedactor.redactedPath(model.codexAuthPath)) + DetailRow(label: "Claude", value: "local status") + } } .padding(22) } @@ -420,8 +503,8 @@ struct AccountDetail: View { if limit.provider == .openAI, limit.label.contains("GPT-5") { return FastModeForecast( input: FastModeForecastInput( - limit: limit, - now: SampleUsageData.referenceNow, + limit: limit, + now: model.now, standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), reserveUnits: 6, @@ -555,6 +638,81 @@ struct AccountRow: View { } } +@MainActor +final class ContextPanelAppModel: ObservableObject { + @Published private(set) var storedSnapshot: StoredUsageSnapshot? + @Published private(set) var storeStatus: UsageStatus = .unknown + @Published private(set) var historyCount: Int = 0 + @Published private(set) var isRefreshing = false + @Published private(set) var errorMessage: String? + @Published private(set) var lastRefreshAt: Date? + + let now = Date() + let store: JSONSnapshotStore + let codexAuthPath: String + + var currentSnapshot: UsageSnapshot { + storedSnapshot?.snapshot ?? SampleUsageData.snapshot + } + + var lastRefreshText: String { + lastRefreshAt.map(relativeTime) ?? "not yet" + } + + init() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + codexAuthPath = "\(home)/.codex/auth.json" + store = JSONSnapshotStore(rootDirectory: Self.defaultStoreDirectory()) + } + + func loadSnapshot() { + let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 15 * 60), now: Date()) + storedSnapshot = result.snapshot + storeStatus = result.status + errorMessage = result.errorMessage + historyCount = store.loadHistory().count + } + + func refreshLocalConnectors() async { + isRefreshing = true + defer { isRefreshing = false } + + let connectors: [any ProviderConnector] = [ + CodexRateLimitConnector(accounts: [CodexAccountConfiguration(authPath: codexAuthPath)]), + ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()]), + ] + let refreshResult = await ProviderConnectorRuntime(connectors: connectors).refreshAll() + let savedAt = Date() + + do { + try store.save(StoredUsageSnapshot(savedAt: savedAt, refreshResult: refreshResult)) + lastRefreshAt = savedAt + loadSnapshot() + } catch { + storeStatus = .failure + errorMessage = error.localizedDescription + } + } + + func relativeTime(_ date: Date) -> String { + let seconds = max(Int(Date().timeIntervalSince(date)), 0) + if seconds < 60 { return "just now" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } + + private static func defaultStoreDirectory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") + return base + .appending(path: "Context Panel", directoryHint: .isDirectory) + .appending(path: "Snapshots", directoryHint: .isDirectory) + } +} + struct CapacityDial: View { let value: Double let status: UsageStatus From b95d52447f3dd0f0da3abf45ff471b84e1f7f6e5 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 12:37:04 -0400 Subject: [PATCH 19/34] Use cached OpenAI limits for fast mode forecast --- .../ContextPanelPreviewApp.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 7d1da7f..ba73c43 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -176,7 +176,7 @@ struct HeaderCard: View { Text(snapshot.subheadline) .font(.system(size: 13)) .foregroundStyle(CPTheme.secondaryText) - Text(SampleUsageData.fastModeForecast.copy) + Text(model.fastModeForecast.copy) .font(.system(size: 13, weight: .medium)) .foregroundStyle(CPTheme.accent) HStack(spacing: 8) { @@ -655,6 +655,22 @@ final class ContextPanelAppModel: ObservableObject { storedSnapshot?.snapshot ?? SampleUsageData.snapshot } + var fastModeForecast: FastModePortfolioForecast { + let forecasts = currentSnapshot.limits + .filter { $0.provider == .openAI && $0.unit == .percent } + .map { limit in + FastModeForecast(input: FastModeForecastInput( + limit: limit, + now: Date(), + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + )) + } + return FastModePortfolioForecast(forecasts: forecasts) + } + var lastRefreshText: String { lastRefreshAt.map(relativeTime) ?? "not yet" } From cc40192dd6f247f102b6e6d70a2ef3caf484aa9a Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 12:41:38 -0400 Subject: [PATCH 20/34] Add local account configuration store --- .../AccountConfigurationStore.swift | 192 ++++++++++++++++++ .../ContextPanelPreviewApp.swift | 32 ++- .../AccountConfigurationStoreTests.swift | 94 +++++++++ docs/architecture.md | 8 + 4 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 Sources/ContextPanelCore/AccountConfigurationStore.swift create mode 100644 Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift new file mode 100644 index 0000000..5213d7d --- /dev/null +++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift @@ -0,0 +1,192 @@ +import Foundation + +public enum AccountConnectorKind: String, Codable, Equatable, Sendable { + case codexRateLimits + case geminiCodeAssist + case claudeLocalStatus +} + +public struct LocalProviderAccountConfiguration: Codable, Equatable, Identifiable, Sendable { + public let id: String + public let provider: Provider + public let connectorKind: AccountConnectorKind + public var displayName: String + public var isEnabled: Bool + public var authPath: String? + public var commandPath: String? + public var statsPath: String? + public var oauthClientIDEnvironmentName: String? + public var oauthClientSecretEnvironmentName: String? + + public init( + id: String, + provider: Provider, + connectorKind: AccountConnectorKind, + displayName: String, + isEnabled: Bool = true, + authPath: String? = nil, + commandPath: String? = nil, + statsPath: String? = nil, + oauthClientIDEnvironmentName: String? = nil, + oauthClientSecretEnvironmentName: String? = nil + ) { + self.id = id + self.provider = provider + self.connectorKind = connectorKind + self.displayName = displayName + self.isEnabled = isEnabled + self.authPath = authPath + self.commandPath = commandPath + self.statsPath = statsPath + self.oauthClientIDEnvironmentName = oauthClientIDEnvironmentName + self.oauthClientSecretEnvironmentName = oauthClientSecretEnvironmentName + } +} + +public struct AccountConfigurationDocument: Codable, Equatable, Sendable { + public let schemaVersion: Int + public var updatedAt: Date + public var accounts: [LocalProviderAccountConfiguration] + + public init(updatedAt: Date, accounts: [LocalProviderAccountConfiguration]) { + schemaVersion = 1 + self.updatedAt = updatedAt + self.accounts = accounts + } +} + +public struct AccountConfigurationLoadResult: Equatable, Sendable { + public let document: AccountConfigurationDocument + public let status: UsageStatus + public let errorMessage: String? + + public init(document: AccountConfigurationDocument, status: UsageStatus, errorMessage: String? = nil) { + self.document = document + self.status = status + self.errorMessage = errorMessage.map(ConnectorRedactor.redact) + } +} + +public struct AccountConfigurationStore: Sendable { + public let configurationURL: URL + + public init(configurationURL: URL) { + self.configurationURL = configurationURL + } + + public func load(now: Date = Date()) -> AccountConfigurationLoadResult { + guard FileManager.default.fileExists(atPath: configurationURL.path) else { + return AccountConfigurationLoadResult(document: Self.defaultDocument(now: now), status: .unknown) + } + + do { + let document = try Self.makeDecoder().decode( + AccountConfigurationDocument.self, + from: try Data(contentsOf: configurationURL) + ) + guard document.schemaVersion == 1 else { + throw SnapshotStoreError.unsupportedSchema(version: document.schemaVersion) + } + return AccountConfigurationLoadResult(document: document, status: .healthy) + } catch { + return AccountConfigurationLoadResult( + document: Self.defaultDocument(now: now), + status: .failure, + errorMessage: error.localizedDescription + ) + } + } + + public func save(_ document: AccountConfigurationDocument) throws { + let directory = configurationURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try Self.makeEncoder().encode(document) + try data.write(to: configurationURL, options: [.atomic]) + } + + public static func defaultDocument(now: Date = Date()) -> AccountConfigurationDocument { + let home = FileManager.default.homeDirectoryForCurrentUser.path + return AccountConfigurationDocument(updatedAt: now, accounts: [ + LocalProviderAccountConfiguration( + id: "openai-codex-default", + provider: .openAI, + connectorKind: .codexRateLimits, + displayName: "Codex", + authPath: "\(home)/.codex/auth.json" + ), + LocalProviderAccountConfiguration( + id: "claude-local-default", + provider: .anthropic, + connectorKind: .claudeLocalStatus, + displayName: "Claude", + commandPath: "claude", + statsPath: "\(home)/.claude/stats-cache.json" + ), + LocalProviderAccountConfiguration( + id: "gemini-code-assist-default", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + isEnabled: false, + authPath: "\(home)/.gemini/oauth_creds.json", + oauthClientIDEnvironmentName: "GEMINI_OAUTH_CLIENT_ID", + oauthClientSecretEnvironmentName: "GEMINI_OAUTH_CLIENT_SECRET" + ), + ]) + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} + +public enum AccountConnectorFactory { + public static func connectors( + from document: AccountConfigurationDocument, + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> [any ProviderConnector] { + document.accounts.compactMap { account in + guard account.isEnabled else { return nil } + switch account.connectorKind { + case .codexRateLimits: + guard let authPath = account.authPath else { return nil } + return CodexRateLimitConnector(accounts: [CodexAccountConfiguration( + authPath: authPath, + accountName: account.displayName + )]) + case .geminiCodeAssist: + guard + let authPath = account.authPath, + let clientIDName = account.oauthClientIDEnvironmentName, + let clientSecretName = account.oauthClientSecretEnvironmentName, + let clientID = environment[clientIDName], + let clientSecret = environment[clientSecretName] + else { + return nil + } + return GeminiCodeAssistConnector(accounts: [GeminiAccountConfiguration( + authPath: authPath, + accountName: account.displayName, + clientID: clientID, + clientSecret: clientSecret + )]) + case .claudeLocalStatus: + return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration( + accountName: account.displayName, + claudeBinary: account.commandPath ?? "claude", + statsPath: account.statsPath + )]) + } + } + } +} + diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index ba73c43..1aa715e 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -489,8 +489,13 @@ struct AccountDetail: View { DetailCard(title: "Setup") { DetailRow(label: "Store", value: ConnectorRedactor.redactedPath(model.store.rootDirectory.path)) - DetailRow(label: "Codex", value: ConnectorRedactor.redactedPath(model.codexAuthPath)) - DetailRow(label: "Claude", value: "local status") + DetailRow(label: "Accounts", value: "\(model.configuredAccounts.filter(\.isEnabled).count) enabled") + ForEach(model.configuredAccounts) { account in + DetailRow( + label: account.displayName, + value: account.isEnabled ? account.connectorKind.rawValue : "disabled" + ) + } } } .padding(22) @@ -643,13 +648,14 @@ final class ContextPanelAppModel: ObservableObject { @Published private(set) var storedSnapshot: StoredUsageSnapshot? @Published private(set) var storeStatus: UsageStatus = .unknown @Published private(set) var historyCount: Int = 0 + @Published private(set) var configuredAccounts: [LocalProviderAccountConfiguration] = [] @Published private(set) var isRefreshing = false @Published private(set) var errorMessage: String? @Published private(set) var lastRefreshAt: Date? let now = Date() let store: JSONSnapshotStore - let codexAuthPath: String + let accountStore: AccountConfigurationStore var currentSnapshot: UsageSnapshot { storedSnapshot?.snapshot ?? SampleUsageData.snapshot @@ -676,12 +682,13 @@ final class ContextPanelAppModel: ObservableObject { } init() { - let home = FileManager.default.homeDirectoryForCurrentUser.path - codexAuthPath = "\(home)/.codex/auth.json" store = JSONSnapshotStore(rootDirectory: Self.defaultStoreDirectory()) + accountStore = AccountConfigurationStore(configurationURL: Self.defaultConfigurationURL()) } func loadSnapshot() { + let accounts = accountStore.load().document.accounts + configuredAccounts = accounts let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 15 * 60), now: Date()) storedSnapshot = result.snapshot storeStatus = result.status @@ -693,10 +700,9 @@ final class ContextPanelAppModel: ObservableObject { isRefreshing = true defer { isRefreshing = false } - let connectors: [any ProviderConnector] = [ - CodexRateLimitConnector(accounts: [CodexAccountConfiguration(authPath: codexAuthPath)]), - ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()]), - ] + let accountDocument = accountStore.load().document + configuredAccounts = accountDocument.accounts + let connectors = AccountConnectorFactory.connectors(from: accountDocument) let refreshResult = await ProviderConnectorRuntime(connectors: connectors).refreshAll() let savedAt = Date() @@ -727,6 +733,14 @@ final class ContextPanelAppModel: ObservableObject { .appending(path: "Context Panel", directoryHint: .isDirectory) .appending(path: "Snapshots", directoryHint: .isDirectory) } + + private static func defaultConfigurationURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") + return base + .appending(path: "Context Panel", directoryHint: .isDirectory) + .appending(path: "accounts.json") + } } struct CapacityDial: View { diff --git a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift new file mode 100644 index 0000000..b097a12 --- /dev/null +++ b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func accountConfigurationStoreReturnsDefaultsWhenMissing() throws { + let store = AccountConfigurationStore(configurationURL: try temporaryDirectory().appending(path: "accounts.json")) + + let result = store.load(now: Date(timeIntervalSince1970: 0)) + + #expect(result.status == .unknown) + #expect(result.document.accounts.count == 3) + #expect(result.document.accounts.contains { $0.connectorKind == .codexRateLimits && $0.isEnabled }) + #expect(result.document.accounts.contains { $0.connectorKind == .geminiCodeAssist && !$0.isEnabled }) +} + +@Test func accountConfigurationStoreRoundTripsAccounts() throws { + let url = try temporaryDirectory().appending(path: "accounts.json") + let store = AccountConfigurationStore(configurationURL: url) + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 10), accounts: [ + LocalProviderAccountConfiguration( + id: "codex-a", + provider: .openAI, + connectorKind: .codexRateLimits, + displayName: "OpenAI A", + authPath: "/tmp/codex-a.json" + ) + ]) + + try store.save(document) + let result = store.load() + + #expect(result.status == .healthy) + #expect(result.document == document) +} + +@Test func accountConnectorFactorySkipsDisabledAndMissingSecretEnvironment() { + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [ + LocalProviderAccountConfiguration( + id: "codex", + provider: .openAI, + connectorKind: .codexRateLimits, + displayName: "OpenAI", + authPath: "/tmp/codex.json" + ), + LocalProviderAccountConfiguration( + id: "gemini", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + authPath: "/tmp/gemini.json", + oauthClientIDEnvironmentName: "GEMINI_ID", + oauthClientSecretEnvironmentName: "GEMINI_SECRET" + ), + LocalProviderAccountConfiguration( + id: "claude-disabled", + provider: .anthropic, + connectorKind: .claudeLocalStatus, + displayName: "Claude", + isEnabled: false + ), + ]) + + let withoutGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [:]) + let withGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [ + "GEMINI_ID": "client", + "GEMINI_SECRET": "secret", + ]) + + #expect(withoutGeminiEnvironment.count == 1) + #expect(withoutGeminiEnvironment[0].provider == .openAI) + #expect(withGeminiEnvironment.count == 2) + #expect(Set(withGeminiEnvironment.map(\.provider)) == [.openAI, .google]) +} + +@Test func accountConfigurationStoreReportsCorruptFilesAsFailure() throws { + let url = try temporaryDirectory().appending(path: "accounts.json") + try Data("nope".utf8).write(to: url) + + let result = AccountConfigurationStore(configurationURL: url).load(now: Date(timeIntervalSince1970: 0)) + + #expect(result.status == .failure) + #expect(result.document.accounts.count == 3) + #expect(result.errorMessage?.isEmpty == false) +} + +private func temporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: "context-panel-account-tests") + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url +} + diff --git a/docs/architecture.md b/docs/architecture.md index aedd25d..a249cb0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,14 @@ must not read provider credential files or make provider network calls. The app owns connector refreshes, account setup, diagnostics, and future migration from JSON to a richer store if history queries become more complex. +## Account Configuration + +The MVP account configuration is also local JSON. It stores account labels, +enabled/disabled state, connector kind, and local paths or command names needed +to locate provider CLI auth. It does not store provider secrets. Gemini OAuth +client inputs are referenced by environment variable names so the values can +remain outside the repository and outside the account config file. + Widget interactions should keep the widget simple. Tapping the widget should open the app to the relevant provider or account detail; mutation and setup stay inside the app. From 0e81b79f1f29f4aa58d45fe1f5f989a1afd5db48 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 13:18:17 -0400 Subject: [PATCH 21/34] Add WidgetKit snapshot glance --- Package.swift | 8 + .../ContextPanelLocations.swift | 28 ++ Sources/ContextPanelCore/WidgetSnapshot.swift | 140 ++++++ .../ContextPanelPreviewApp.swift | 26 +- .../ContextPanelWidget.swift | 105 +++++ .../ContextPanelWidgetViews.swift | 425 ++++++++++++++++++ .../WidgetSnapshotTests.swift | 54 +++ docs/architecture.md | 4 + 8 files changed, 767 insertions(+), 23 deletions(-) create mode 100644 Sources/ContextPanelCore/ContextPanelLocations.swift create mode 100644 Sources/ContextPanelCore/WidgetSnapshot.swift create mode 100644 Sources/ContextPanelWidget/ContextPanelWidget.swift create mode 100644 Sources/ContextPanelWidget/ContextPanelWidgetViews.swift create mode 100644 Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift diff --git a/Package.swift b/Package.swift index ea15abd..4682dd6 100644 --- a/Package.swift +++ b/Package.swift @@ -35,6 +35,10 @@ let package = Package( .executable( name: "SnapshotStoreProbe", targets: ["SnapshotStoreProbe"] + ), + .executable( + name: "ContextPanelWidget", + targets: ["ContextPanelWidget"] ) ], targets: [ @@ -63,6 +67,10 @@ let package = Package( name: "SnapshotStoreProbe", dependencies: ["ContextPanelCore"] ), + .executableTarget( + name: "ContextPanelWidget", + dependencies: ["ContextPanelCore"] + ), .testTarget( name: "ContextPanelCoreTests", dependencies: ["ContextPanelCore"] diff --git a/Sources/ContextPanelCore/ContextPanelLocations.swift b/Sources/ContextPanelCore/ContextPanelLocations.swift new file mode 100644 index 0000000..22ca282 --- /dev/null +++ b/Sources/ContextPanelCore/ContextPanelLocations.swift @@ -0,0 +1,28 @@ +import Foundation + +public enum ContextPanelLocations { + public static func applicationSupportDirectory() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") + return base.appending(path: "Context Panel", directoryHint: .isDirectory) + } + + public static func snapshotDirectory(appGroupID: String? = nil) -> URL { + if + let appGroupID, + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) + { + return containerURL + .appending(path: "Context Panel", directoryHint: .isDirectory) + .appending(path: "Snapshots", directoryHint: .isDirectory) + } + + return applicationSupportDirectory() + .appending(path: "Snapshots", directoryHint: .isDirectory) + } + + public static func accountConfigurationURL() -> URL { + applicationSupportDirectory().appending(path: "accounts.json") + } +} + diff --git a/Sources/ContextPanelCore/WidgetSnapshot.swift b/Sources/ContextPanelCore/WidgetSnapshot.swift new file mode 100644 index 0000000..23c3bd3 --- /dev/null +++ b/Sources/ContextPanelCore/WidgetSnapshot.swift @@ -0,0 +1,140 @@ +import Foundation + +public enum WidgetSnapshotState: String, Codable, Equatable, Sendable { + case ready + case setupNeeded + case stale + case failure +} + +public struct WidgetSnapshot: Codable, Equatable, Sendable { + public let state: WidgetSnapshotState + public let generatedAt: Date + public let limits: [UsageLimit] + public let reports: [StoredProviderReport] + public let status: UsageStatus + public let message: String + + public init( + state: WidgetSnapshotState, + generatedAt: Date, + limits: [UsageLimit], + reports: [StoredProviderReport] = [], + status: UsageStatus, + message: String + ) { + self.state = state + self.generatedAt = generatedAt + self.limits = limits + self.reports = reports + self.status = status + self.message = message + } + + public var usageSnapshot: UsageSnapshot { + UsageSnapshot(generatedAt: generatedAt, limits: limits) + } + + public var mostConstrainedLimits: [UsageLimit] { + usageSnapshot.mostConstrainedLimits + } + + public var aggregateCapacityRatio: Double { + usageSnapshot.aggregateCapacityRatio + } + + public var providerSummaries: [ProviderSummary] { + Provider.allCases.map { provider in + let providerLimits = limits.filter { $0.provider == provider } + return ProviderSummary( + provider: provider, + limitCount: providerLimits.count, + status: providerLimits.map(\.status).contextPanelWorstStatus, + capacityRatio: capacityRatio(for: providerLimits) + ) + } + } + + public static func fromStore( + _ result: SnapshotStoreLoadResult, + now: Date = Date() + ) -> WidgetSnapshot { + guard let stored = result.snapshot else { + return WidgetSnapshot( + state: result.status == .failure ? .failure : .setupNeeded, + generatedAt: now, + limits: [], + status: result.status, + message: result.errorMessage ?? "Set up Context Panel in the app." + ) + } + + let state: WidgetSnapshotState = switch result.status { + case .failure: + .failure + case .stale: + .stale + default: + .ready + } + + return WidgetSnapshot( + state: state, + generatedAt: stored.snapshot.generatedAt, + limits: stored.snapshot.limits, + reports: stored.reports, + status: result.status, + message: message(state: state, stored: stored) + ) + } + + private static func message(state: WidgetSnapshotState, stored: StoredUsageSnapshot) -> String { + switch state { + case .ready: + let limitedCount = stored.snapshot.limits.filter { $0.status == .limited }.count + if limitedCount > 0 { + return "\(limitedCount) limit needs attention." + } + return "You're good to keep working." + case .setupNeeded: + return "Set up Context Panel in the app." + case .stale: + return "Last snapshot is stale." + case .failure: + return "Refresh failed." + } + } + + private func capacityRatio(for limits: [UsageLimit]) -> Double { + let ratios = limits.compactMap(\.usageRatio) + guard !ratios.isEmpty else { return 0 } + return max(1 - (ratios.reduce(0, +) / Double(ratios.count)), 0) + } +} + +public struct ProviderSummary: Codable, Equatable, Sendable { + public let provider: Provider + public let limitCount: Int + public let status: UsageStatus + public let capacityRatio: Double + + public init(provider: Provider, limitCount: Int, status: UsageStatus, capacityRatio: Double) { + self.provider = provider + self.limitCount = limitCount + self.status = status + self.capacityRatio = capacityRatio + } +} + +extension Array where Element == UsageStatus { + public var contextPanelWorstStatus: UsageStatus { + if contains(.limited) { return .limited } + if contains(.failure) { return .failure } + if contains(.close) { return .close } + if contains(.stale) { return .stale } + if contains(.unknown) { return .unknown } + if contains(.loading) { return .loading } + return .healthy + } +} + diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 1aa715e..c828418 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -682,8 +682,8 @@ final class ContextPanelAppModel: ObservableObject { } init() { - store = JSONSnapshotStore(rootDirectory: Self.defaultStoreDirectory()) - accountStore = AccountConfigurationStore(configurationURL: Self.defaultConfigurationURL()) + store = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory()) + accountStore = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL()) } func loadSnapshot() { @@ -726,21 +726,6 @@ final class ContextPanelAppModel: ObservableObject { return "\(hours / 24)d ago" } - private static func defaultStoreDirectory() -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") - return base - .appending(path: "Context Panel", directoryHint: .isDirectory) - .appending(path: "Snapshots", directoryHint: .isDirectory) - } - - private static func defaultConfigurationURL() -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") - return base - .appending(path: "Context Panel", directoryHint: .isDirectory) - .appending(path: "accounts.json") - } } struct CapacityDial: View { @@ -1027,11 +1012,6 @@ extension UsageLimit { extension [UsageStatus] { var worstStatus: UsageStatus { - if contains(.limited) { return .limited } - if contains(.failure) { return .failure } - if contains(.close) { return .close } - if contains(.stale) { return .stale } - if contains(.unknown) { return .unknown } - return .healthy + contextPanelWorstStatus } } diff --git a/Sources/ContextPanelWidget/ContextPanelWidget.swift b/Sources/ContextPanelWidget/ContextPanelWidget.swift new file mode 100644 index 0000000..2bc6d51 --- /dev/null +++ b/Sources/ContextPanelWidget/ContextPanelWidget.swift @@ -0,0 +1,105 @@ +import ContextPanelCore +import SwiftUI +import WidgetKit + +struct ContextPanelWidgetEntry: TimelineEntry { + let date: Date + let snapshot: WidgetSnapshot +} + +struct ContextPanelTimelineProvider: TimelineProvider { + let store: JSONSnapshotStore + + init(store: JSONSnapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory())) { + self.store = store + } + + func placeholder(in context: Context) -> ContextPanelWidgetEntry { + ContextPanelWidgetEntry(date: Date(), snapshot: .placeholder) + } + + func getSnapshot(in context: Context, completion: @escaping (ContextPanelWidgetEntry) -> Void) { + completion(entry(date: Date())) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + let nextRefresh = Calendar.current.date(byAdding: .minute, value: 10, to: now) ?? now.addingTimeInterval(600) + completion(Timeline(entries: [entry(date: now)], policy: .after(nextRefresh))) + } + + private func entry(date: Date) -> ContextPanelWidgetEntry { + let result = store.loadCurrent(policy: SnapshotStoreStalenessPolicy(maximumAge: 20 * 60), now: date) + return ContextPanelWidgetEntry(date: date, snapshot: WidgetSnapshot.fromStore(result, now: date)) + } +} + +struct ContextPanelWidgetView: View { + @Environment(\.widgetFamily) private var family + let entry: ContextPanelWidgetEntry + + var body: some View { + switch family { + case .systemSmall: + ContextPanelSmallWidget(snapshot: entry.snapshot) + case .systemLarge, .systemExtraLarge: + ContextPanelLargeWidget(snapshot: entry.snapshot) + default: + ContextPanelMediumWidget(snapshot: entry.snapshot) + } + } +} + +@main +struct ContextPanelWidget: Widget { + let kind = "ContextPanelWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ContextPanelTimelineProvider()) { entry in + ContextPanelWidgetView(entry: entry) + .containerBackground(CPWTheme.surface, for: .widget) + } + .configurationDisplayName("Context Panel") + .description("AI account usage limits, reset timing, and fast-mode safety from local snapshots.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} + +extension WidgetSnapshot { + static var placeholder: WidgetSnapshot { + let now = Date() + return WidgetSnapshot( + state: .ready, + generatedAt: now, + limits: [ + UsageLimit( + provider: .openAI, + accountID: "placeholder-openai", + accountName: "OpenAI", + label: "Codex weekly", + unit: .percent, + used: 52, + limit: 100, + resetsAt: now.addingTimeInterval(18_000), + lastUpdatedAt: now.addingTimeInterval(-90), + confidence: .observed + ), + UsageLimit( + provider: .google, + accountID: "placeholder-google", + accountName: "Gemini", + label: "gemini-3-pro-preview", + unit: .percent, + used: 12, + limit: 100, + resetsAt: now.addingTimeInterval(86_400), + lastUpdatedAt: now.addingTimeInterval(-90), + confidence: .observed + ), + ], + status: .healthy, + message: "Fast mode looks safe." + ) + } +} + diff --git a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift new file mode 100644 index 0000000..13c492a --- /dev/null +++ b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift @@ -0,0 +1,425 @@ +import ContextPanelCore +import SwiftUI + +struct ContextPanelSmallWidget: View { + let snapshot: WidgetSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + CPWHeader(status: snapshot.status) + Spacer(minLength: 4) + if let tightest = snapshot.mostConstrainedLimits.first { + Text(tightest.widgetUsageText) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .minimumScaleFactor(0.75) + .lineLimit(2) + Text("\(tightest.label) · \(tightest.accountName)") + .font(.system(size: 11)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + } else { + Text("Set up accounts") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(2) + Text(snapshot.message) + .font(.system(size: 11)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + } + Spacer(minLength: 4) + CPWProviderMiniStatus(snapshot: snapshot) + } + .padding(16) + } +} + +struct ContextPanelMediumWidget: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + CPWHeader(status: snapshot.status) + Spacer(minLength: 0) + CPWCapacityDial( + value: snapshot.aggregateCapacityRatio, + status: snapshot.status, + label: "\(Int(snapshot.aggregateCapacityRatio * 100))", + sublabel: "capacity", + size: 86 + ) + Text(snapshot.message) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + Spacer(minLength: 0) + } + .frame(width: 142, alignment: .leading) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + CPWSectionHeader(title: "Most Constrained", trailing: "\(snapshot.limits.count) limits") + ForEach(snapshot.mostConstrainedLimits.prefix(4)) { limit in + CPWLimitRow(limit: limit) + } + if snapshot.limits.isEmpty { + CPWEmptyRow(message: snapshot.message) + } + } + } + .padding(16) + } +} + +struct ContextPanelLargeWidget: View { + let snapshot: WidgetSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + CPWLabel("Context Panel") + Text(snapshot.message) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(2) + Text(snapshot.state.rawValue) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .textCase(.uppercase) + } + Spacer() + CPWCapacityDial( + value: snapshot.aggregateCapacityRatio, + status: snapshot.status, + label: "\(Int(snapshot.aggregateCapacityRatio * 100))", + sublabel: "cap", + size: 82 + ) + } + + CPWProviderSummaryGrid(snapshot: snapshot) + + Divider() + + VStack(alignment: .leading, spacing: 8) { + CPWSectionHeader(title: "Account Limits", trailing: snapshot.generatedAt.widgetRelativeText) + ForEach(snapshot.mostConstrainedLimits.prefix(6)) { limit in + CPWLimitRow(limit: limit) + } + if snapshot.limits.isEmpty { + CPWEmptyRow(message: snapshot.message) + } + } + } + .padding(16) + } +} + +struct CPWHeader: View { + let status: UsageStatus + + var body: some View { + HStack { + CPWLabel("Context Panel") + Spacer() + CPWStatusMark(status: status, size: 9) + } + } +} + +struct CPWLimitRow: View { + let limit: UsageLimit + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + CPWProviderGlyph(provider: limit.provider, size: 10) + Text(limit.label) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(1) + Text("· \(limit.accountName)") + .font(.system(size: 11)) + .foregroundStyle(CPWTheme.tertiaryText) + .lineLimit(1) + Spacer(minLength: 6) + Text(limit.widgetUsageText) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(CPWTheme.secondaryText) + } + HStack(spacing: 8) { + CPWCapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + Text(limit.widgetResetText) + .font(.system(size: 10)) + .foregroundStyle(CPWTheme.tertiaryText) + .lineLimit(1) + } + } + } +} + +struct CPWProviderSummaryGrid: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 12) { + ForEach(snapshot.providerSummaries, id: \.provider) { summary in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + CPWProviderGlyph(provider: summary.provider, size: 10) + Text(summary.provider.shortName) + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + Spacer() + CPWStatusMark(status: summary.status, size: 7) + } + Text(summary.limitCount == 0 ? "setup" : "\(Int(summary.capacityRatio * 100))% room") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + CPWCapacityBar(value: 1 - summary.capacityRatio, status: summary.status) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } +} + +struct CPWProviderMiniStatus: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 12) { + ForEach(snapshot.providerSummaries, id: \.provider) { summary in + HStack(spacing: 5) { + CPWProviderGlyph(provider: summary.provider, size: 9) + Text(summary.provider.shortName) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + } + .foregroundStyle(CPWTheme.secondaryText) + .opacity(summary.limitCount == 0 ? 0.35 : 1) + } + } + } +} + +struct CPWEmptyRow: View { + let message: String + + var body: some View { + HStack(spacing: 8) { + CPWStatusMark(status: .unknown, size: 9) + Text(message) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(2) + } + } +} + +struct CPWCapacityDial: View { + let value: Double + let status: UsageStatus + let label: String + let sublabel: String + var size: CGFloat = 86 + + var body: some View { + ZStack { + Circle().stroke(CPWTheme.line, lineWidth: 6) + Circle() + .trim(from: 0, to: min(max(value, 0), 1)) + .stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + VStack(spacing: 0) { + Text(label) + .font(.system(size: 22, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPWTheme.primaryText) + Text(sublabel) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(CPWTheme.tertiaryText) + .textCase(.uppercase) + } + } + .frame(width: size, height: size) + } +} + +struct CPWCapacityBar: View { + let value: Double + let status: UsageStatus + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule().fill(CPWTheme.line) + Capsule() + .fill(CPWTheme.statusColor(status)) + .frame(width: proxy.size.width * min(max(value, 0), 1)) + } + } + .frame(height: 4) + } +} + +struct CPWProviderGlyph: View { + let provider: Provider + var size: CGFloat = 10 + + var body: some View { + Group { + switch provider { + case .openAI: + RoundedRectangle(cornerRadius: 2).stroke(CPWTheme.accent, lineWidth: 1.4) + case .anthropic: + CPWTriangle().stroke(CPWTheme.accent, lineWidth: 1.4) + case .google: + RoundedRectangle(cornerRadius: 1).rotation(.degrees(45)).stroke(CPWTheme.accent, lineWidth: 1.4) + } + } + .frame(width: size, height: size) + } +} + +struct CPWStatusMark: View { + let status: UsageStatus + var size: CGFloat = 8 + + var body: some View { + Group { + switch status { + case .healthy: + Circle().fill(CPWTheme.statusColor(status)) + case .close: + Circle().trim(from: 0, to: 0.75).stroke(CPWTheme.statusColor(status), lineWidth: 2) + case .limited: + RoundedRectangle(cornerRadius: 1).fill(CPWTheme.statusColor(status)) + case .stale: + Circle().stroke(CPWTheme.statusColor(status), style: StrokeStyle(lineWidth: 1.4, dash: [2, 2])) + case .unknown: + Text("?").font(.system(size: size + 3, weight: .semibold)).foregroundStyle(CPWTheme.statusColor(status)) + case .failure: + Image(systemName: "xmark").font(.system(size: size, weight: .bold)).foregroundStyle(CPWTheme.statusColor(status)) + case .loading: + Circle().stroke(CPWTheme.statusColor(status), lineWidth: 1.4) + } + } + .frame(width: size, height: size) + } +} + +struct CPWSectionHeader: View { + let title: String + var trailing: String? = nil + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .textCase(.uppercase) + .foregroundStyle(CPWTheme.tertiaryText) + Spacer() + if let trailing { + Text(trailing) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPWTheme.tertiaryText) + } + } + } +} + +struct CPWLabel: View { + let text: String + + init(_ text: String) { + self.text = text + } + + var body: some View { + HStack(spacing: 6) { + RoundedRectangle(cornerRadius: 1) + .fill(CPWTheme.accent) + .rotationEffect(.degrees(45)) + .frame(width: 6, height: 6) + Text(text) + .font(.system(size: 10, weight: .semibold)) + .tracking(0.8) + .textCase(.uppercase) + .foregroundStyle(CPWTheme.tertiaryText) + } + } +} + +struct CPWTriangle: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.closeSubpath() + return path + } +} + +enum CPWTheme { + static let surface = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255) + static let line = Color.black.opacity(0.08) + static let primaryText = Color(red: 10 / 255, green: 10 / 255, blue: 11 / 255) + static let secondaryText = primaryText.opacity(0.66) + static let tertiaryText = primaryText.opacity(0.46) + static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255) + + static func statusColor(_ status: UsageStatus) -> Color { + switch status { + case .healthy: + Color(red: 74 / 255, green: 122 / 255, blue: 91 / 255) + case .close: + Color(red: 138 / 255, green: 106 / 255, blue: 42 / 255) + case .limited, .failure: + Color(red: 138 / 255, green: 74 / 255, blue: 74 / 255) + case .stale, .unknown, .loading: + Color(red: 106 / 255, green: 106 / 255, blue: 114 / 255) + } + } +} + +extension UsageLimit { + var widgetUsageText: String { + if let remaining, let limit { + return "\(remaining)/\(limit) left" + } + if status == .failure { return "refresh failed" } + return "unknown" + } + + var widgetResetText: String { + guard let resetsAt else { + return status == .failure ? "refresh failed" : "unknown reset" + } + return "resets \(resetsAt.widgetRelativeText)" + } +} + +extension Date { + var widgetRelativeText: String { + let seconds = Int(timeIntervalSince(Date())) + if abs(seconds) < 60 { return "now" } + if seconds >= 0 { + let minutes = seconds / 60 + if minutes < 60 { return "in \(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "in \(hours)h" } + return "in \(hours / 24)d" + } + let elapsed = abs(seconds) + let minutes = elapsed / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" + } +} + diff --git a/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift new file mode 100644 index 0000000..29fa2f5 --- /dev/null +++ b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func widgetSnapshotUsesSetupNeededForMissingStore() { + let widget = WidgetSnapshot.fromStore( + SnapshotStoreLoadResult(snapshot: nil, status: .unknown), + now: Date(timeIntervalSince1970: 0) + ) + + #expect(widget.state == .setupNeeded) + #expect(widget.status == .unknown) + #expect(widget.limits.isEmpty) + #expect(widget.message.contains("Set up")) +} + +@Test func widgetSnapshotPreservesStaleCachedLimits() { + let savedAt = Date(timeIntervalSince1970: 100) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [UsageLimit(provider: .openAI, label: "Codex", used: 20, limit: 100)] + )) + + let widget = WidgetSnapshot.fromStore( + SnapshotStoreLoadResult(snapshot: stored, status: .stale), + now: Date(timeIntervalSince1970: 1_000) + ) + + #expect(widget.state == .stale) + #expect(widget.limits.count == 1) + #expect(widget.message == "Last snapshot is stale.") +} + +@Test func widgetSnapshotBuildsProviderSummaries() { + let savedAt = Date(timeIntervalSince1970: 100) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [ + UsageLimit(provider: .openAI, label: "Codex", used: 85, limit: 100), + UsageLimit(provider: .google, label: "Gemini", used: 10, limit: 100), + ] + )) + + let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy)) + let summaries = Dictionary(uniqueKeysWithValues: widget.providerSummaries.map { ($0.provider, $0) }) + + #expect(widget.state == .ready) + #expect(summaries[.openAI]?.status == .close) + #expect(summaries[.openAI]?.limitCount == 1) + #expect(summaries[.google]?.status == .healthy) + #expect(summaries[.anthropic]?.limitCount == 0) +} + diff --git a/docs/architecture.md b/docs/architecture.md index a249cb0..4da0aa9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -54,6 +54,10 @@ must not read provider credential files or make provider network calls. The app owns connector refreshes, account setup, diagnostics, and future migration from JSON to a richer store if history queries become more complex. +The WidgetKit implementation uses a `WidgetSnapshot` projection from the stored +snapshot. That projection owns setup-needed, stale, failure, provider-summary, +and most-constrained row selection so the widget view stays read-only and small. + ## Account Configuration The MVP account configuration is also local JSON. It stores account labels, From 3e216f5902fba1a1ef93f98477c5ff0ebb1683f2 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 14:23:28 -0400 Subject: [PATCH 22/34] Refine widget rate-limit decision UX --- .../ContextPanelCore/ClaudeLocalStatus.swift | 1 + .../ContextPanelCore/CodexRateLimits.swift | 39 +++- .../GeminiCodeAssistQuota.swift | 15 ++ Sources/ContextPanelCore/UsageLimit.swift | 59 +++++- Sources/ContextPanelCore/WidgetSnapshot.swift | 3 +- .../ContextPanelPreviewApp.swift | 183 ++++++++++-------- .../ContextPanelPreview/SampleUsageData.swift | 22 ++- .../ContextPanelWidget.swift | 7 +- .../ContextPanelWidgetViews.swift | 178 ++++++++++++----- .../CodexRateLimitsTests.swift | 10 +- .../ProviderConnectorTests.swift | 3 +- .../UsageLimitTests.swift | 30 ++- .../WidgetSnapshotTests.swift | 15 ++ docs/design-direction.md | 11 +- docs/provider-usage-access.md | 6 +- 15 files changed, 429 insertions(+), 153 deletions(-) diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift index f35687e..6b6f6fe 100644 --- a/Sources/ContextPanelCore/ClaudeLocalStatus.swift +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -187,6 +187,7 @@ public func claudeLocalStatusLimits( accountID: accountID, accountName: accountName, label: "Claude subscription allowance", + modelLabel: "Claude subscription allowance", unit: .unknown, used: nil, limit: nil, diff --git a/Sources/ContextPanelCore/CodexRateLimits.swift b/Sources/ContextPanelCore/CodexRateLimits.swift index 07139b7..5c7b06e 100644 --- a/Sources/ContextPanelCore/CodexRateLimits.swift +++ b/Sources/ContextPanelCore/CodexRateLimits.swift @@ -61,7 +61,7 @@ public struct CodexRateLimitSnapshot: Codable, Equatable, Identifiable, Sendable } public var displayName: String { - limitName ?? id + limitName ?? (id == "codex" ? "Codex" : id) } } @@ -219,7 +219,6 @@ public func codexUsageLimits( limits.append(codexUsageLimit( snapshot: snapshot, window: primary, - windowName: "primary", accountID: accountID, accountName: accountName, observedAt: observedAt @@ -229,7 +228,6 @@ public func codexUsageLimits( limits.append(codexUsageLimit( snapshot: snapshot, window: secondary, - windowName: "secondary", accountID: accountID, accountName: accountName, observedAt: observedAt @@ -387,17 +385,18 @@ private struct CodexReachedType: Decodable { private func codexUsageLimit( snapshot: CodexRateLimitSnapshot, window: CodexRateLimitWindow, - windowName: String, accountID: String, accountName: String, observedAt: Date ) -> UsageLimit { - let duration = window.windowMinutes.map { "\($0)m" } ?? "rolling" + let windowLabel = window.windowMinutes.map(codexWindowLabel(minutes:)) ?? "Rolling" return UsageLimit( provider: .openAI, accountID: accountID, accountName: accountName, - label: "\(snapshot.displayName) \(windowName) \(duration)", + label: "\(snapshot.displayName) \(windowLabel)", + windowLabel: windowLabel, + modelLabel: snapshot.displayName, unit: .percent, used: Int(window.usedPercent.rounded()), limit: 100, @@ -407,3 +406,31 @@ private func codexUsageLimit( note: "plan: \(snapshot.planType)" ) } + +private func codexWindowLabel(minutes: Int) -> String { + switch minutes { + case 0..<60: + return "\(minutes)m" + case 60: + return "Hourly" + case 300: + return "5-hour" + case 1_440: + return "Daily" + case 7_200: + return "5-day" + case 10_080: + return "Weekly" + default: + if minutes.isMultiple(of: 10_080) { + return "\(minutes / 10_080)-week" + } + if minutes.isMultiple(of: 1_440) { + return "\(minutes / 1_440)-day" + } + if minutes.isMultiple(of: 60) { + return "\(minutes / 60)-hour" + } + return "\(minutes)m" + } +} diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift index f1e7fb3..0922e2f 100644 --- a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -25,6 +25,8 @@ public struct GeminiQuotaBucket: Codable, Equatable, Identifiable, Sendable { accountID: accountID, accountName: accountName, label: modelID, + windowLabel: resetWindowLabel, + modelLabel: modelID, unit: .percent, used: usedPercent.map { Int($0.rounded()) }, limit: usedPercent == nil ? nil : 100, @@ -34,6 +36,19 @@ public struct GeminiQuotaBucket: Codable, Equatable, Identifiable, Sendable { note: remainingAmount.map { "remaining amount: \($0)" } ) } + + private var resetWindowLabel: String? { + guard let resetsAt else { return nil } + let seconds = Int(resetsAt.timeIntervalSince(Date())) + if seconds <= 0 { return nil } + let hours = max(Int((Double(seconds) / 3_600).rounded()), 1) + if hours <= 2 { return "Hourly" } + if hours <= 7 { return "5-hour" } + if hours <= 30 { return "Daily" } + if hours <= 132 { return "5-day" } + if hours <= 180 { return "Weekly" } + return nil + } } public enum GeminiQuotaPayloadParser { diff --git a/Sources/ContextPanelCore/UsageLimit.swift b/Sources/ContextPanelCore/UsageLimit.swift index d344745..21cc432 100644 --- a/Sources/ContextPanelCore/UsageLimit.swift +++ b/Sources/ContextPanelCore/UsageLimit.swift @@ -77,6 +77,8 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { public let accountID: String public let accountName: String public let label: String + public let windowLabel: String? + public let modelLabel: String? public let unit: UsageUnit public let used: Int? public let limit: Int? @@ -86,12 +88,32 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { public let statusOverride: UsageStatus? public let note: String? + enum CodingKeys: String, CodingKey { + case id + case provider + case accountID + case accountName + case label + case windowLabel + case modelLabel + case unit + case used + case limit + case resetsAt + case lastUpdatedAt + case confidence + case statusOverride + case note + } + public init( id: String? = nil, provider: Provider, accountID: String, accountName: String, label: String, + windowLabel: String? = nil, + modelLabel: String? = nil, unit: UsageUnit = .units, used: Int?, limit: Int?, @@ -113,6 +135,8 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { self.accountID = accountID self.accountName = accountName self.label = label + self.windowLabel = windowLabel + self.modelLabel = modelLabel self.unit = unit self.used = used self.limit = limit @@ -123,6 +147,25 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { self.note = note } + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + provider = try container.decode(Provider.self, forKey: .provider) + accountID = try container.decode(String.self, forKey: .accountID) + accountName = try container.decode(String.self, forKey: .accountName) + label = try container.decode(String.self, forKey: .label) + windowLabel = try container.decodeIfPresent(String.self, forKey: .windowLabel) + modelLabel = try container.decodeIfPresent(String.self, forKey: .modelLabel) + unit = try container.decode(UsageUnit.self, forKey: .unit) + used = try container.decodeIfPresent(Int.self, forKey: .used) + limit = try container.decodeIfPresent(Int.self, forKey: .limit) + resetsAt = try container.decodeIfPresent(Date.self, forKey: .resetsAt) + lastUpdatedAt = try container.decodeIfPresent(Date.self, forKey: .lastUpdatedAt) + confidence = try container.decode(UsageConfidence.self, forKey: .confidence) + statusOverride = try container.decodeIfPresent(UsageStatus.self, forKey: .statusOverride) + note = try container.decodeIfPresent(String.self, forKey: .note) + } + public init(provider: Provider, label: String, used: Int, limit: Int, resetsAt: Date? = nil) { self.init( provider: provider, @@ -147,6 +190,19 @@ public struct UsageLimit: Codable, Equatable, Identifiable, Sendable { return min(Double(used) / Double(limit), 1) } + public var displayLabel: String { + windowLabel ?? label + } + + public var contextLabel: String { + [modelLabel, accountName] + .compactMap { value in + guard let value, !value.isEmpty else { return nil } + return value + } + .joined(separator: " · ") + } + public var status: UsageStatus { if let statusOverride { return statusOverride @@ -187,8 +243,7 @@ public struct UsageSnapshot: Codable, Equatable, Sendable { public var aggregateCapacityRatio: Double { let ratios = limits.compactMap(\.usageRatio) guard !ratios.isEmpty else { return 0 } - let averageUsed = ratios.reduce(0, +) / Double(ratios.count) - return max(1 - averageUsed, 0) + return max(1 - (ratios.max() ?? 0), 0) } public var aggregateStatus: UsageStatus { diff --git a/Sources/ContextPanelCore/WidgetSnapshot.swift b/Sources/ContextPanelCore/WidgetSnapshot.swift index 23c3bd3..5f0cd8d 100644 --- a/Sources/ContextPanelCore/WidgetSnapshot.swift +++ b/Sources/ContextPanelCore/WidgetSnapshot.swift @@ -108,7 +108,7 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { private func capacityRatio(for limits: [UsageLimit]) -> Double { let ratios = limits.compactMap(\.usageRatio) guard !ratios.isEmpty else { return 0 } - return max(1 - (ratios.reduce(0, +) / Double(ratios.count)), 0) + return max(1 - (ratios.max() ?? 0), 0) } } @@ -137,4 +137,3 @@ extension Array where Element == UsageStatus { return .healthy } } - diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index c828418..449705a 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -95,7 +95,7 @@ struct ProviderSidebarRow: View { var body: some View { HStack(spacing: 8) { - ProviderGlyph(provider: provider, size: 12) + ProviderBadge(provider: provider) Text(provider.displayName) .font(.system(size: 12, weight: .semibold)) .textCase(.uppercase) @@ -117,7 +117,7 @@ struct SidebarLimitRow: View { VStack(alignment: .leading, spacing: 2) { Text(limit.accountName) .font(.system(size: 13, weight: .medium)) - Text(limit.label) + Text(limit.displayLabel) .font(.system(size: 11)) .foregroundStyle(.secondary) } @@ -187,10 +187,10 @@ struct HeaderCard: View { } Spacer(minLength: 16) CapacityDial( - value: snapshot.aggregateCapacityRatio, + value: snapshot.tightestCapacityRatio, status: snapshot.aggregateStatus, - label: "\(Int(snapshot.aggregateCapacityRatio * 100))", - sublabel: "capacity", + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", size: 116 ) } @@ -282,7 +282,7 @@ struct SmallWidgetPreview: View { VStack(alignment: .leading, spacing: 10) { WidgetHeader(status: snapshot.aggregateStatus) Spacer() - Text(snapshot.tightestUsageText) + Text(snapshot.fastModeForecast.copy) .font(.system(size: 26, weight: .semibold)) .foregroundStyle(CPTheme.primaryText) .lineLimit(2) @@ -308,21 +308,21 @@ struct MediumWidgetPreview: View { WidgetHeader(status: snapshot.aggregateStatus) Spacer() CapacityDial( - value: snapshot.aggregateCapacityRatio, + value: snapshot.tightestCapacityRatio, status: snapshot.aggregateStatus, - label: "\(Int(snapshot.aggregateCapacityRatio * 100))", - sublabel: "capacity", + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", size: 94 ) VStack(alignment: .leading, spacing: 2) { - Text("Working room") + Text(snapshot.fastModeForecast.copy) .font(.system(size: 18, weight: .semibold)) - Text("1 limited · 2 close") + Text(snapshot.providerPressureText) .font(.system(size: 11)) .foregroundStyle(CPTheme.tertiaryText) } Spacer() - Text("nearest reset · 42m") + Text(snapshot.nearestResetText) .font(.system(size: 10, weight: .medium)) .foregroundStyle(CPTheme.tertiaryText) } @@ -350,19 +350,19 @@ struct LargeWidgetPreview: View { HStack(alignment: .top) { VStack(alignment: .leading, spacing: 6) { CPLabel("Context Panel") - Text("You're good for the afternoon.") + Text(snapshot.fastModeForecast.copy) .font(.system(size: 25, weight: .semibold)) .foregroundStyle(CPTheme.primaryText) - Text("Image gen on Team is the only blocker.") + Text(snapshot.tightestSupportText) .font(.system(size: 12)) .foregroundStyle(CPTheme.secondaryText) } Spacer() CapacityDial( - value: snapshot.aggregateCapacityRatio, + value: snapshot.tightestCapacityRatio, status: snapshot.aggregateStatus, - label: "\(Int(snapshot.aggregateCapacityRatio * 100))", - sublabel: "cap", + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", size: 84 ) } @@ -374,11 +374,11 @@ struct LargeWidgetPreview: View { HStack { Sparkline(values: [0.72, 0.68, 0.7, 0.64, 0.62, 0.58, 0.64]) .frame(width: 120, height: 20) - Text("24h capacity") + Text("pressure trend") .font(.system(size: 10)) .foregroundStyle(CPTheme.tertiaryText) Spacer() - Text("next reset in 42m · upd 2m ago") + Text(snapshot.nearestResetText) .font(.system(size: 10)) .foregroundStyle(CPTheme.tertiaryText) } @@ -398,7 +398,7 @@ struct ProviderGroupGrid: View { if !limits.isEmpty { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { - ProviderGlyph(provider: provider, size: 11) + ProviderBadge(provider: provider, compact: true) Text(provider.displayName) .font(.system(size: 11, weight: .semibold)) .textCase(.uppercase) @@ -418,7 +418,7 @@ struct ProviderGroupGrid: View { .font(.system(size: 10, weight: .medium, design: .monospaced)) .foregroundStyle(CPTheme.secondaryText) } - Text(limit.label) + Text(limit.displayLabel) .font(.system(size: 10)) .foregroundStyle(CPTheme.tertiaryText) .lineLimit(1) @@ -442,11 +442,11 @@ struct AccountDetail: View { ScrollView { VStack(alignment: .leading, spacing: 18) { HStack(spacing: 10) { - ProviderGlyph(provider: limit.provider, size: 16) + ProviderBadge(provider: limit.provider) VStack(alignment: .leading, spacing: 2) { Text(limit.accountName) .font(.system(size: 22, weight: .semibold)) - Text("\(limit.provider.displayName) · \(limit.label)") + Text("\(limit.provider.displayName) · \(limit.displayLabel) · \(limit.contextLabel)") .font(.system(size: 13)) .foregroundStyle(CPTheme.secondaryText) } @@ -505,7 +505,7 @@ struct AccountDetail: View { } private var forecastCopy: String { - if limit.provider == .openAI, limit.label.contains("GPT-5") { + if limit.provider == .openAI, limit.unit == .percent { return FastModeForecast( input: FastModeForecastInput( limit: limit, @@ -590,10 +590,7 @@ struct ProviderMiniStatus: View { ForEach(Provider.allCases) { provider in let limits = snapshot.limits.filter { $0.provider == provider } HStack(spacing: 5) { - ProviderGlyph(provider: provider, size: 10) - Text(provider.shortName) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(CPTheme.secondaryText) + ProviderBadge(provider: provider, compact: true) } .opacity(limits.isEmpty ? 0.35 : 1) } @@ -607,14 +604,14 @@ struct AccountRow: View { var body: some View { HStack(spacing: 10) { - ProviderGlyph(provider: limit.provider, size: compact ? 11 : 13) + ProviderBadge(provider: limit.provider, compact: true) .frame(width: 16) VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline) { - Text(limit.label) + Text(limit.displayLabel) .font(.system(size: compact ? 12 : 13, weight: .medium)) .lineLimit(1) - Text("· \(limit.accountName)") + Text("· \(limit.contextLabel)") .font(.system(size: compact ? 12 : 13)) .foregroundStyle(CPTheme.tertiaryText) .lineLimit(1) @@ -780,26 +777,15 @@ struct CapacityBar: View { } } -struct ProviderGlyph: View { +struct ProviderBadge: View { let provider: Provider - var size: CGFloat = 12 + var compact = false var body: some View { - Group { - switch provider { - case .openAI: - RoundedRectangle(cornerRadius: 2, style: .continuous) - .stroke(CPTheme.accent, lineWidth: 1.4) - case .anthropic: - Triangle() - .stroke(CPTheme.accent, lineWidth: 1.4) - case .google: - RoundedRectangle(cornerRadius: 1, style: .continuous) - .rotation(.degrees(45)) - .stroke(CPTheme.accent, lineWidth: 1.4) - } - } - .frame(width: size, height: size) + Text(provider.shortName) + .font(.system(size: compact ? 10 : 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPTheme.providerColor(provider)) + .lineLimit(1) } } @@ -912,17 +898,6 @@ struct TagLabel: View { } } -struct Triangle: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - path.move(to: CGPoint(x: rect.midX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - path.closeSubpath() - return path - } -} - enum CPTheme { static let background = Color(red: 244 / 255, green: 244 / 255, blue: 245 / 255) static let surface = Color.white @@ -933,6 +908,17 @@ enum CPTheme { static let tertiaryText = primaryText.opacity(0.46) static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255) + static func providerColor(_ provider: Provider) -> Color { + switch provider { + case .openAI: + Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255) + case .anthropic: + Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255) + case .google: + Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255) + } + } + static func statusColor(_ status: UsageStatus) -> Color { switch status { case .healthy: @@ -972,7 +958,43 @@ extension UsageSnapshot { var tightestSupportText: String { guard let tightestLimit else { return "Add OpenAI, Anthropic, or Google." } - return "\(tightestLimit.label) · \(tightestLimit.accountName) — your tightest account" + return "\(tightestLimit.provider.shortName) · \(tightestLimit.displayLabel) · \(tightestLimit.contextLabel)" + } + + var tightestCapacityRatio: Double { + guard let ratio = tightestLimit?.usageRatio else { return 0 } + return max(1 - ratio, 0) + } + + var fastModeForecast: FastModePortfolioForecast { + let forecasts = limits + .filter { $0.provider == .openAI && $0.unit == .percent } + .map { limit in + FastModeForecast(input: FastModeForecastInput( + limit: limit, + now: Date(), + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + )) + } + return FastModePortfolioForecast(forecasts: forecasts) + } + + var providerPressureText: String { + let limited = limits.filter { $0.status == .limited }.count + let close = limits.filter { $0.status == .close }.count + if limited > 0 || close > 0 { + return "\(limited) limited · \(close) close" + } + return "all tracked windows healthy" + } + + var nearestResetText: String { + let futureResets = limits.compactMap(\.resetsAt).filter { $0 > Date() }.sorted() + guard let reset = futureResets.first else { return "reset unknown" } + return "nearest reset \(reset.widgetRelativeText)" } } @@ -988,25 +1010,30 @@ extension UsageLimit { } var resetText: String { - switch status { - case .failure: - "refresh failed" - case .unknown: - "unknown" - default: - switch label { - case "Image generation": - "42m" - case "Claude Opus": - "1h 15m" - case "GPT-5": - "3h 20m" - case "GPT-5 Thinking": - "tomorrow 9:00" - default: - "tonight" - } + if status == .failure { return "refresh failed" } + guard let resetsAt else { return "unknown reset" } + if resetsAt < Date().addingTimeInterval(-60) { return "reset passed" } + return "resets \(resetsAt.widgetRelativeText)" + } +} + +extension Date { + var widgetRelativeText: String { + let seconds = Int(timeIntervalSince(Date())) + if abs(seconds) < 60 { return "now" } + if seconds >= 0 { + let minutes = seconds / 60 + if minutes < 60 { return "in \(minutes)m" } + let hours = minutes / 60 + if hours < 24 { return "in \(hours)h" } + return "in \(hours / 24)d" } + let elapsed = abs(seconds) + let minutes = elapsed / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + return "\(hours / 24)d ago" } } diff --git a/Sources/ContextPanelPreview/SampleUsageData.swift b/Sources/ContextPanelPreview/SampleUsageData.swift index 6bcc23a..42fd6ac 100644 --- a/Sources/ContextPanelPreview/SampleUsageData.swift +++ b/Sources/ContextPanelPreview/SampleUsageData.swift @@ -12,7 +12,9 @@ enum SampleUsageData { provider: .openAI, accountID: "openai-personal", accountName: "Personal", - label: "GPT-5", + label: "GPT-5 5-hour", + windowLabel: "5-hour", + modelLabel: "GPT-5", unit: .percent, used: 72, limit: 100, @@ -24,7 +26,9 @@ enum SampleUsageData { provider: .openAI, accountID: "openai-work", accountName: "Work", - label: "GPT-5 Thinking", + label: "GPT-5 Thinking Weekly", + windowLabel: "Weekly", + modelLabel: "GPT-5 Thinking", unit: .percent, used: 18, limit: 100, @@ -37,7 +41,9 @@ enum SampleUsageData { provider: .openAI, accountID: "openai-team", accountName: "Team", - label: "Image generation", + label: "Image generation Hourly", + windowLabel: "Hourly", + modelLabel: "Image generation", unit: .percent, used: 49, limit: 100, @@ -49,7 +55,9 @@ enum SampleUsageData { provider: .anthropic, accountID: "anthropic-personal", accountName: "Personal", - label: "Claude Opus", + label: "Claude Opus 5-hour", + windowLabel: "5-hour", + modelLabel: "Claude Opus", used: 38, limit: 45, resetsAt: referenceNow.addingTimeInterval(4_500), @@ -60,7 +68,9 @@ enum SampleUsageData { provider: .anthropic, accountID: "anthropic-work", accountName: "Work", - label: "Claude Sonnet", + label: "Claude Sonnet Daily", + windowLabel: "Daily", + modelLabel: "Claude Sonnet", used: 12, limit: 100, resetsAt: referenceNow.addingTimeInterval(21_600), @@ -72,6 +82,7 @@ enum SampleUsageData { accountID: "google-personal", accountName: "Personal", label: "Gemini Pro", + modelLabel: "Gemini Pro", used: nil, limit: nil, lastUpdatedAt: referenceNow.addingTimeInterval(-120), @@ -84,6 +95,7 @@ enum SampleUsageData { accountID: "google-work", accountName: "Work", label: "Gemini Deep Research", + modelLabel: "Gemini Deep Research", used: nil, limit: nil, lastUpdatedAt: referenceNow.addingTimeInterval(-21_600), diff --git a/Sources/ContextPanelWidget/ContextPanelWidget.swift b/Sources/ContextPanelWidget/ContextPanelWidget.swift index 2bc6d51..4705b54 100644 --- a/Sources/ContextPanelWidget/ContextPanelWidget.swift +++ b/Sources/ContextPanelWidget/ContextPanelWidget.swift @@ -76,7 +76,9 @@ extension WidgetSnapshot { provider: .openAI, accountID: "placeholder-openai", accountName: "OpenAI", - label: "Codex weekly", + label: "Codex Weekly", + windowLabel: "Weekly", + modelLabel: "Codex", unit: .percent, used: 52, limit: 100, @@ -89,6 +91,8 @@ extension WidgetSnapshot { accountID: "placeholder-google", accountName: "Gemini", label: "gemini-3-pro-preview", + windowLabel: "Daily", + modelLabel: "gemini-3-pro-preview", unit: .percent, used: 12, limit: 100, @@ -102,4 +106,3 @@ extension WidgetSnapshot { ) } } - diff --git a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift index 13c492a..218e918 100644 --- a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift +++ b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift @@ -9,15 +9,19 @@ struct ContextPanelSmallWidget: View { CPWHeader(status: snapshot.status) Spacer(minLength: 4) if let tightest = snapshot.mostConstrainedLimits.first { - Text(tightest.widgetUsageText) + Text(snapshot.fastModeVerdict) .font(.system(size: 26, weight: .semibold)) .foregroundStyle(CPWTheme.primaryText) .minimumScaleFactor(0.75) .lineLimit(2) - Text("\(tightest.label) · \(tightest.accountName)") + Text("\(tightest.provider.shortName) · \(tightest.displayLabel) · \(tightest.widgetUsageText)") .font(.system(size: 11)) .foregroundStyle(CPWTheme.secondaryText) .lineLimit(2) + Text(tightest.widgetResetText) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPWTheme.tertiaryText) + .lineLimit(1) } else { Text("Set up accounts") .font(.system(size: 22, weight: .semibold)) @@ -44,13 +48,13 @@ struct ContextPanelMediumWidget: View { CPWHeader(status: snapshot.status) Spacer(minLength: 0) CPWCapacityDial( - value: snapshot.aggregateCapacityRatio, + value: snapshot.tightestCapacityRatio, status: snapshot.status, - label: "\(Int(snapshot.aggregateCapacityRatio * 100))", - sublabel: "capacity", + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", size: 86 ) - Text(snapshot.message) + Text(snapshot.fastModeVerdict) .font(.system(size: 12, weight: .medium)) .foregroundStyle(CPWTheme.secondaryText) .lineLimit(2) @@ -82,7 +86,7 @@ struct ContextPanelLargeWidget: View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { CPWLabel("Context Panel") - Text(snapshot.message) + Text(snapshot.fastModeVerdict) .font(.system(size: 24, weight: .semibold)) .foregroundStyle(CPWTheme.primaryText) .lineLimit(2) @@ -93,10 +97,10 @@ struct ContextPanelLargeWidget: View { } Spacer() CPWCapacityDial( - value: snapshot.aggregateCapacityRatio, + value: snapshot.tightestCapacityRatio, status: snapshot.status, - label: "\(Int(snapshot.aggregateCapacityRatio * 100))", - sublabel: "cap", + label: "\(Int(snapshot.tightestCapacityRatio * 100))", + sublabel: "tightest", size: 82 ) } @@ -106,6 +110,8 @@ struct ContextPanelLargeWidget: View { Divider() VStack(alignment: .leading, spacing: 8) { + CPWFastModeCard(snapshot: snapshot) + CPWSectionHeader(title: "Account Limits", trailing: snapshot.generatedAt.widgetRelativeText) ForEach(snapshot.mostConstrainedLimits.prefix(6)) { limit in CPWLimitRow(limit: limit) @@ -137,12 +143,12 @@ struct CPWLimitRow: View { var body: some View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 6) { - CPWProviderGlyph(provider: limit.provider, size: 10) - Text(limit.label) + CPWProviderBadge(provider: limit.provider, compact: true) + Text(limit.displayLabel) .font(.system(size: 12, weight: .medium)) .foregroundStyle(CPWTheme.primaryText) .lineLimit(1) - Text("· \(limit.accountName)") + Text("· \(limit.contextLabel)") .font(.system(size: 11)) .foregroundStyle(CPWTheme.tertiaryText) .lineLimit(1) @@ -170,13 +176,11 @@ struct CPWProviderSummaryGrid: View { ForEach(snapshot.providerSummaries, id: \.provider) { summary in VStack(alignment: .leading, spacing: 6) { HStack(spacing: 6) { - CPWProviderGlyph(provider: summary.provider, size: 10) - Text(summary.provider.shortName) - .font(.system(size: 10, weight: .semibold, design: .monospaced)) + CPWProviderBadge(provider: summary.provider, compact: true) Spacer() CPWStatusMark(status: summary.status, size: 7) } - Text(summary.limitCount == 0 ? "setup" : "\(Int(summary.capacityRatio * 100))% room") + Text(summary.limitCount == 0 ? "setup" : "\(Int(summary.capacityRatio * 100))% tightest room") .font(.system(size: 11, weight: .medium)) .foregroundStyle(CPWTheme.secondaryText) CPWCapacityBar(value: 1 - summary.capacityRatio, status: summary.status) @@ -194,9 +198,7 @@ struct CPWProviderMiniStatus: View { HStack(spacing: 12) { ForEach(snapshot.providerSummaries, id: \.provider) { summary in HStack(spacing: 5) { - CPWProviderGlyph(provider: summary.provider, size: 9) - Text(summary.provider.shortName) - .font(.system(size: 10, weight: .medium, design: .monospaced)) + CPWProviderBadge(provider: summary.provider, compact: true) } .foregroundStyle(CPWTheme.secondaryText) .opacity(summary.limitCount == 0 ? 0.35 : 1) @@ -219,6 +221,34 @@ struct CPWEmptyRow: View { } } +struct CPWFastModeCard: View { + let snapshot: WidgetSnapshot + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "bolt.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(CPWTheme.fastModeColor(snapshot.fastModeStatus)) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(snapshot.fastModeVerdict) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(CPWTheme.primaryText) + .lineLimit(1) + Text(snapshot.fastModeDetail) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(CPWTheme.secondaryText) + .lineLimit(1) + } + Spacer(minLength: 4) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(CPWTheme.line.opacity(0.65)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + struct CPWCapacityDial: View { let value: Double let status: UsageStatus @@ -264,22 +294,15 @@ struct CPWCapacityBar: View { } } -struct CPWProviderGlyph: View { +struct CPWProviderBadge: View { let provider: Provider - var size: CGFloat = 10 + var compact = false var body: some View { - Group { - switch provider { - case .openAI: - RoundedRectangle(cornerRadius: 2).stroke(CPWTheme.accent, lineWidth: 1.4) - case .anthropic: - CPWTriangle().stroke(CPWTheme.accent, lineWidth: 1.4) - case .google: - RoundedRectangle(cornerRadius: 1).rotation(.degrees(45)).stroke(CPWTheme.accent, lineWidth: 1.4) - } - } - .frame(width: size, height: size) + Text(provider.shortName) + .font(.system(size: compact ? 9 : 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(CPWTheme.providerColor(provider)) + .lineLimit(1) } } @@ -353,17 +376,6 @@ struct CPWLabel: View { } } -struct CPWTriangle: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - path.move(to: CGPoint(x: rect.midX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) - path.closeSubpath() - return path - } -} - enum CPWTheme { static let surface = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255) static let line = Color.black.opacity(0.08) @@ -372,6 +384,30 @@ enum CPWTheme { static let tertiaryText = primaryText.opacity(0.46) static let accent = Color(red: 74 / 255, green: 91 / 255, blue: 122 / 255) + static func providerColor(_ provider: Provider) -> Color { + switch provider { + case .openAI: + Color(red: 56 / 255, green: 92 / 255, blue: 126 / 255) + case .anthropic: + Color(red: 139 / 255, green: 102 / 255, blue: 51 / 255) + case .google: + Color(red: 35 / 255, green: 116 / 255, blue: 106 / 255) + } + } + + static func fastModeColor(_ recommendation: FastModeRecommendation?) -> Color { + switch recommendation { + case .safeThroughReset: + statusColor(.healthy) + case .safeForLimitedTime: + statusColor(.close) + case .saveFastMode, .limited: + statusColor(.limited) + case .needsCalibration, nil: + statusColor(.unknown) + } + } + static func statusColor(_ status: UsageStatus) -> Color { switch status { case .healthy: @@ -399,10 +435,63 @@ extension UsageLimit { guard let resetsAt else { return status == .failure ? "refresh failed" : "unknown reset" } + if resetsAt < Date().addingTimeInterval(-60) { + return "reset passed" + } return "resets \(resetsAt.widgetRelativeText)" } } +extension WidgetSnapshot { + var tightestCapacityRatio: Double { + guard let ratio = mostConstrainedLimits.first?.usageRatio else { return 0 } + return max(1 - ratio, 0) + } + + var fastModeForecast: FastModeForecast? { + let forecasts = limits + .filter { $0.provider == .openAI && $0.unit == .percent } + .map { limit in + FastModeForecast(input: FastModeForecastInput( + limit: limit, + now: Date(), + standardBurnRate: BurnRate(mode: .standard, unitsPerHour: 2), + fastBurnRate: BurnRate(mode: .fast, unitsPerHour: 12), + reserveUnits: 6, + minimumSafeHours: 1 + )) + } + return FastModePortfolioForecast(forecasts: forecasts).bestForecast + } + + var fastModeStatus: FastModeRecommendation? { + fastModeForecast?.recommendation + } + + var fastModeVerdict: String { + fastModeForecast?.copy ?? message + } + + var fastModeDetail: String { + guard let forecast = fastModeForecast else { return "OpenAI account needed for fast-mode forecast" } + let runway = forecast.fastModeRunwayHours.map { "runway \(Self.format(hours: $0))" } ?? "runway unknown" + let reset = forecast.hoursUntilReset.map { "reset \(Self.format(hours: $0))" } ?? "reset unknown" + return "\(forecast.accountName) · \(runway) · \(reset)" + } + + private static func format(hours: Double) -> String { + if hours < 1 { + return "\(max(Int((hours * 60).rounded()), 1))m" + } + if hours < 10 { + let rounded = (hours * 2).rounded() / 2 + if rounded == rounded.rounded() { return "\(Int(rounded))h" } + return "\(rounded)h" + } + return "\(Int(hours.rounded()))h" + } +} + extension Date { var widgetRelativeText: String { let seconds = Int(timeIntervalSince(Date())) @@ -422,4 +511,3 @@ extension Date { return "\(hours / 24)d ago" } } - diff --git a/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift index 65acbd2..42aee55 100644 --- a/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift +++ b/Tests/ContextPanelCoreTests/CodexRateLimitsTests.swift @@ -65,6 +65,15 @@ import Testing #expect(snapshots[1].limitName == "GPT-5.3-Codex-Spark") #expect(snapshots[1].primary?.usedPercent == 1) #expect(snapshots[1].credits == nil) + + let limits = codexUsageLimits( + from: snapshots[0], + accountID: "acct", + accountName: "Personal", + observedAt: Date(timeIntervalSince1970: 0) + ) + #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"]) + #expect(limits.first?.modelLabel == "Codex") } @Test func codexUsagePayloadParserHandlesMissingLimitDetails() throws { @@ -85,4 +94,3 @@ import Testing #expect(snapshots[0].primary == nil) #expect(snapshots[0].secondary == nil) } - diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift index 3306354..1f1570d 100644 --- a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -30,7 +30,7 @@ import Testing #expect(result.snapshot.limits.count == 4) #expect(Set(result.snapshot.limits.map(\.accountName)) == ["OpenAI A", "OpenAI B"]) #expect(result.snapshot.limits.allSatisfy { $0.provider == .openAI && $0.unit == .percent }) - #expect(result.snapshot.limits.contains { $0.used == 50 && $0.label.contains("300m") }) + #expect(result.snapshot.limits.contains { $0.used == 50 && $0.windowLabel == "5-hour" }) #expect(http.requests.count == 2) } @@ -164,4 +164,3 @@ private struct StubConnector: ProviderConnector { ConnectorRefreshResult(generatedAt: now, reports: [report]) } } - diff --git a/Tests/ContextPanelCoreTests/UsageLimitTests.swift b/Tests/ContextPanelCoreTests/UsageLimitTests.swift index 5fb142f..fa3f369 100644 --- a/Tests/ContextPanelCoreTests/UsageLimitTests.swift +++ b/Tests/ContextPanelCoreTests/UsageLimitTests.swift @@ -59,7 +59,35 @@ import Testing let first = snapshot.mostConstrainedLimits.first #expect(first?.label == "Image generation") - #expect(snapshot.aggregateCapacityRatio > 0) + #expect(abs(snapshot.aggregateCapacityRatio - 0.02) < 0.0001) +} + +@Test func aggregateCapacityUsesTightestTrackedWindow() { + let snapshot = UsageSnapshot( + generatedAt: Date(), + limits: [ + UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100), + UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100), + ] + ) + + #expect(abs(snapshot.aggregateCapacityRatio - 0.05) < 0.0001) +} + +@Test func usageLimitSeparatesWindowAndModelLabels() { + let limit = UsageLimit( + provider: .openAI, + accountID: "openai-personal", + accountName: "Personal", + label: "Codex 5-hour", + windowLabel: "5-hour", + modelLabel: "Codex", + used: 42, + limit: 100 + ) + + #expect(limit.displayLabel == "5-hour") + #expect(limit.contextLabel == "Codex · Personal") } @Test func providersCoverInitialScope() { diff --git a/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift index 29fa2f5..53be3a9 100644 --- a/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift +++ b/Tests/ContextPanelCoreTests/WidgetSnapshotTests.swift @@ -52,3 +52,18 @@ import Testing #expect(summaries[.anthropic]?.limitCount == 0) } +@Test func providerSummariesUseTheTightestWindowInsteadOfAverageCapacity() { + let savedAt = Date(timeIntervalSince1970: 100) + let stored = StoredUsageSnapshot(savedAt: savedAt, snapshot: UsageSnapshot( + generatedAt: savedAt, + limits: [ + UsageLimit(provider: .openAI, label: "Weekly", used: 95, limit: 100), + UsageLimit(provider: .openAI, label: "5-hour", used: 5, limit: 100), + ] + )) + + let widget = WidgetSnapshot.fromStore(SnapshotStoreLoadResult(snapshot: stored, status: .healthy)) + let openAI = widget.providerSummaries.first { $0.provider == .openAI } + + #expect(abs((openAI?.capacityRatio ?? 0) - 0.05) < 0.0001) +} diff --git a/docs/design-direction.md b/docs/design-direction.md index a018884..b3cec85 100644 --- a/docs/design-direction.md +++ b/docs/design-direction.md @@ -27,10 +27,9 @@ multi-account data. - True neutral gray surfaces, tuned separately for light and dark appearances. - One swappable accent color; default accent is a restrained slate blue. - Subtle status tints. Avoid alarm-heavy red as the dominant state language. -- Provider identity should use abstract glyphs plus labels, not provider logos as - the only hierarchy. -- Status must be communicated by color plus shape or text for color-vision - safety. +- Provider identity should use short text badges plus labels, not abstract + shapes or provider logos as the only hierarchy. +- Status must be communicated by color plus nearby text for color-vision safety. - Widgets are read-only and deep-link into the app for setup and detail. - Failure and stale states isolate to the affected account or provider; never blank the whole widget when neighboring data is still valid. @@ -55,8 +54,8 @@ of copying the React implementation: - `CapacityDial`: ring/dial for overall or account capacity. - `CapacityBar`: compact account/model capacity bar. -- `ProviderGlyph`: abstract provider symbol. -- `StatusMark`: shape-coded status marker. +- `ProviderBadge`: provider short-name text badge. +- `StatusMark`: compact status marker paired with text. - `AccountRow`: reusable account/model row for widgets and app detail. - `ContextWidget`: WidgetKit configuration for small, medium, and large layouts. - `WidgetTimelineProvider`: timeline backed by cached local snapshots and diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index 7f20f0b..4402f22 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -106,7 +106,7 @@ Codex CLI has an app-server method, `account/rateLimits/read`, backed by a backend client call that fetches live snapshots from the ChatGPT Codex backend: `GET /backend-api/wham/usage` for ChatGPT-backed auth, or `/api/codex/usage` for Codex API-style deployments. The payload maps into rate-limit snapshots with -primary and secondary windows, reset times, plan type, credits, reached-limit +provider window buckets, reset times, plan type, credits, reached-limit classification, and additional buckets keyed by `limit_id`. Every Code is useful as a fallback and validation source. It does not derive @@ -142,7 +142,7 @@ Preferred v1 connector scope: - Fetch live Codex limits directly from the Codex backend usage endpoint shape: `GET https://chatgpt.com/backend-api/wham/usage` for ChatGPT-backed auth. -- Support primary and secondary windows, reset times, plan type, credits, +- Support provider window buckets, reset times, plan type, credits, reached-limit classification, and additional `limit_id` buckets. - Keep auth handling isolated and redacted; never log tokens, cookies, authorization headers, account IDs, emails, or raw response bodies. @@ -216,7 +216,7 @@ Fallback connector scope: for the connector. - Parse `rate_limit.snapshot`, `observed_at`, `primary_next_reset_at`, `secondary_next_reset_at`, `last_usage_limit_hit_at`, and `plan`. -- Normalize primary and secondary windows as Codex limits with observed +- Normalize Codex provider window buckets as limits with observed confidence and freshness state. - Mark the connector stale when `observed_at` or `last_updated` is older than a conservative threshold; do not trigger refreshes. From b6722a96fd48830c5eb6da3e2a0d295dcd0599d1 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 14:41:04 -0400 Subject: [PATCH 23/34] Load local Gemini client metadata --- .../AccountConfigurationStore.swift | 43 ++++++++---- .../GeminiCodeAssistQuota.swift | 68 +++++++++++++++++++ Sources/GeminiQuotaProbe/main.swift | 31 +++++---- Sources/SnapshotStoreProbe/main.swift | 40 ++++++++--- .../AccountConfigurationStoreTests.swift | 34 +++++++++- .../GeminiCodeAssistQuotaTests.swift | 25 +++++++ 6 files changed, 202 insertions(+), 39 deletions(-) diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift index 5213d7d..a907478 100644 --- a/Sources/ContextPanelCore/AccountConfigurationStore.swift +++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift @@ -152,7 +152,13 @@ public struct AccountConfigurationStore: Sendable { public enum AccountConnectorFactory { public static func connectors( from document: AccountConfigurationDocument, - environment: [String: String] = ProcessInfo.processInfo.environment + environment: [String: String] = ProcessInfo.processInfo.environment, + geminiMetadataFileLoader: @escaping @Sendable (String) throws -> String = { path in + try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8) + }, + geminiMetadataFileExists: @escaping @Sendable (String) -> Bool = { path in + FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + } ) -> [any ProviderConnector] { document.accounts.compactMap { account in guard account.isEnabled else { return nil } @@ -164,20 +170,19 @@ public enum AccountConnectorFactory { accountName: account.displayName )]) case .geminiCodeAssist: - guard - let authPath = account.authPath, - let clientIDName = account.oauthClientIDEnvironmentName, - let clientSecretName = account.oauthClientSecretEnvironmentName, - let clientID = environment[clientIDName], - let clientSecret = environment[clientSecretName] - else { - return nil - } + guard let authPath = account.authPath else { return nil } + let configuredMetadata = geminiMetadata(account: account, environment: environment) + let discoveredMetadata = GeminiOAuthClientMetadataDiscovery.discover( + environment: environment, + fileLoader: geminiMetadataFileLoader, + fileExists: geminiMetadataFileExists + ) + guard let metadata = configuredMetadata ?? discoveredMetadata else { return nil } return GeminiCodeAssistConnector(accounts: [GeminiAccountConfiguration( authPath: authPath, accountName: account.displayName, - clientID: clientID, - clientSecret: clientSecret + clientID: metadata.clientID, + clientSecret: metadata.clientSecret )]) case .claudeLocalStatus: return ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration( @@ -188,5 +193,17 @@ public enum AccountConnectorFactory { } } } -} + private static func geminiMetadata( + account: LocalProviderAccountConfiguration, + environment: [String: String] + ) -> GeminiOAuthClientMetadata? { + guard + let clientIDName = account.oauthClientIDEnvironmentName, + let clientSecretName = account.oauthClientSecretEnvironmentName, + let clientID = environment[clientIDName], !clientID.isEmpty, + let clientSecret = environment[clientSecretName], !clientSecret.isEmpty + else { return nil } + return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) + } +} diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift index 0922e2f..6376285 100644 --- a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -116,6 +116,74 @@ public struct GeminiAccountConfiguration: Equatable, Sendable { } } +public struct GeminiOAuthClientMetadata: Equatable, Sendable { + public let clientID: String + public let clientSecret: String + + public init(clientID: String, clientSecret: String) { + self.clientID = clientID + self.clientSecret = clientSecret + } +} + +public enum GeminiOAuthClientMetadataDiscovery { + public static func discover( + environment: [String: String] = ProcessInfo.processInfo.environment, + fileLoader: @escaping @Sendable (String) throws -> String = { path in + try String(contentsOfFile: NSString(string: path).expandingTildeInPath, encoding: .utf8) + }, + fileExists: @escaping @Sendable (String) -> Bool = { path in + FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + } + ) -> GeminiOAuthClientMetadata? { + if + let clientID = environment["GEMINI_OAUTH_CLIENT_ID"], !clientID.isEmpty, + let clientSecret = environment["GEMINI_OAUTH_CLIENT_SECRET"], !clientSecret.isEmpty + { + return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) + } + + for path in candidateBundlePaths(environment: environment) where fileExists(path) { + guard + let source = try? fileLoader(path), + let metadata = parseClientMetadata(from: source) + else { continue } + return metadata + } + return nil + } + + static func parseClientMetadata(from source: String) -> GeminiOAuthClientMetadata? { + guard + let clientID = stringLiteral(named: "OAUTH_CLIENT_ID", in: source), + let clientSecret = stringLiteral(named: "OAUTH_CLIENT_SECRET", in: source) + else { return nil } + return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) + } + + private static func stringLiteral(named variableName: String, in source: String) -> String? { + let pattern = #"var\s+\#(variableName)\s*=\s*\"([^\"]+)\""# + guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(source.startIndex..= 2, + let valueRange = Range(match.range(at: 1), in: source) + else { return nil } + return String(source[valueRange]) + } + + private static func candidateBundlePaths(environment: [String: String]) -> [String] { + var paths: [String] = [] + if let path = environment["GEMINI_CLI_BUNDLE_PATH"], !path.isEmpty { + paths.append(path) + } + paths.append("/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle/chunk-GDRLBWZL.js") + paths.append("/usr/local/lib/node_modules/@google/gemini-cli/bundle/chunk-GDRLBWZL.js") + return paths + } +} + public struct GeminiCodeAssistConnector: ProviderConnector { public let provider: Provider = .google diff --git a/Sources/GeminiQuotaProbe/main.swift b/Sources/GeminiQuotaProbe/main.swift index c6c8520..b3d6e6d 100644 --- a/Sources/GeminiQuotaProbe/main.swift +++ b/Sources/GeminiQuotaProbe/main.swift @@ -13,8 +13,7 @@ struct ProbeConfiguration { var authPath: String? var tokenEndpoint = URL(string: "https://oauth2.googleapis.com/token")! var codeAssistEndpoint = URL(string: "https://cloudcode-pa.googleapis.com/v1internal")! - var clientID = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_ID"] - var clientSecret = ProcessInfo.processInfo.environment["GEMINI_OAUTH_CLIENT_SECRET"] + var metadata = GeminiOAuthClientMetadataDiscovery.discover() var iterator = arguments.dropFirst().makeIterator() while let argument = iterator.next() { @@ -38,12 +37,18 @@ struct ProbeConfiguration { guard let value = iterator.next(), !value.isEmpty else { throw GeminiProbeError(message: "--client-id requires a value") } - clientID = value + metadata = GeminiOAuthClientMetadata( + clientID: value, + clientSecret: metadata?.clientSecret ?? "" + ) case "--client-secret": guard let value = iterator.next(), !value.isEmpty else { throw GeminiProbeError(message: "--client-secret requires a value") } - clientSecret = value + metadata = GeminiOAuthClientMetadata( + clientID: metadata?.clientID ?? "", + clientSecret: value + ) case "--help", "-h": printHelp() Foundation.exit(0) @@ -52,19 +57,16 @@ struct ProbeConfiguration { } } - guard let clientID, !clientID.isEmpty else { - throw GeminiProbeError(message: "set GEMINI_OAUTH_CLIENT_ID or pass --client-id") - } - guard let clientSecret, !clientSecret.isEmpty else { - throw GeminiProbeError(message: "set GEMINI_OAUTH_CLIENT_SECRET or pass --client-secret") + guard let metadata, !metadata.clientID.isEmpty, !metadata.clientSecret.isEmpty else { + throw GeminiProbeError(message: "install Gemini CLI, set GEMINI_OAUTH_CLIENT_ID/SECRET, or pass --client-id/--client-secret") } return ProbeConfiguration(account: GeminiAccountConfiguration( authPath: authPath ?? defaultAuthPath(), tokenEndpoint: tokenEndpoint, codeAssistEndpoint: codeAssistEndpoint, - clientID: clientID, - clientSecret: clientSecret + clientID: metadata.clientID, + clientSecret: metadata.clientSecret )) } @@ -79,9 +81,10 @@ struct ProbeConfiguration { print(""" Usage: swift run GeminiQuotaProbe [--auth /path/to/oauth_creds.json] - Requires GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET, or the - equivalent --client-id and --client-secret flags. Use values from the - locally installed Gemini CLI; do not commit them to this repository. + Uses the locally installed Gemini CLI OAuth client metadata when + available. You can also set GEMINI_OAUTH_CLIENT_ID and + GEMINI_OAUTH_CLIENT_SECRET, or pass --client-id and --client-secret. + Do not commit OAuth client values to this repository. Uses the production Gemini Code Assist connector and prints only a redacted quota summary. Tokens, account identifiers, project IDs, diff --git a/Sources/SnapshotStoreProbe/main.swift b/Sources/SnapshotStoreProbe/main.swift index 7da8de4..1bfd4c4 100644 --- a/Sources/SnapshotStoreProbe/main.swift +++ b/Sources/SnapshotStoreProbe/main.swift @@ -9,12 +9,14 @@ struct SnapshotStoreProbeError: LocalizedError { struct ProbeConfiguration { let outputDirectory: URL - let codexAuthPath: String? + let codexAccounts: [CodexAccountConfiguration] + let includeConfiguredAccounts: Bool let includeClaude: Bool static func fromArguments(_ arguments: [String]) throws -> ProbeConfiguration { var outputDirectory: URL? - var codexAuthPath: String? + var codexAccounts: [CodexAccountConfiguration] = [] + var includeConfiguredAccounts = false var includeClaude = false var iterator = arguments.dropFirst().makeIterator() @@ -29,7 +31,14 @@ struct ProbeConfiguration { guard let value = iterator.next() else { throw SnapshotStoreProbeError(message: "--codex-auth requires a path") } - codexAuthPath = value + codexAccounts.append(CodexAccountConfiguration(authPath: value)) + case "--codex-account": + guard let value = iterator.next() else { + throw SnapshotStoreProbeError(message: "--codex-account requires label=path") + } + codexAccounts.append(try parseCodexAccount(value)) + case "--configured-accounts": + includeConfiguredAccounts = true case "--include-claude": includeClaude = true case "--help", "-h": @@ -42,11 +51,20 @@ struct ProbeConfiguration { return ProbeConfiguration( outputDirectory: outputDirectory ?? defaultOutputDirectory(), - codexAuthPath: codexAuthPath, + codexAccounts: codexAccounts, + includeConfiguredAccounts: includeConfiguredAccounts, includeClaude: includeClaude ) } + private static func parseCodexAccount(_ value: String) throws -> CodexAccountConfiguration { + let parts = value.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + throw SnapshotStoreProbeError(message: "--codex-account requires label=path") + } + return CodexAccountConfiguration(authPath: String(parts[1]), accountName: String(parts[0])) + } + private static func defaultOutputDirectory() -> URL { FileManager.default.temporaryDirectory .appending(path: "context-panel-snapshot-store", directoryHint: .isDirectory) @@ -54,7 +72,7 @@ struct ProbeConfiguration { private static func printHelp() { print(""" - Usage: swift run SnapshotStoreProbe [--output /tmp/context-panel-store] [--codex-auth ~/.codex/auth.json] [--include-claude] + Usage: swift run SnapshotStoreProbe [--output /tmp/context-panel-store] [--configured-accounts] [--codex-account Label=~/.codex/auth.json] [--include-claude] Refreshes selected local connectors, writes the normalized snapshot to the JSON snapshot store, then reloads it. The store contains normalized @@ -93,11 +111,14 @@ struct SnapshotStoreProbe { } private static func makeConnectors(configuration: ProbeConfiguration) -> [any ProviderConnector] { + if configuration.includeConfiguredAccounts { + let store = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL()) + return AccountConnectorFactory.connectors(from: store.load().document) + } + var connectors: [any ProviderConnector] = [] - if let codexAuthPath = configuration.codexAuthPath { - connectors.append(CodexRateLimitConnector(accounts: [ - CodexAccountConfiguration(authPath: codexAuthPath) - ])) + if !configuration.codexAccounts.isEmpty { + connectors.append(CodexRateLimitConnector(accounts: configuration.codexAccounts)) } if configuration.includeClaude { connectors.append(ClaudeLocalStatusConnector(accounts: [ClaudeAccountConfiguration()])) @@ -105,4 +126,3 @@ struct SnapshotStoreProbe { return connectors } } - diff --git a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift index b097a12..54844bb 100644 --- a/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift +++ b/Tests/ContextPanelCoreTests/AccountConfigurationStoreTests.swift @@ -61,7 +61,12 @@ import Testing ), ]) - let withoutGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [:]) + let withoutGeminiEnvironment = AccountConnectorFactory.connectors( + from: document, + environment: [:], + geminiMetadataFileLoader: { _ in "" }, + geminiMetadataFileExists: { _ in false } + ) let withGeminiEnvironment = AccountConnectorFactory.connectors(from: document, environment: [ "GEMINI_ID": "client", "GEMINI_SECRET": "secret", @@ -73,6 +78,32 @@ import Testing #expect(Set(withGeminiEnvironment.map(\.provider)) == [.openAI, .google]) } +@Test func accountConnectorFactoryCanDiscoverGeminiMetadataFromInstalledCLI() { + let document = AccountConfigurationDocument(updatedAt: Date(timeIntervalSince1970: 0), accounts: [ + LocalProviderAccountConfiguration( + id: "gemini", + provider: .google, + connectorKind: .geminiCodeAssist, + displayName: "Gemini", + authPath: "/tmp/gemini.json" + ) + ]) + + let source = #""" + var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com"; + var OAUTH_CLIENT_SECRET = "client-secret"; + """# + let connectors = AccountConnectorFactory.connectors( + from: document, + environment: ["GEMINI_CLI_BUNDLE_PATH": "/tmp/gemini-bundle.js"], + geminiMetadataFileLoader: { _ in source }, + geminiMetadataFileExists: { _ in true } + ) + + #expect(connectors.count == 1) + #expect(connectors[0].provider == .google) +} + @Test func accountConfigurationStoreReportsCorruptFilesAsFailure() throws { let url = try temporaryDirectory().appending(path: "accounts.json") try Data("nope".utf8).write(to: url) @@ -91,4 +122,3 @@ private func temporaryDirectory() throws -> URL { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } - diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift index 7eecf04..929bbba 100644 --- a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift +++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift @@ -46,3 +46,28 @@ import Testing #expect(buckets.isEmpty) } + +@Test func geminiOAuthClientMetadataDiscoveryParsesInstalledCLIBundleShape() { + let source = #""" + var OAUTH_CLIENT_ID = "client-id.apps.googleusercontent.com"; + var OAUTH_CLIENT_SECRET = "client-secret"; + """# + + let metadata = GeminiOAuthClientMetadataDiscovery.parseClientMetadata(from: source) + + #expect(metadata?.clientID == "client-id.apps.googleusercontent.com") + #expect(metadata?.clientSecret == "client-secret") +} + +@Test func geminiOAuthClientMetadataDiscoveryPrefersEnvironmentValues() { + let metadata = GeminiOAuthClientMetadataDiscovery.discover( + environment: [ + "GEMINI_OAUTH_CLIENT_ID": "env-client", + "GEMINI_OAUTH_CLIENT_SECRET": "env-secret", + ], + fileLoader: { _ in "" }, + fileExists: { _ in false } + ) + + #expect(metadata == GeminiOAuthClientMetadata(clientID: "env-client", clientSecret: "env-secret")) +} From 6743338c1328d80bdac382dec3de0467ace5364f Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 15:11:13 -0400 Subject: [PATCH 24/34] Clarify Claude allowance status --- .../AccountConfigurationStore.swift | 7 +++- .../ContextPanelCore/ClaudeLocalStatus.swift | 12 +++++-- .../GeminiCodeAssistQuota.swift | 33 ++++++++++++++++--- .../ContextPanelPreviewApp.swift | 6 ++++ .../ContextPanelWidgetViews.swift | 3 ++ .../ClaudeLocalStatusTests.swift | 22 +++++++++++++ .../GeminiCodeAssistQuotaTests.swift | 25 +++++++++++++- 7 files changed, 99 insertions(+), 9 deletions(-) diff --git a/Sources/ContextPanelCore/AccountConfigurationStore.swift b/Sources/ContextPanelCore/AccountConfigurationStore.swift index a907478..45e0ba1 100644 --- a/Sources/ContextPanelCore/AccountConfigurationStore.swift +++ b/Sources/ContextPanelCore/AccountConfigurationStore.swift @@ -158,6 +158,10 @@ public enum AccountConnectorFactory { }, geminiMetadataFileExists: @escaping @Sendable (String) -> Bool = { path in FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + }, + geminiMetadataDirectoryLister: @escaping @Sendable (String) -> [String] = { path in + let expanded = NSString(string: path).expandingTildeInPath + return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? [] } ) -> [any ProviderConnector] { document.accounts.compactMap { account in @@ -175,7 +179,8 @@ public enum AccountConnectorFactory { let discoveredMetadata = GeminiOAuthClientMetadataDiscovery.discover( environment: environment, fileLoader: geminiMetadataFileLoader, - fileExists: geminiMetadataFileExists + fileExists: geminiMetadataFileExists, + directoryLister: geminiMetadataDirectoryLister ) guard let metadata = configuredMetadata ?? discoveredMetadata else { return nil } return GeminiCodeAssistConnector(accounts: [GeminiAccountConfiguration( diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift index 6b6f6fe..039e044 100644 --- a/Sources/ContextPanelCore/ClaudeLocalStatus.swift +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -12,6 +12,11 @@ public struct ClaudeAuthStatus: Codable, Equatable, Sendable { self.apiProvider = apiProvider self.subscriptionType = subscriptionType } + + public var subscriptionDisplayName: String { + guard let subscriptionType, !subscriptionType.isEmpty else { return "Claude" } + return "Claude \(subscriptionType.capitalized)" + } } public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { @@ -174,6 +179,7 @@ public func claudeLocalStatusLimits( "auth: \(authStatus.authMethod)", "provider: \(authStatus.apiProvider ?? "unknown")", "subscription: \(authStatus.subscriptionType ?? "unknown")", + "allowance: not exposed by Claude Code", ] if let statsSummary { noteParts.append("sessions: \(statsSummary.totalSessions.map(String.init) ?? "unknown")") @@ -186,14 +192,14 @@ public func claudeLocalStatusLimits( provider: .anthropic, accountID: accountID, accountName: accountName, - label: "Claude subscription allowance", - modelLabel: "Claude subscription allowance", + label: "\(authStatus.subscriptionDisplayName) status", + modelLabel: "Claude Code", unit: .unknown, used: nil, limit: nil, resetsAt: nil, lastUpdatedAt: statsSummary?.lastComputedDate ?? observedAt, - confidence: .unknown, + confidence: .observed, statusOverride: authStatus.loggedIn ? .unknown : .failure, note: noteParts.joined(separator: "; ") )] diff --git a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift index 6376285..e134c98 100644 --- a/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift +++ b/Sources/ContextPanelCore/GeminiCodeAssistQuota.swift @@ -134,6 +134,10 @@ public enum GeminiOAuthClientMetadataDiscovery { }, fileExists: @escaping @Sendable (String) -> Bool = { path in FileManager.default.fileExists(atPath: NSString(string: path).expandingTildeInPath) + }, + directoryLister: @escaping @Sendable (String) -> [String] = { path in + let expanded = NSString(string: path).expandingTildeInPath + return (try? FileManager.default.contentsOfDirectory(atPath: expanded).map { "\(expanded)/\($0)" }) ?? [] } ) -> GeminiOAuthClientMetadata? { if @@ -143,7 +147,10 @@ public enum GeminiOAuthClientMetadataDiscovery { return GeminiOAuthClientMetadata(clientID: clientID, clientSecret: clientSecret) } - for path in candidateBundlePaths(environment: environment) where fileExists(path) { + for path in candidateBundlePaths( + environment: environment, + directoryLister: directoryLister + ) where fileExists(path) { guard let source = try? fileLoader(path), let metadata = parseClientMetadata(from: source) @@ -173,15 +180,33 @@ public enum GeminiOAuthClientMetadataDiscovery { return String(source[valueRange]) } - private static func candidateBundlePaths(environment: [String: String]) -> [String] { + private static func candidateBundlePaths( + environment: [String: String], + directoryLister: @Sendable (String) -> [String] + ) -> [String] { var paths: [String] = [] if let path = environment["GEMINI_CLI_BUNDLE_PATH"], !path.isEmpty { paths.append(path) } - paths.append("/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle/chunk-GDRLBWZL.js") - paths.append("/usr/local/lib/node_modules/@google/gemini-cli/bundle/chunk-GDRLBWZL.js") + paths.append(contentsOf: bundleChunkPaths( + root: "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle", + directoryLister: directoryLister + )) + paths.append(contentsOf: bundleChunkPaths( + root: "/usr/local/lib/node_modules/@google/gemini-cli/bundle", + directoryLister: directoryLister + )) return paths } + + private static func bundleChunkPaths( + root: String, + directoryLister: @Sendable (String) -> [String] + ) -> [String] { + directoryLister(root) + .filter { $0.hasSuffix(".js") } + .sorted() + } } public struct GeminiCodeAssistConnector: ProviderConnector { diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 449705a..8bebc3c 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -1000,11 +1000,17 @@ extension UsageSnapshot { extension UsageLimit { var compactUsageText: String { + if provider == .anthropic, unit == .unknown, status == .unknown { + return "unknown" + } guard let used, let limit else { return status == .failure ? "—" : "?" } return "\(used)/\(limit)" } var percentText: String { + if provider == .anthropic, unit == .unknown, status == .unknown { + return "unknown" + } guard let usageRatio else { return status == .failure ? "—" : "?" } return "\(Int(usageRatio * 100))%" } diff --git a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift index 218e918..01a24f8 100644 --- a/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift +++ b/Sources/ContextPanelWidget/ContextPanelWidgetViews.swift @@ -424,6 +424,9 @@ enum CPWTheme { extension UsageLimit { var widgetUsageText: String { + if provider == .anthropic, unit == .unknown, status == .unknown { + return "allowance unknown" + } if let remaining, let limit { return "\(remaining)/\(limit) left" } diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift index e92f043..2ea49c8 100644 --- a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift +++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift @@ -21,6 +21,7 @@ import Testing #expect(status.authMethod == "claude.ai") #expect(status.apiProvider == "firstParty") #expect(status.subscriptionType == "pro") + #expect(status.subscriptionDisplayName == "Claude Pro") } @Test func claudeStatsCacheParserSummarizesLocalActivityOnly() throws { @@ -71,3 +72,24 @@ import Testing #expect(summary.dailyActivityCount == 2) #expect(summary.modelUsageCount == 0) } + +@Test func claudeLocalStatusLimitMakesUnknownAllowanceExplicit() { + let limit = claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus( + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro" + ), + statsSummary: nil, + accountID: "local", + accountName: "Claude", + observedAt: Date(timeIntervalSince1970: 0) + ).first! + + #expect(limit.label == "Claude Pro status") + #expect(limit.modelLabel == "Claude Code") + #expect(limit.confidence == .observed) + #expect(limit.status == .unknown) + #expect(limit.note?.contains("allowance: not exposed by Claude Code") == true) +} diff --git a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift index 929bbba..6c93b80 100644 --- a/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift +++ b/Tests/ContextPanelCoreTests/GeminiCodeAssistQuotaTests.swift @@ -66,8 +66,31 @@ import Testing "GEMINI_OAUTH_CLIENT_SECRET": "env-secret", ], fileLoader: { _ in "" }, - fileExists: { _ in false } + fileExists: { _ in false }, + directoryLister: { _ in [] } ) #expect(metadata == GeminiOAuthClientMetadata(clientID: "env-client", clientSecret: "env-secret")) } + +@Test func geminiOAuthClientMetadataDiscoveryScansInstalledBundleDirectory() { + let source = #""" + var OAUTH_CLIENT_ID = "bundle-client"; + var OAUTH_CLIENT_SECRET = "bundle-secret"; + """# + + let metadata = GeminiOAuthClientMetadataDiscovery.discover( + environment: [:], + fileLoader: { path in + path.hasSuffix("chunk-with-oauth.js") ? source : "" + }, + fileExists: { _ in true }, + directoryLister: { root in + root == "/opt/homebrew/lib/node_modules/@google/gemini-cli/bundle" + ? ["\(root)/chunk-with-oauth.js"] + : [] + } + ) + + #expect(metadata == GeminiOAuthClientMetadata(clientID: "bundle-client", clientSecret: "bundle-secret")) +} From 12243da4bf1859c9ad60b862bc72e1ab77941bd5 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 15:31:22 -0400 Subject: [PATCH 25/34] Add Claude subscription statusline cache --- README.md | 23 ++- .../ContextPanelCore/ClaudeLocalStatus.swift | 155 +++++++++++++++++- .../ClaudeLocalStatusTests.swift | 84 ++++++++++ .../ProviderConnectorTests.swift | 2 +- docs/provider-usage-access.md | 35 ++-- scripts/claude-statusline-cache.sh | 51 ++++++ 6 files changed, 328 insertions(+), 22 deletions(-) create mode 100755 scripts/claude-statusline-cache.sh diff --git a/README.md b/README.md index 72e47ae..bb50e55 100644 --- a/README.md +++ b/README.md @@ -67,9 +67,26 @@ swift run SnapshotStoreProbe --codex-auth ~/.codex/auth.json --include-claude The Codex and Gemini probes can return live percent-window quota buckets for their respective CLI-backed accounts. The Claude probe intentionally reports -only local auth/subscription metadata and local stats-cache freshness because a -live personal subscription allowance is not exposed through a clean local signal -yet. +local auth/subscription metadata and local stats-cache freshness until a Claude +Code status-line cache has been populated. + +To capture Claude subscription usage percentages, configure Claude Code's +status line to call the helper in this repo. Claude Code sends the helper a JSON +payload after session responses; the helper stores only five-hour and weekly +used percentages plus reset timestamps under Context Panel's Application +Support directory. + +```json +{ + "statusLine": { + "type": "command", + "command": "/absolute/path/to/context-panel/scripts/claude-statusline-cache.sh" + } +} +``` + +The helper does not store auth tokens, prompts, transcript contents, emails, +organization IDs, or raw Claude session JSON. For Gemini, use the OAuth client values from the locally installed Gemini CLI; they are intentionally not checked into this repository. diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift index 039e044..e14300f 100644 --- a/Sources/ContextPanelCore/ClaudeLocalStatus.swift +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -27,6 +27,7 @@ public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { public let firstSessionDate: Date? public let modelUsageCount: Int public let dailyActivityCount: Int + public let rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? public init( version: Int?, @@ -35,7 +36,8 @@ public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { totalMessages: Int?, firstSessionDate: Date?, modelUsageCount: Int, - dailyActivityCount: Int + dailyActivityCount: Int, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil ) { self.version = version self.lastComputedDate = lastComputedDate @@ -44,6 +46,29 @@ public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { self.firstSessionDate = firstSessionDate self.modelUsageCount = modelUsageCount self.dailyActivityCount = dailyActivityCount + self.rateLimitSnapshot = rateLimitSnapshot + } +} + +public struct ClaudeSubscriptionRateLimitWindow: Codable, Equatable, Sendable { + public let label: String + public let usedPercent: Double + public let resetsAt: Date? + + public init(label: String, usedPercent: Double, resetsAt: Date?) { + self.label = label + self.usedPercent = max(0, min(usedPercent, 100)) + self.resetsAt = resetsAt + } +} + +public struct ClaudeSubscriptionRateLimitSnapshot: Codable, Equatable, Sendable { + public let observedAt: Date + public let windows: [ClaudeSubscriptionRateLimitWindow] + + public init(observedAt: Date, windows: [ClaudeSubscriptionRateLimitWindow]) { + self.observedAt = observedAt + self.windows = windows } } @@ -69,7 +94,27 @@ public enum ClaudeStatsCacheParser { totalMessages: payload.totalMessages, firstSessionDate: payload.firstSessionDate, modelUsageCount: payload.modelUsage?.count ?? 0, - dailyActivityCount: payload.dailyActivity?.count ?? 0 + dailyActivityCount: payload.dailyActivity?.count ?? 0, + rateLimitSnapshot: payload.rateLimits?.snapshot( + observedAt: payload.lastComputedDate ?? Date(timeIntervalSince1970: 0) + ) + ) + } +} + +public enum ClaudeSubscriptionRateLimitCacheParser { + public static func snapshot(from data: Data) throws -> ClaudeSubscriptionRateLimitSnapshot { + let payload = try JSONDecoder().decode(ClaudeStatuslineRateLimitPayload.self, from: data) + var windows: [ClaudeSubscriptionRateLimitWindow] = [] + if let fiveHour = payload.rateLimits.fiveHour { + windows.append(fiveHour.window(label: "5-hour")) + } + if let sevenDay = payload.rateLimits.sevenDay { + windows.append(sevenDay.window(label: "Weekly")) + } + return ClaudeSubscriptionRateLimitSnapshot( + observedAt: Date(timeIntervalSince1970: TimeInterval(payload.observedAt)), + windows: windows ) } } @@ -78,11 +123,20 @@ public struct ClaudeAccountConfiguration: Equatable, Sendable { public let accountName: String public let claudeBinary: String public let statsPath: String + public let rateLimitSnapshotPath: String - public init(accountName: String = "Claude", claudeBinary: String = "claude", statsPath: String? = nil) { + public init( + accountName: String = "Claude", + claudeBinary: String = "claude", + statsPath: String? = nil, + rateLimitSnapshotPath: String? = nil + ) { self.accountName = accountName self.claudeBinary = claudeBinary - self.statsPath = statsPath ?? "\(FileManager.default.homeDirectoryForCurrentUser.path)/.claude/stats-cache.json" + let home = FileManager.default.homeDirectoryForCurrentUser.path + self.statsPath = statsPath ?? "\(home)/.claude/stats-cache.json" + self.rateLimitSnapshotPath = rateLimitSnapshotPath + ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/statusline-cache.json" } } @@ -120,14 +174,17 @@ public struct ClaudeLocalStatusConnector: ProviderConnector { } private func refresh(account: ClaudeAccountConfiguration, now: Date) -> ProviderConnectorReport { - let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.statsPath) + let localAccountID = ConnectorRedactor.localAccountID(provider: provider, path: account.rateLimitSnapshotPath) do { let authStatus = try loadAuthStatus(claudeBinary: account.claudeBinary) let statsSummary = try loadStatsSummary(path: account.statsPath) + let rateLimitSnapshot = try loadRateLimitSnapshot(path: account.rateLimitSnapshotPath) + ?? statsSummary?.rateLimitSnapshot let limits = claudeLocalStatusLimits( authStatus: authStatus, statsSummary: statsSummary, + rateLimitSnapshot: rateLimitSnapshot, accountID: localAccountID, accountName: account.accountName, observedAt: now @@ -166,15 +223,41 @@ public struct ClaudeLocalStatusConnector: ProviderConnector { guard fileExists(path) else { return nil } return try ClaudeStatsCacheParser.summary(from: try fileLoader(path)) } + + private func loadRateLimitSnapshot(path: String) throws -> ClaudeSubscriptionRateLimitSnapshot? { + guard fileExists(path) else { return nil } + return try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: try fileLoader(path)) + } } public func claudeLocalStatusLimits( authStatus: ClaudeAuthStatus, statsSummary: ClaudeStatsCacheSummary?, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil, accountID: String, accountName: String, observedAt: Date ) -> [UsageLimit] { + if authStatus.loggedIn, let rateLimitSnapshot, !rateLimitSnapshot.windows.isEmpty { + return rateLimitSnapshot.windows.map { window in + UsageLimit( + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude \(window.label)", + windowLabel: window.label, + modelLabel: authStatus.subscriptionDisplayName, + unit: .percent, + used: Int(window.usedPercent.rounded()), + limit: 100, + resetsAt: window.resetsAt, + lastUpdatedAt: rateLimitSnapshot.observedAt, + confidence: .observed, + note: "source: Claude Code statusline; subscription: \(authStatus.subscriptionType ?? "unknown")" + ) + } + } + var noteParts = [ "auth: \(authStatus.authMethod)", "provider: \(authStatus.apiProvider ?? "unknown")", @@ -205,6 +288,56 @@ public func claudeLocalStatusLimits( )] } +private struct ClaudeStatuslineRateLimitPayload: Decodable { + let observedAt: Int + let rateLimits: ClaudeStatuslineRateLimits + + enum CodingKeys: String, CodingKey { + case observedAt = "observed_at" + case rateLimits = "rate_limits" + } +} + +private struct ClaudeStatuslineRateLimits: Decodable { + let fiveHour: ClaudeStatuslineRateLimitWindow? + let sevenDay: ClaudeStatuslineRateLimitWindow? + + enum CodingKeys: String, CodingKey { + case fiveHour = "five_hour" + case sevenDay = "seven_day" + } + + func snapshot(observedAt: Date) -> ClaudeSubscriptionRateLimitSnapshot? { + var windows: [ClaudeSubscriptionRateLimitWindow] = [] + if let fiveHour { + windows.append(fiveHour.window(label: "5-hour")) + } + if let sevenDay { + windows.append(sevenDay.window(label: "Weekly")) + } + guard !windows.isEmpty else { return nil } + return ClaudeSubscriptionRateLimitSnapshot(observedAt: observedAt, windows: windows) + } +} + +private struct ClaudeStatuslineRateLimitWindow: Decodable { + let usedPercentage: Double + let resetsAt: Int? + + enum CodingKeys: String, CodingKey { + case usedPercentage = "used_percentage" + case resetsAt = "resets_at" + } + + func window(label: String) -> ClaudeSubscriptionRateLimitWindow { + ClaudeSubscriptionRateLimitWindow( + label: label, + usedPercent: usedPercentage, + resetsAt: resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + } +} + private struct ClaudeAuthStatusPayload: Decodable { let loggedIn: Bool let authMethod: String @@ -220,6 +353,18 @@ private struct ClaudeStatsCachePayload: Decodable { let totalSessions: Int? let totalMessages: Int? let firstSessionDate: Date? + let rateLimits: ClaudeStatuslineRateLimits? + + enum CodingKeys: String, CodingKey { + case version + case lastComputedDate + case dailyActivity + case modelUsage + case totalSessions + case totalMessages + case firstSessionDate + case rateLimits = "rate_limits" + } } private enum ClaudeCountedCollection: Decodable { diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift index 2ea49c8..c17a643 100644 --- a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift +++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift @@ -73,6 +73,29 @@ import Testing #expect(summary.modelUsageCount == 0) } +@Test func claudeStatsCacheParserAcceptsRateLimitSnapshotWhenPresent() throws { + let json = #""" + { + "lastComputedDate": "2026-05-06T14:00:00Z", + "rate_limits": { + "five_hour": { + "used_percentage": 42.4, + "resets_at": 1788397200 + }, + "seven_day": { + "used_percentage": 51.6, + "resets_at": 1788984000 + } + } + } + """# + + let summary = try ClaudeStatsCacheParser.summary(from: Data(json.utf8)) + + #expect(summary.rateLimitSnapshot?.windows.map(\.label) == ["5-hour", "Weekly"]) + #expect(summary.rateLimitSnapshot?.windows.map { Int($0.usedPercent.rounded()) } == [42, 52]) +} + @Test func claudeLocalStatusLimitMakesUnknownAllowanceExplicit() { let limit = claudeLocalStatusLimits( authStatus: ClaudeAuthStatus( @@ -93,3 +116,64 @@ import Testing #expect(limit.status == .unknown) #expect(limit.note?.contains("allowance: not exposed by Claude Code") == true) } + +@Test func claudeStatuslineRateLimitCacheParsesSubscriptionWindows() throws { + let json = #""" + { + "observed_at": 1788379200, + "rate_limits": { + "five_hour": { + "used_percentage": 42.4, + "resets_at": 1788397200 + }, + "seven_day": { + "used_percentage": 51.6, + "resets_at": 1788984000 + } + } + } + """# + + let snapshot = try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: Data(json.utf8)) + + #expect(ContextPanelDateFormatting.string(from: snapshot.observedAt) == "2026-09-02T20:00:00Z") + #expect(snapshot.windows.map(\.label) == ["5-hour", "Weekly"]) + #expect(snapshot.windows.map { Int($0.usedPercent.rounded()) } == [42, 52]) +} + +@Test func claudeLocalStatusLimitsPrefersStatuslineSubscriptionWindows() { + let limits = claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus( + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro" + ), + statsSummary: nil, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot( + observedAt: Date(timeIntervalSince1970: 10), + windows: [ + ClaudeSubscriptionRateLimitWindow( + label: "5-hour", + usedPercent: 42.4, + resetsAt: Date(timeIntervalSince1970: 100) + ), + ClaudeSubscriptionRateLimitWindow( + label: "Weekly", + usedPercent: 51.6, + resetsAt: Date(timeIntervalSince1970: 200) + ), + ] + ), + accountID: "local", + accountName: "Claude", + observedAt: Date(timeIntervalSince1970: 0) + ) + + #expect(limits.count == 2) + #expect(limits.map(\.provider) == [.anthropic, .anthropic]) + #expect(limits.map(\.windowLabel) == ["5-hour", "Weekly"]) + #expect(limits.map(\.modelLabel) == ["Claude Pro", "Claude Pro"]) + #expect(limits.map(\.used) == [42, 52]) + #expect(limits.allSatisfy { $0.unit == .percent && $0.limit == 100 && $0.confidence == .observed }) +} diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift index 1f1570d..e734488 100644 --- a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -92,7 +92,7 @@ import Testing accounts: [ClaudeAccountConfiguration(accountName: "Claude", claudeBinary: "claude", statsPath: "/tmp/stats.json")], processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), fileLoader: { _ in stats }, - fileExists: { _ in true } + fileExists: { path in path == "/tmp/stats.json" } ) let result = await connector.refresh(now: Date(timeIntervalSince1970: 0)) diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index 4402f22..69e7512 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -56,7 +56,7 @@ instead of pretending an estimate is exact. | OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | | OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | | Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | -| Claude subscriptions and Claude Code seats | Public docs describe usage limits across Claude.ai, Claude Code, and Claude Desktop, but no stable public API for personal subscription allowance was found. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. | Pro/Max/Team usage has session-based reset behavior; Claude Code Enterprise seats show reset time when a limit is reached. Local cache does not expose live remaining allowance. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata and local activity freshness. Defer live subscription pressure unless a supported signal is found. Prioritize Anthropic API first for org/API usage. | Medium for auth/subscription metadata and local history; low for live subscription automation | +| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, and optional status-line rate-limit cache. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL. | High for status-line subscription windows when configured; medium for auth/subscription metadata and local history | | Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | | Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | | Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | @@ -181,30 +181,39 @@ On 2026-05-06 it returned seven live model buckets for the local Gemini CLI account, including Gemini 2.5 and Gemini 3 preview models, with percent remaining and reset timestamps. -### Claude Local Status Connector +### Claude Subscription Connector + +Claude subscription pressure should use Claude Code's supported status-line JSON +surface, not Anthropic API organization usage. Claude Code's status-line input +can contain `rate_limits.five_hour.used_percentage`, +`rate_limits.five_hour.resets_at`, `rate_limits.seven_day.used_percentage`, and +`rate_limits.seven_day.resets_at` for Claude.ai Pro and Max subscribers after a +Claude Code session receives an API response. -Claude currently has a weaker local connector story for subscription pressure. The official Claude Code authentication docs say macOS credentials are stored in -the encrypted macOS Keychain. Context Panel should not read Keychain secrets or +the encrypted macOS Keychain. Context Panel must not read Keychain secrets or try to extract subscription OAuth tokens. Preferred v1 connector scope: - Call `claude auth status --json` and keep only non-secret fields such as `loggedIn`, `authMethod`, `apiProvider`, and `subscriptionType`. +- Offer a tiny status-line helper that receives Claude Code status-line JSON on + stdin and writes only observed timestamp, five-hour percentage/reset, and + weekly percentage/reset to a Context Panel cache file. +- Read that sanitized status-line cache and normalize Claude five-hour and + weekly windows as percent limits when present. - Read `~/.claude/stats-cache.json` only as local historical activity, not live subscription allowance. - Summarize local stats by freshness and counts; do not read raw transcript JSONL files, prompts, account UUIDs, emails, organization IDs, or token blobs. -- Show Claude subscription allowance as unknown unless a provider UI/runtime - state exposes a reset or limit banner that the user can confirm. -- Prefer Anthropic's official Admin Usage, Cost, and Rate Limits APIs when the - user connects an organization/API credential with sufficient permission. - -The local `ClaudeLimitProbe` executable proves the conservative path. On -2026-05-06 it confirmed the local Claude CLI is logged in with subscription -metadata and has a local stats cache, but no live subscription allowance is -exposed by the probe. +- Show Claude subscription allowance as unknown until the status-line cache has + been populated by a live Claude Code response. + +The local `ClaudeLimitProbe` executable proves the conservative fallback path. +On 2026-05-06 it confirmed the local Claude CLI is logged in with subscription +metadata and has a local stats cache. The follow-up subscription path is the +sanitized status-line cache, not raw Claude auth/session data. ### Every Code Cache Fallback diff --git a/scripts/claude-statusline-cache.sh b/scripts/claude-statusline-cache.sh new file mode 100755 index 0000000..03348be --- /dev/null +++ b/scripts/claude-statusline-cache.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +input="$(cat)" +out_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}" +out_file="$out_dir/statusline-cache.json" +tmp_file="${out_file}.tmp" + +mkdir -p "$out_dir" + +cache_json="$(jq -c --argjson observed_at "$(date +%s)" ' + def clean_window: + select(type == "object") + | { + used_percentage: (.used_percentage // empty), + resets_at: (.resets_at // null) + }; + + { + observed_at: $observed_at, + rate_limits: { + five_hour: (.rate_limits.five_hour? | clean_window), + seven_day: (.rate_limits.seven_day? | clean_window) + } + } + | .rate_limits |= with_entries(select(.value != null)) + | select((.rate_limits | length) > 0) +' <<<"$input")" + +if [ -n "$cache_json" ]; then + printf '%s\n' "$cache_json" >"$tmp_file" + mv "$tmp_file" "$out_file" +fi + +model="$(jq -r '.model.display_name // "Claude"' <<<"$input")" +five_hour="$(jq -r '.rate_limits.five_hour.used_percentage // empty' <<<"$input")" +weekly="$(jq -r '.rate_limits.seven_day.used_percentage // empty' <<<"$input")" + +limits="" +if [ -n "$five_hour" ]; then + limits="5h: $(printf '%.0f' "$five_hour")%" +fi +if [ -n "$weekly" ]; then + limits="${limits:+$limits }7d: $(printf '%.0f' "$weekly")%" +fi + +if [ -n "$limits" ]; then + printf '[%s] | %s\n' "$model" "$limits" +else + printf '[%s]\n' "$model" +fi From 0e0b32dd4a2058d80b5bcf90e2cfa15b9dc27483 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 15:50:14 -0400 Subject: [PATCH 26/34] Mark stale Claude subscription snapshots --- README.md | 4 +- .../ContextPanelCore/ClaudeLocalStatus.swift | 14 ++++-- .../ProviderConnectorTests.swift | 48 +++++++++++++++++++ docs/provider-usage-access.md | 2 +- 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index bb50e55..b4c166d 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ Support directory. ``` The helper does not store auth tokens, prompts, transcript contents, emails, -organization IDs, or raw Claude session JSON. +organization IDs, or raw Claude session JSON. Claude Code's non-interactive +`claude -p` path does not appear to run the status-line hook, so Context Panel +marks old Claude subscription readings stale instead of treating them as live. For Gemini, use the OAuth client values from the locally installed Gemini CLI; they are intentionally not checked into this repository. diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift index e14300f..db3ef12 100644 --- a/Sources/ContextPanelCore/ClaudeLocalStatus.swift +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -124,12 +124,14 @@ public struct ClaudeAccountConfiguration: Equatable, Sendable { public let claudeBinary: String public let statsPath: String public let rateLimitSnapshotPath: String + public let rateLimitSnapshotMaximumAge: TimeInterval public init( accountName: String = "Claude", claudeBinary: String = "claude", statsPath: String? = nil, - rateLimitSnapshotPath: String? = nil + rateLimitSnapshotPath: String? = nil, + rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60 ) { self.accountName = accountName self.claudeBinary = claudeBinary @@ -137,6 +139,7 @@ public struct ClaudeAccountConfiguration: Equatable, Sendable { self.statsPath = statsPath ?? "\(home)/.claude/stats-cache.json" self.rateLimitSnapshotPath = rateLimitSnapshotPath ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/statusline-cache.json" + self.rateLimitSnapshotMaximumAge = rateLimitSnapshotMaximumAge } } @@ -185,6 +188,7 @@ public struct ClaudeLocalStatusConnector: ProviderConnector { authStatus: authStatus, statsSummary: statsSummary, rateLimitSnapshot: rateLimitSnapshot, + rateLimitSnapshotMaximumAge: account.rateLimitSnapshotMaximumAge, accountID: localAccountID, accountName: account.accountName, observedAt: now @@ -195,7 +199,7 @@ public struct ClaudeLocalStatusConnector: ProviderConnector { accountName: account.accountName, generatedAt: now, limits: limits, - status: authStatus.loggedIn ? .unknown : .failure, + status: authStatus.loggedIn ? limits.map(\.status).contextPanelWorstStatus : .failure, errorMessage: authStatus.loggedIn ? nil : "Claude CLI is not logged in" ) } catch { @@ -234,11 +238,14 @@ public func claudeLocalStatusLimits( authStatus: ClaudeAuthStatus, statsSummary: ClaudeStatsCacheSummary?, rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil, + rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60, accountID: String, accountName: String, observedAt: Date ) -> [UsageLimit] { if authStatus.loggedIn, let rateLimitSnapshot, !rateLimitSnapshot.windows.isEmpty { + let isStale = observedAt.timeIntervalSince(rateLimitSnapshot.observedAt) > rateLimitSnapshotMaximumAge + let sourceNote = isStale ? "source: stale Claude Code statusline" : "source: Claude Code statusline" return rateLimitSnapshot.windows.map { window in UsageLimit( provider: .anthropic, @@ -253,7 +260,8 @@ public func claudeLocalStatusLimits( resetsAt: window.resetsAt, lastUpdatedAt: rateLimitSnapshot.observedAt, confidence: .observed, - note: "source: Claude Code statusline; subscription: \(authStatus.subscriptionType ?? "unknown")" + statusOverride: isStale ? .stale : nil, + note: "\(sourceNote); subscription: \(authStatus.subscriptionType ?? "unknown")" ) } } diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift index e734488..2b931a2 100644 --- a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -105,6 +105,54 @@ import Testing #expect(result.snapshot.limits[0].note?.contains("subscription: pro") == true) } +@Test func claudeConnectorReportsHealthyWhenStatuslineLimitsExist() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration( + accountName: "Claude", + claudeBinary: "claude", + statsPath: "/tmp/stats.json", + rateLimitSnapshotPath: "/tmp/claude-statusline.json", + rateLimitSnapshotMaximumAge: 60 + )], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats }, + fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_230)) + + #expect(result.reports[0].status == .healthy) + #expect(result.snapshot.limits.count == 2) + #expect(result.snapshot.limits.map(\.windowLabel) == ["5-hour", "Weekly"]) +} + +@Test func claudeConnectorMarksOldStatuslineLimitsStale() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let cache = #"{"observed_at":1788379200,"rate_limits":{"five_hour":{"used_percentage":4,"resets_at":1788397200},"seven_day":{"used_percentage":0,"resets_at":1788984000}}}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration( + accountName: "Claude", + claudeBinary: "claude", + statsPath: "/tmp/stats.json", + rateLimitSnapshotPath: "/tmp/claude-statusline.json", + rateLimitSnapshotMaximumAge: 60 + )], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { path in path == "/tmp/claude-statusline.json" ? cache : stats }, + fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/claude-statusline.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_788_379_500)) + + #expect(result.reports[0].status == .stale) + #expect(result.snapshot.limits.map(\.status) == [.stale, .stale]) + #expect(result.snapshot.limits[0].note?.contains("stale Claude Code statusline") == true) +} + @Test func providerConnectorRuntimeAggregatesConnectorSnapshots() async { let connectorA = StubConnector(provider: .openAI, report: ProviderConnectorReport( provider: .openAI, diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index 69e7512..906d0d1 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -56,7 +56,7 @@ instead of pretending an estimate is exact. | OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | | OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | | Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | -| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, and optional status-line rate-limit cache. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL. | High for status-line subscription windows when configured; medium for auth/subscription metadata and local history | +| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, and optional status-line rate-limit cache. Mark old status-line readings stale, especially when Claude is used through non-interactive tooling such as Every Code. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for auth/subscription metadata and local history | | Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | | Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | | Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | From 5a4d139305bd9aa5339f56a008abe238d30946a6 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 16:41:14 -0400 Subject: [PATCH 27/34] Add Every Code Claude usage estimate --- README.md | 4 + Sources/ClaudeLimitProbe/main.swift | 3 +- .../ContextPanelCore/ClaudeLocalStatus.swift | 151 +++++++++++++++++- .../ContextPanelCore/ProviderConnector.swift | 1 - .../ClaudeLocalStatusTests.swift | 65 ++++++++ .../ProviderConnectorTests.swift | 26 +++ docs/provider-usage-access.md | 2 +- scripts/claude-ccusage-cache.sh | 19 +++ 8 files changed, 267 insertions(+), 4 deletions(-) create mode 100755 scripts/claude-ccusage-cache.sh diff --git a/README.md b/README.md index b4c166d..6bb654c 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,10 @@ The helper does not store auth tokens, prompts, transcript contents, emails, organization IDs, or raw Claude session JSON. Claude Code's non-interactive `claude -p` path does not appear to run the status-line hook, so Context Panel marks old Claude subscription readings stale instead of treating them as live. +For Every Code-driven Claude usage, Context Panel also reads `ccusage` aggregate +block output when available and shows a clearly marked estimated 5-hour token +window; that estimate is useful for "am I likely to run out soon?" but is not +Anthropic's official subscription percentage. For Gemini, use the OAuth client values from the locally installed Gemini CLI; they are intentionally not checked into this repository. diff --git a/Sources/ClaudeLimitProbe/main.swift b/Sources/ClaudeLimitProbe/main.swift index ad0e971..b471d46 100644 --- a/Sources/ClaudeLimitProbe/main.swift +++ b/Sources/ClaudeLimitProbe/main.swift @@ -98,6 +98,7 @@ struct ClaudeLimitProbe { } } } - print("live subscription allowance: not exposed by this probe") + print("official non-interactive subscription percentage: not exposed by Claude Code") + print("Every Code estimate: shown when ccusage aggregate block data is available") } } diff --git a/Sources/ContextPanelCore/ClaudeLocalStatus.swift b/Sources/ContextPanelCore/ClaudeLocalStatus.swift index db3ef12..4d484ce 100644 --- a/Sources/ContextPanelCore/ClaudeLocalStatus.swift +++ b/Sources/ContextPanelCore/ClaudeLocalStatus.swift @@ -50,6 +50,55 @@ public struct ClaudeStatsCacheSummary: Codable, Equatable, Sendable { } } +public struct ClaudeUsageBlock: Codable, Equatable, Sendable { + public let isActive: Bool + public let totalTokens: Int? + public let endTime: Date? + public let projectedTotalTokens: Int? + public let remainingMinutes: Int? + public let modelCount: Int + + public init( + isActive: Bool, + totalTokens: Int?, + endTime: Date?, + projectedTotalTokens: Int?, + remainingMinutes: Int?, + modelCount: Int + ) { + self.isActive = isActive + self.totalTokens = totalTokens + self.endTime = endTime + self.projectedTotalTokens = projectedTotalTokens + self.remainingMinutes = remainingMinutes + self.modelCount = modelCount + } +} + +public struct ClaudeUsageBlocksSummary: Codable, Equatable, Sendable { + public let activeBlock: ClaudeUsageBlock? + public let completedBlockTokenLimitEstimate: Int? + + public init(activeBlock: ClaudeUsageBlock?, completedBlockTokenLimitEstimate: Int?) { + self.activeBlock = activeBlock + self.completedBlockTokenLimitEstimate = completedBlockTokenLimitEstimate + } +} + +public enum ClaudeUsageBlocksParser { + public static func summary(from data: Data) throws -> ClaudeUsageBlocksSummary { + let payload = try JSONDecoder.contextPanelFlexibleDates.decode(ClaudeUsageBlocksPayload.self, from: data) + let active = payload.blocks.first { $0.isActive }?.usageBlock + let completedTokens = payload.blocks + .filter { !$0.isActive } + .compactMap(\.totalTokens) + .filter { $0 > 0 } + .sorted() + let estimate = completedTokens.isEmpty ? nil : completedTokens[Int(Double(completedTokens.count - 1) * 0.95)] + return ClaudeUsageBlocksSummary(activeBlock: active, completedBlockTokenLimitEstimate: estimate) + } +} + public struct ClaudeSubscriptionRateLimitWindow: Codable, Equatable, Sendable { public let label: String public let usedPercent: Double @@ -125,13 +174,15 @@ public struct ClaudeAccountConfiguration: Equatable, Sendable { public let statsPath: String public let rateLimitSnapshotPath: String public let rateLimitSnapshotMaximumAge: TimeInterval + public let usageBlocksPath: String? public init( accountName: String = "Claude", claudeBinary: String = "claude", statsPath: String? = nil, rateLimitSnapshotPath: String? = nil, - rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60 + rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60, + usageBlocksPath: String? = nil ) { self.accountName = accountName self.claudeBinary = claudeBinary @@ -140,6 +191,8 @@ public struct ClaudeAccountConfiguration: Equatable, Sendable { self.rateLimitSnapshotPath = rateLimitSnapshotPath ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/statusline-cache.json" self.rateLimitSnapshotMaximumAge = rateLimitSnapshotMaximumAge + self.usageBlocksPath = usageBlocksPath + ?? "\(home)/Library/Application Support/Context Panel/ClaudeRateLimits/ccusage-blocks-cache.json" } } @@ -184,11 +237,13 @@ public struct ClaudeLocalStatusConnector: ProviderConnector { let statsSummary = try loadStatsSummary(path: account.statsPath) let rateLimitSnapshot = try loadRateLimitSnapshot(path: account.rateLimitSnapshotPath) ?? statsSummary?.rateLimitSnapshot + let usageBlocksSummary = try loadUsageBlocksSummary(path: account.usageBlocksPath) let limits = claudeLocalStatusLimits( authStatus: authStatus, statsSummary: statsSummary, rateLimitSnapshot: rateLimitSnapshot, rateLimitSnapshotMaximumAge: account.rateLimitSnapshotMaximumAge, + usageBlocksSummary: usageBlocksSummary, accountID: localAccountID, accountName: account.accountName, observedAt: now @@ -232,6 +287,11 @@ public struct ClaudeLocalStatusConnector: ProviderConnector { guard fileExists(path) else { return nil } return try ClaudeSubscriptionRateLimitCacheParser.snapshot(from: try fileLoader(path)) } + + private func loadUsageBlocksSummary(path: String?) throws -> ClaudeUsageBlocksSummary? { + guard let path, fileExists(path) else { return nil } + return try ClaudeUsageBlocksParser.summary(from: try fileLoader(path)) + } } public func claudeLocalStatusLimits( @@ -239,10 +299,21 @@ public func claudeLocalStatusLimits( statsSummary: ClaudeStatsCacheSummary?, rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot? = nil, rateLimitSnapshotMaximumAge: TimeInterval = 30 * 60, + usageBlocksSummary: ClaudeUsageBlocksSummary? = nil, accountID: String, accountName: String, observedAt: Date ) -> [UsageLimit] { + if let estimatedLimit = claudeUsageBlockEstimate( + usageBlocksSummary: usageBlocksSummary, + authStatus: authStatus, + accountID: accountID, + accountName: accountName, + observedAt: observedAt + ) { + return [estimatedLimit] + } + if authStatus.loggedIn, let rateLimitSnapshot, !rateLimitSnapshot.windows.isEmpty { let isStale = observedAt.timeIntervalSince(rateLimitSnapshot.observedAt) > rateLimitSnapshotMaximumAge let sourceNote = isStale ? "source: stale Claude Code statusline" : "source: Claude Code statusline" @@ -296,6 +367,56 @@ public func claudeLocalStatusLimits( )] } +private func claudeUsageBlockEstimate( + usageBlocksSummary: ClaudeUsageBlocksSummary?, + authStatus: ClaudeAuthStatus, + accountID: String, + accountName: String, + observedAt: Date +) -> UsageLimit? { + guard + authStatus.loggedIn, + let activeBlock = usageBlocksSummary?.activeBlock, + activeBlock.isActive, + let totalTokens = activeBlock.totalTokens, + totalTokens > 0 + else { return nil } + + let estimatedLimit = usageBlocksSummary?.completedBlockTokenLimitEstimate + ?? activeBlock.projectedTotalTokens + let used: Int? + let limit: Int? + if let estimatedLimit, estimatedLimit > 0 { + used = min(totalTokens, estimatedLimit) + limit = estimatedLimit + } else { + used = nil + limit = nil + } + + let resetsAt = activeBlock.remainingMinutes.map { + observedAt.addingTimeInterval(TimeInterval($0 * 60)) + } ?? activeBlock.endTime + let modelMode = activeBlock.modelCount > 1 ? "mixed models" : "single model" + + return UsageLimit( + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude 5-hour estimate", + windowLabel: "5-hour estimated", + modelLabel: authStatus.subscriptionDisplayName, + unit: .tokens, + used: used, + limit: limit, + resetsAt: resetsAt, + lastUpdatedAt: observedAt, + confidence: .estimated, + statusOverride: limit == nil ? .unknown : nil, + note: "source: ccusage local block estimate from Every Code/Claude sessions; \(modelMode); official subscription percentage unavailable in claude -p" + ) +} + private struct ClaudeStatuslineRateLimitPayload: Decodable { let observedAt: Int let rateLimits: ClaudeStatuslineRateLimits @@ -375,6 +496,34 @@ private struct ClaudeStatsCachePayload: Decodable { } } +private struct ClaudeUsageBlocksPayload: Decodable { + let blocks: [ClaudeUsageBlockPayload] +} + +private struct ClaudeUsageBlockPayload: Decodable { + let isActive: Bool + let totalTokens: Int? + let endTime: Date? + let projection: ClaudeUsageBlockProjection? + let models: [String]? + + var usageBlock: ClaudeUsageBlock { + ClaudeUsageBlock( + isActive: isActive, + totalTokens: totalTokens, + endTime: endTime, + projectedTotalTokens: projection?.totalTokens, + remainingMinutes: projection?.remainingMinutes, + modelCount: models?.filter { $0 != "" }.count ?? 0 + ) + } +} + +private struct ClaudeUsageBlockProjection: Decodable { + let totalTokens: Int? + let remainingMinutes: Int? +} + private enum ClaudeCountedCollection: Decodable { case dictionary([String: ClaudeDiscardedValue]) case array([ClaudeDiscardedValue]) diff --git a/Sources/ContextPanelCore/ProviderConnector.swift b/Sources/ContextPanelCore/ProviderConnector.swift index d1f9045..b8b8978 100644 --- a/Sources/ContextPanelCore/ProviderConnector.swift +++ b/Sources/ContextPanelCore/ProviderConnector.swift @@ -196,4 +196,3 @@ public enum ConnectorRedactor { return String(hash, radix: 16) } } - diff --git a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift index c17a643..9869c89 100644 --- a/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift +++ b/Tests/ContextPanelCoreTests/ClaudeLocalStatusTests.swift @@ -96,6 +96,32 @@ import Testing #expect(summary.rateLimitSnapshot?.windows.map { Int($0.usedPercent.rounded()) } == [42, 52]) } +@Test func claudeUsageBlocksParserSummarizesActiveBlockAndLimitEstimate() throws { + let json = #""" + { + "blocks": [ + { "isActive": false, "totalTokens": 1000 }, + { "isActive": false, "totalTokens": 2000 }, + { + "isActive": true, + "totalTokens": 500, + "endTime": "2026-05-06T23:00:00Z", + "projection": { "totalTokens": 1200, "remainingMinutes": 45 }, + "models": ["claude-sonnet-4-6", ""] + } + ] + } + """# + + let summary = try ClaudeUsageBlocksParser.summary(from: Data(json.utf8)) + + #expect(summary.activeBlock?.totalTokens == 500) + #expect(summary.activeBlock?.projectedTotalTokens == 1200) + #expect(summary.activeBlock?.remainingMinutes == 45) + #expect(summary.activeBlock?.modelCount == 1) + #expect(summary.completedBlockTokenLimitEstimate == 1000) +} + @Test func claudeLocalStatusLimitMakesUnknownAllowanceExplicit() { let limit = claudeLocalStatusLimits( authStatus: ClaudeAuthStatus( @@ -177,3 +203,42 @@ import Testing #expect(limits.map(\.used) == [42, 52]) #expect(limits.allSatisfy { $0.unit == .percent && $0.limit == 100 && $0.confidence == .observed }) } + +@Test func claudeLocalStatusLimitsUsesEveryCodeEstimateWhenAvailable() { + let limits = claudeLocalStatusLimits( + authStatus: ClaudeAuthStatus( + loggedIn: true, + authMethod: "claude.ai", + apiProvider: "firstParty", + subscriptionType: "pro" + ), + statsSummary: nil, + rateLimitSnapshot: ClaudeSubscriptionRateLimitSnapshot( + observedAt: Date(timeIntervalSince1970: 10), + windows: [ClaudeSubscriptionRateLimitWindow(label: "5-hour", usedPercent: 4, resetsAt: nil)] + ), + usageBlocksSummary: ClaudeUsageBlocksSummary( + activeBlock: ClaudeUsageBlock( + isActive: true, + totalTokens: 500, + endTime: Date(timeIntervalSince1970: 9_000), + projectedTotalTokens: 1_200, + remainingMinutes: 45, + modelCount: 2 + ), + completedBlockTokenLimitEstimate: 1_000 + ), + accountID: "local", + accountName: "Claude", + observedAt: Date(timeIntervalSince1970: 1_000) + ) + + #expect(limits.count == 1) + #expect(limits[0].label == "Claude 5-hour estimate") + #expect(limits[0].unit == .tokens) + #expect(limits[0].used == 500) + #expect(limits[0].limit == 1_000) + #expect(limits[0].confidence == .estimated) + #expect(limits[0].resetsAt == Date(timeIntervalSince1970: 3_700)) + #expect(limits[0].note?.contains("official subscription percentage unavailable") == true) +} diff --git a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift index 2b931a2..566fc71 100644 --- a/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift +++ b/Tests/ContextPanelCoreTests/ProviderConnectorTests.swift @@ -153,6 +153,32 @@ import Testing #expect(result.snapshot.limits[0].note?.contains("stale Claude Code statusline") == true) } +@Test func claudeConnectorReportsEveryCodeUsageEstimate() async throws { + let auth = #"{"loggedIn":true,"authMethod":"claude.ai","apiProvider":"firstParty","subscriptionType":"pro"}"#.data(using: .utf8)! + let stats = #"{"version":3,"lastComputedDate":"2026-04-26","dailyActivity":[],"modelUsage":{},"totalSessions":2,"totalMessages":3}"#.data(using: .utf8)! + let blocks = #"{"blocks":[{"isActive":false,"totalTokens":1000},{"isActive":true,"totalTokens":500,"projection":{"totalTokens":1200,"remainingMinutes":30},"models":["claude-sonnet-4-6"]}]}"#.data(using: .utf8)! + let connector = ClaudeLocalStatusConnector( + accounts: [ClaudeAccountConfiguration( + accountName: "Claude", + claudeBinary: "claude", + statsPath: "/tmp/stats.json", + usageBlocksPath: "/tmp/ccusage-blocks.json" + )], + processClient: StubProcessClient(result: ConnectorProcessResult(exitCode: 0, stdout: auth)), + fileLoader: { path in path == "/tmp/ccusage-blocks.json" ? blocks : stats }, + fileExists: { path in path == "/tmp/stats.json" || path == "/tmp/ccusage-blocks.json" } + ) + + let result = await connector.refresh(now: Date(timeIntervalSince1970: 1_000)) + + #expect(result.reports[0].status == .healthy) + #expect(result.snapshot.limits.count == 1) + #expect(result.snapshot.limits[0].confidence == .estimated) + #expect(result.snapshot.limits[0].used == 500) + #expect(result.snapshot.limits[0].limit == 1000) + #expect(result.snapshot.limits[0].note?.contains("Every Code/Claude sessions") == true) +} + @Test func providerConnectorRuntimeAggregatesConnectorSnapshots() async { let connectorA = StubConnector(provider: .openAI, report: ProviderConnectorReport( provider: .openAI, diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index 906d0d1..dbbc55d 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -56,7 +56,7 @@ instead of pretending an estimate is exact. | OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | | OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | | Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | -| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, and optional status-line rate-limit cache. Mark old status-line readings stale, especially when Claude is used through non-interactive tooling such as Every Code. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for auth/subscription metadata and local history | +| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. `ccusage blocks --json --offline` can derive active 5-hour block token use and runway from local aggregate session data used by Every Code. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. `ccusage` estimates token pressure and reset/runway rather than official server percent. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, optional status-line rate-limit cache, and an explicit Every Code-compatible `ccusage` estimate. Mark old status-line readings stale, and label `ccusage` rows as estimated. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL directly. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for `ccusage` estimated runway; medium for auth/subscription metadata and local history | | Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | | Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | | Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | diff --git a/scripts/claude-ccusage-cache.sh b/scripts/claude-ccusage-cache.sh new file mode 100755 index 0000000..1b85566 --- /dev/null +++ b/scripts/claude-ccusage-cache.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +cache_dir="${CONTEXT_PANEL_CLAUDE_RATE_LIMIT_DIR:-$HOME/Library/Application Support/Context Panel/ClaudeRateLimits}" +cache_file="$cache_dir/ccusage-blocks-cache.json" + +mkdir -p "$cache_dir" + +if command -v ccusage >/dev/null 2>&1; then + ccusage blocks --json --offline >"$cache_file.tmp" +elif command -v bunx >/dev/null 2>&1; then + bunx ccusage@latest blocks --json --offline >"$cache_file.tmp" +else + echo "ccusage or bunx is required" >&2 + exit 127 +fi + +mv "$cache_file.tmp" "$cache_file" +echo "$cache_file" From 90ac709c16c52cd3d716ce8d716be9c7c353f0e8 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 18:28:10 -0400 Subject: [PATCH 28/34] Add Claude web usage parser --- Sources/ContextPanelCore/ClaudeWebUsage.swift | 140 ++++++++++++++++++ .../ClaudeWebUsageTests.swift | 62 ++++++++ docs/provider-usage-access.md | 58 ++++++-- 3 files changed, 250 insertions(+), 10 deletions(-) create mode 100644 Sources/ContextPanelCore/ClaudeWebUsage.swift create mode 100644 Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift diff --git a/Sources/ContextPanelCore/ClaudeWebUsage.swift b/Sources/ContextPanelCore/ClaudeWebUsage.swift new file mode 100644 index 0000000..6a46f04 --- /dev/null +++ b/Sources/ContextPanelCore/ClaudeWebUsage.swift @@ -0,0 +1,140 @@ +import Foundation + +public enum ClaudeWebUsageParser { + public static func usageLimits( + from data: Data, + accountID: String, + accountName: String, + observedAt: Date + ) throws -> [UsageLimit] { + let payload = try JSONSerialization.jsonObject(with: data) + guard let root = payload as? [String: Any] else { return [] } + + let windows: [(key: String, label: String, model: String?)] = [ + ("five_hour", "5-hour", nil), + ("seven_day", "7-day", nil), + ("seven_day_opus", "7-day", "Opus"), + ("seven_day_sonnet", "7-day", "Sonnet"), + ("seven_day_oauth_apps", "7-day", "OAuth apps"), + ] + + return windows.compactMap { window in + guard let object = findObject(named: window.key, in: root) else { return nil } + let usedPercentage = percentValue(for: ["used_percentage", "utilization"], in: object) + let remainingPercentage = percentValue(for: ["remaining_percentage"], in: object) + let used = usedPercentage ?? remainingPercentage.map { max(0, 100 - $0) } + guard let used else { return nil } + + let roundedUsed = min(max(Int(used.rounded()), 0), 100) + return UsageLimit( + id: "anthropic:\(accountID):claude-web:\(window.key)", + provider: .anthropic, + accountID: accountID, + accountName: accountName, + label: "Claude \(window.label)", + windowLabel: window.label, + modelLabel: window.model ?? "Claude subscription", + unit: .percent, + used: roundedUsed, + limit: 100, + resetsAt: resetDate(in: object), + lastUpdatedAt: observedAt, + confidence: .observed, + note: "source: Claude web usage endpoint; authenticated web session required" + ) + } + } + + public static func sanitizedUsageFields(from data: Data) throws -> [String] { + let payload = try JSONSerialization.jsonObject(with: data) + return Array(collectUsageFields(payload).sorted()) + } + + private static func findObject(named key: String, in value: Any) -> [String: Any]? { + if let dictionary = value as? [String: Any] { + if let found = dictionary[key] as? [String: Any] { + return found + } + for child in dictionary.values { + if let found = findObject(named: key, in: child) { + return found + } + } + } else if let array = value as? [Any] { + for child in array { + if let found = findObject(named: key, in: child) { + return found + } + } + } + return nil + } + + private static func percentValue(for keys: [String], in object: [String: Any]) -> Double? { + for key in keys { + guard let raw = numericValue(object[key]) else { continue } + return raw <= 1 ? raw * 100 : raw + } + return nil + } + + private static func resetDate(in object: [String: Any]) -> Date? { + guard let raw = object["resets_at"] ?? object["reset_at"] else { return nil } + if let number = numericValue(raw) { + return Date(timeIntervalSince1970: number > 10_000_000_000 ? number / 1000 : number) + } + if let string = raw as? String { + return ISO8601DateFormatter().date(from: string) + } + return nil + } + + private static func numericValue(_ value: Any?) -> Double? { + switch value { + case let value as Double: + value + case let value as Int: + Double(value) + case let value as NSNumber: + value.doubleValue + case let value as String: + Double(value) + default: + nil + } + } + + private static func collectUsageFields(_ value: Any, prefix: String = "", output: Set = []) -> Set { + var output = output + let allowed = [ + "five_hour", + "seven_day", + "seven_day_opus", + "seven_day_sonnet", + "seven_day_oauth_apps", + "used_percentage", + "remaining_percentage", + "utilization", + "resets_at", + "reset_at", + "rate_limits", + "usage", + ] + + if let dictionary = value as? [String: Any] { + for (key, child) in dictionary { + let path = prefix.isEmpty ? key : "\(prefix).\(key)" + if allowed.contains(key) { + output.insert(path) + } + output = collectUsageFields(child, prefix: path, output: output) + } + } else if let array = value as? [Any] { + for child in array.prefix(3) { + output = collectUsageFields(child, prefix: prefix, output: output) + } + } + + return output + } +} diff --git a/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift new file mode 100644 index 0000000..3e90790 --- /dev/null +++ b/Tests/ContextPanelCoreTests/ClaudeWebUsageTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing + +@testable import ContextPanelCore + +@Test func claudeWebUsageParserBuildsSubscriptionPercentWindows() throws { + let payload = #""" + { + "rate_limits": { + "five_hour": { + "used_percentage": 42.4, + "resets_at": 1778109600 + }, + "seven_day": { + "remaining_percentage": 30, + "resets_at": "2026-05-08T01:00:00Z" + }, + "seven_day_opus": { + "utilization": 0.91, + "resets_at": 1778192400000 + } + }, + "account_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38" + } + """#.data(using: .utf8)! + + let limits = try ClaudeWebUsageParser.usageLimits( + from: payload, + accountID: "claude-local", + accountName: "Claude Max", + observedAt: Date(timeIntervalSince1970: 1) + ) + + #expect(limits.count == 3) + #expect(limits[0].label == "Claude 5-hour") + #expect(limits[0].windowLabel == "5-hour") + #expect(limits[0].used == 42) + #expect(limits[0].confidence == .observed) + #expect(limits[1].used == 70) + #expect(limits[2].modelLabel == "Opus") + #expect(limits[2].used == 91) +} + +@Test func claudeWebUsageSanitizerReturnsOnlyAllowedUsageFields() throws { + let payload = #""" + { + "rate_limits": { + "five_hour": { "used_percentage": 12, "resets_at": 1778109600 } + }, + "email": "chris@example.com", + "organization_uuid": "1e13c5e0-a592-428d-a051-9fe5d6260e38" + } + """#.data(using: .utf8)! + + let fields = try ClaudeWebUsageParser.sanitizedUsageFields(from: payload) + + #expect(fields.contains("rate_limits")) + #expect(fields.contains("rate_limits.five_hour")) + #expect(fields.contains("rate_limits.five_hour.used_percentage")) + #expect(!fields.contains { $0.localizedCaseInsensitiveContains("email") }) + #expect(!fields.contains { $0.localizedCaseInsensitiveContains("uuid") }) +} diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index dbbc55d..fd819c0 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -51,15 +51,15 @@ instead of pretending an estimate is exact. ## Provider Matrix -| Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence | -| --- | --- | --- | --- | --- | --- | -| OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | -| OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | -| Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | -| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. `ccusage blocks --json --offline` can derive active 5-hour block token use and runway from local aggregate session data used by Every Code. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. `ccusage` estimates token pressure and reset/runway rather than official server percent. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, optional status-line rate-limit cache, and an explicit Every Code-compatible `ccusage` estimate. Mark old status-line readings stale, and label `ccusage` rows as estimated. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL directly. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for `ccusage` estimated runway; medium for auth/subscription metadata and local history | -| Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | -| Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | -| Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | +| Provider surface | Official data available | Reset/limit signal | Multi-login shape | V1 recommendation | Confidence | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI API organizations | Usage API, Costs API, and rate-limit headers. Usage can be grouped by project, user, API key, model, batch, and service tier depending on endpoint. | API rate limits expose remaining requests/tokens and reset headers; monthly usage limits are organization/project concerns. | One connected API organization/project per credential. Multiple credentials/accounts should be supported. | Support API org usage as an official adapter using admin or sufficiently privileged API keys. | High | +| OpenAI ChatGPT accounts | No stable public API found for general personal ChatGPT subscription pressure outside Codex. Current product surfaces no longer present a simple message counter; the useful automated signal found so far is percent-used pressure for Codex/Fast Mode. | Weekly and short rolling reset windows matter. Codex/Fast Mode exposes live percent-used windows through the Codex backend usage endpoint. | Multiple ChatGPT accounts are core. Each account needs its own reset window, plan, mode, percent/token pressure, and local observation history. | For Codex/Fast Mode, use the live Codex usage endpoint. For non-Codex ChatGPT surfaces, keep manual/assisted observations and forecast confidence until a clean provider signal exists. | High for Codex percent windows; medium for visible reset clues; low for non-Codex automation | +| Anthropic API organizations | Usage and Cost API can report message usage and costs by time bucket, model, workspace, API key, service tier, context window, geo, and beta fast-mode speed. API responses include rate-limit headers with remaining and reset values. | API rate limits use token bucket behavior; monthly spend limits exist by tier. | Organization/workspace/API-key credentials. Multiple organizations and workspaces should be supported. | Support official API usage/cost adapter. Capture fast-mode dimensions where available. | High | +| Claude subscriptions and Claude Code seats | Claude Code status-line JSON can include `rate_limits.five_hour` and `rate_limits.seven_day` for Claude.ai Pro and Max subscribers after a session receives an API response. Claude Code auth status exposes login method and subscription type; local stats cache exposes historical local usage only. Non-interactive `claude -p --output-format stream-json --verbose` can emit `rate_limit_info` with status, active window type, and reset time, but no used percentage was observed. `ccusage blocks --json --offline` can derive active 5-hour block token use and runway from local aggregate session data used by Every Code. | Status-line rate-limit windows include used percentage and reset epochs. Pro/Max/Team usage has session-based reset behavior. `ccusage` estimates token pressure and reset/runway rather than official server percent. | Multiple Claude accounts/seats are possible, but account connection should be conservative. | Support a local Claude status connector for account metadata, local activity freshness, optional status-line rate-limit cache, and an explicit Every Code-compatible `ccusage` estimate. Mark old status-line readings stale, and label `ccusage` rows as estimated. Do not read auth, Keychain, raw transcripts, prompts, or conversation JSONL directly. | High for fresh status-line subscription windows when configured; medium for non-interactive reset/status metadata; medium for `ccusage` estimated runway; medium for auth/subscription metadata and local history | +| Google Gemini CLI / Code Assist | Gemini CLI OAuth credentials can be refreshed locally, then the Code Assist backend returns live quota buckets with model IDs, remaining fractions, optional remaining amounts, and reset times. | Quota buckets are percent-style remaining fractions per model with provider reset timestamps. | Google account plus Code Assist project is the natural boundary; multiple `GEMINI_CLI_HOME` roots can represent multiple logins. | Support a Gemini Code Assist live quota connector using Gemini CLI auth. Store only normalized percent pressure and reset times. | High for Gemini CLI/Code Assist buckets observed locally | +| Google Gemini API / Google AI Studio projects | AI Studio and Cloud Billing show usage. Gemini API rate limits are project-scoped, not API-key-scoped. Service Usage API lists quota limits; Cloud Monitoring exposes quota usage metrics; Cloud Billing export to BigQuery provides detailed cost/usage data. | Rate limits are RPM, input TPM, and RPD, with model/tier variation. RPD quotas reset at midnight Pacific time. | Google project is the natural account boundary. Multiple Google accounts/projects should be supported. | Support Google API projects after OAuth/service-account design. Use Service Usage for limits, Cloud Monitoring for quota usage, and optional Billing export for cost history. | Medium-high, but setup is heavier | +| Google consumer Gemini app subscriptions | No stable public API for personal Gemini app subscription allowance was found in this pass. | Provider UI likely remains source of truth. | Multiple Google accounts may matter, but automation risk is high. | Defer for v1 unless a supported API emerges. | Low | ## OpenAI Fast-Mode Forecasting @@ -172,7 +172,7 @@ Preferred v1 connector scope: - Call the Gemini Code Assist quota path and normalize buckets by model ID, remaining fraction, optional remaining amount, and reset time. - Represent each bucket as percent pressure: `used = round((1 - remaining) * - 100)`, `limit = 100`, `unit = percent`. +100)`, `limit = 100`, `unit = percent`. - Mark confidence as observed because this is a product backend surface rather than a public quota API contract. @@ -190,6 +190,42 @@ can contain `rate_limits.five_hour.used_percentage`, `rate_limits.seven_day.resets_at` for Claude.ai Pro and Max subscribers after a Claude Code session receives an API response. +This status-line surface is currently interactive-session scoped. On +2026-05-06, a local non-interactive probe using `claude -p "Reply with exactly +OK." --output-format json --verbose` emitted a `rate_limit_event` with +`rate_limit_info.status`, `rateLimitType`, and `resetsAt`, but did not emit +`used_percentage` and did not refresh the configured status-line cache. A local +`claude -p "/usage" --output-format json --verbose` probe returned only that the +Claude Code subscription was in use, not the five-hour or weekly usage +percentages. This means Every Code's current external `claude -p` agent path +does not by itself provide official subscription percent pressure. + +Local binary/bundle inspection found runtime strings for +`anthropic-ratelimit-unified-*`, `utilization`, `five_hour`, and `seven_day` in +Claude Code/Desktop surfaces. It also found the Claude web/desktop usage hook: +the installed Claude app calls +`GET /api/organizations/{active_organization_uuid}/usage` and refreshes it on a +five-minute interval from the settings usage page. The usage page component is +what renders "Claude subscription usage", current-session/five-hour usage, and +weekly limit rows. + +That endpoint is the strongest direct subscription API candidate found so far, +but it is authenticated through the Claude web/app session. A local automated +browser probe against `https://claude.ai/settings/usage` on 2026-05-06 was +blocked by Cloudflare before login/session reuse, so Context Panel has not yet +proven it can call this endpoint without a user-visible web login context. We +should not extract browser cookies, Keychain credentials, OAuth tokens, local +storage, raw response bodies, transcripts, account UUIDs, or emails to force the +call. The next safe implementation path is a Claude web usage probe that runs in +a user-visible embedded web session and records only sanitized fields such as +`five_hour`, `seven_day`, `used_percentage`, `remaining_percentage`, +`utilization`, and `resets_at`. + +No safe persisted local Claude Desktop file/cache containing official +subscription percentages was found. The remaining research target is an +explicit, privacy-safe metadata capture path for Every Code/non-interactive +usage, tracked separately in issue #19. + The official Claude Code authentication docs say macOS credentials are stored in the encrypted macOS Keychain. Context Panel must not read Keychain secrets or try to extract subscription OAuth tokens. @@ -209,6 +245,8 @@ Preferred v1 connector scope: JSONL files, prompts, account UUIDs, emails, organization IDs, or token blobs. - Show Claude subscription allowance as unknown until the status-line cache has been populated by a live Claude Code response. +- Treat `ccusage` and local token aggregates as estimated pressure only; never + present them as official Claude subscription percent used. The local `ClaudeLimitProbe` executable proves the conservative fallback path. On 2026-05-06 it confirmed the local Claude CLI is logged in with subscription From 854c2eb73630232ddbd4af33c78fec4ad5d9d343 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 19:01:21 -0400 Subject: [PATCH 29/34] Add Claude web usage probe --- Package.swift | 8 + .../ClaudeWebUsageProbeApp.swift | 585 ++++++++++++++++++ docs/provider-usage-access.md | 9 + 3 files changed, 602 insertions(+) create mode 100644 Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift diff --git a/Package.swift b/Package.swift index 4682dd6..38c6cff 100644 --- a/Package.swift +++ b/Package.swift @@ -32,6 +32,10 @@ let package = Package( name: "ClaudeLimitProbe", targets: ["ClaudeLimitProbe"] ), + .executable( + name: "ClaudeWebUsageProbe", + targets: ["ClaudeWebUsageProbe"] + ), .executable( name: "SnapshotStoreProbe", targets: ["SnapshotStoreProbe"] @@ -63,6 +67,10 @@ let package = Package( name: "ClaudeLimitProbe", dependencies: ["ContextPanelCore"] ), + .executableTarget( + name: "ClaudeWebUsageProbe", + dependencies: ["ContextPanelCore"] + ), .executableTarget( name: "SnapshotStoreProbe", dependencies: ["ContextPanelCore"] diff --git a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift new file mode 100644 index 0000000..fdf5650 --- /dev/null +++ b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift @@ -0,0 +1,585 @@ +import ContextPanelCore +import AppKit +import SwiftUI +import UniformTypeIdentifiers +import WebKit + +@main +struct ClaudeWebUsageProbeApp: App { + init() { + NSApplication.shared.setActivationPolicy(.regular) + DispatchQueue.main.async { + NSApplication.shared.activate(ignoringOtherApps: true) + } + } + + var body: some Scene { + WindowGroup { + ClaudeUsageProbeRootView() + .frame(minWidth: 1180, minHeight: 760) + } + } +} + +struct ClaudeUsageProbeRootView: View { + @StateObject private var model = ClaudeUsageProbeModel() + + var body: some View { + HStack(spacing: 0) { + sidebar + .frame(width: 390) + .padding(18) + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + ClaudeProbeWebView(model: model) + } + .fileExporter( + isPresented: $model.isExportingReport, + document: ProbeReportDocument(report: model.reportMarkdown), + contentType: .plainText, + defaultFilename: "claude-web-usage-probe-report.md" + ) { _ in } + } + + private var sidebar: some View { + VStack(alignment: .leading, spacing: 14) { + header + controls + status + Divider() + capturedLimits + sanitizedFields + Spacer() + safetyFooter + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Claude Usage Probe") + .font(.system(size: 24, weight: .semibold)) + Text("Log in to Claude in this window, open Usage, then capture official subscription windows from the authenticated page context.") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + } + + private var controls: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Button("Open Usage") { model.openUsagePage() } + Button("Reload") { model.reload() } + Button("Export") { model.exportReport() } + } + + HStack { + Button("Save Snapshot") { model.saveSnapshot() } + .disabled(model.limits.isEmpty) + Button("Clear") { model.clear() } + } + } + } + + private var status: some View { + VStack(alignment: .leading, spacing: 6) { + Label(model.statusText, systemImage: model.statusIcon) + .font(.system(size: 12, weight: .medium)) + Text(model.currentURLText) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(2) + } + .foregroundStyle(model.hasCapturedUsage ? .primary : .secondary) + } + + private var capturedLimits: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Captured windows") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(model.limits.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if model.limits.isEmpty { + ContentUnavailableView( + "No usage windows yet", + systemImage: "gauge.with.dots.needle.67percent", + description: Text("Complete Claude login or verification, then wait for the Usage page to load.") + ) + .frame(maxHeight: 220) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + ForEach(model.limits) { limit in + ClaudeUsageLimitRow(limit: limit) + } + } + } + .frame(maxHeight: 280) + } + } + } + + private var sanitizedFields: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Sanitized fields") + .font(.system(size: 12, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(.secondary) + Spacer() + Text("\(model.fieldPaths.count)") + .font(.system(.caption, design: .monospaced, weight: .medium)) + .foregroundStyle(.secondary) + } + + if model.fieldPaths.isEmpty { + Text("No Claude usage response fields captured yet.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(model.fieldPaths.prefix(18), id: \.self) { field in + Text(field) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 160) + } + } + } + + private var safetyFooter: some View { + VStack(alignment: .leading, spacing: 6) { + Label("Login stays inside the visible web session.", systemImage: "person.crop.circle.badge.checkmark") + Label("Only percent windows, reset times, and field paths leave the page.", systemImage: "lock.shield") + Label("No cookies, auth headers, tokens, local storage, emails, org IDs, or raw bodies are stored.", systemImage: "eye.slash") + } + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } +} + +struct ClaudeUsageLimitRow: View { + let limit: UsageLimit + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(limit.displayLabel) + .font(.system(size: 13, weight: .semibold)) + Text(limit.contextLabel) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Spacer() + Text(percentText) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + } + + ProgressView(value: limit.usageRatio ?? 0) + .tint(tint) + + Text(resetText) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(.tertiary) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + + private var percentText: String { + guard let ratio = limit.usageRatio else { return "?" } + return "\(Int((ratio * 100).rounded()))%" + } + + private var resetText: String { + guard let resetsAt = limit.resetsAt else { return "reset unknown" } + return "resets " + resetsAt.formatted(date: .abbreviated, time: .shortened) + } + + private var tint: Color { + switch limit.status { + case .limited: + .red + case .close: + .orange + case .healthy: + .green + default: + .secondary + } + } +} + +@MainActor +final class ClaudeUsageProbeModel: ObservableObject { + @Published var limits: [UsageLimit] = [] + @Published var fieldPaths: [String] = [] + @Published var statusText = "Waiting for Claude usage response" + @Published var statusIcon = "clock" + @Published var currentURLText = "" + @Published var isExportingReport = false + + private let snapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory()) + + private lazy var navigationDelegate = ClaudeUsageNavigationDelegate(owner: self) + + lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.userContentController.add(ClaudeUsageScriptHandler(owner: self), name: "claudeUsageProbe") + configuration.userContentController.addUserScript( + WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = navigationDelegate + return view + }() + + init() { + openUsagePage() + } + + var hasCapturedUsage: Bool { !limits.isEmpty } + + var reportMarkdown: String { + var lines = [ + "# Claude Web Usage Probe Report", + "", + "- Captured: \(Date().ISO8601Format())", + "- Windows: \(limits.count)", + "- Sanitized fields: \(fieldPaths.count)", + "", + "## Captured Windows", + ] + + if limits.isEmpty { + lines.append("- No usage windows captured.") + } else { + for limit in limits { + let percent = limit.used.map { "\($0)%" } ?? "unknown" + let reset = limit.resetsAt?.ISO8601Format() ?? "unknown reset" + lines.append("- \(limit.displayLabel) / \(limit.contextLabel): \(percent), resets \(reset)") + } + } + + lines.append(contentsOf: [ + "", + "## Sanitized Fields", + ]) + + if fieldPaths.isEmpty { + lines.append("- No field paths captured.") + } else { + lines.append(contentsOf: fieldPaths.map { "- `\($0)`" }) + } + + lines.append(contentsOf: [ + "", + "## Redactions", + "- cookies", + "- authorization headers", + "- bearer tokens", + "- Keychain credentials", + "- OAuth tokens", + "- local storage", + "- account and organization identifiers", + "- emails", + "- raw response bodies", + "- transcripts and prompt/response content", + ]) + + return lines.joined(separator: "\n") + } + + func openUsagePage() { + load("https://claude.ai/settings/usage") + } + + func reload() { + statusText = "Reloading Claude usage page" + statusIcon = "arrow.clockwise" + webView.reload() + } + + func clear() { + limits = [] + fieldPaths = [] + statusText = "Waiting for Claude usage response" + statusIcon = "clock" + } + + func exportReport() { + isExportingReport = true + } + + func saveSnapshot() { + guard !limits.isEmpty else { return } + do { + try saveCurrentSnapshot() + statusText = "Saved sanitized Claude usage snapshot" + statusIcon = "checkmark.circle" + } catch { + statusText = "Save failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + fileprivate func record(payload: [String: Any]) { + let windows = payload["windows"] as? [String: Any] ?? [:] + let fields = payload["fields"] as? [String] ?? [] + let wrapped = ["rate_limits": windows] + + do { + let data = try JSONSerialization.data(withJSONObject: wrapped) + let parsedLimits = try ClaudeWebUsageParser.usageLimits( + from: data, + accountID: "claude-web", + accountName: "Claude Web", + observedAt: Date() + ) + guard !parsedLimits.isEmpty else { + statusText = "Usage response found, but no percent windows were present" + statusIcon = "questionmark.circle" + return + } + + limits = parsedLimits + fieldPaths = Array(Set(fields)).sorted() + saveSnapshotAfterCapture() + } catch { + statusText = "Capture failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + private func saveSnapshotAfterCapture() { + do { + try saveCurrentSnapshot() + statusText = "Captured and saved Claude subscription usage" + statusIcon = "checkmark.circle.fill" + } catch { + statusText = "Captured Claude usage; save failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + private func saveCurrentSnapshot() throws { + let report = ProviderConnectorReport( + provider: .anthropic, + accountID: "claude-web", + accountName: "Claude Web", + generatedAt: Date(), + limits: limits, + status: .healthy + ) + try snapshotStore.save( + StoredUsageSnapshot( + savedAt: Date(), + refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]) + ) + ) + } + + fileprivate func updateCurrentURL(_ url: URL?) { + currentURLText = url?.absoluteString ?? "" + if let host = url?.host, host.contains("claude.ai"), limits.isEmpty { + statusText = "Claude page loaded; waiting for usage API" + statusIcon = "network" + } + } + + private func load(_ rawURL: String) { + guard let url = URL(string: rawURL) else { return } + statusText = "Opening Claude usage page" + statusIcon = "safari" + webView.load(URLRequest(url: url)) + } + + private static let networkProbeScript = #""" + (() => { + if (window.__contextPanelClaudeUsageProbeInstalled) return; + window.__contextPanelClaudeUsageProbeInstalled = true; + + const windowKeys = new Set([ + 'five_hour', + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps' + ]); + const fieldKeys = new Set([ + 'used_percentage', + 'remaining_percentage', + 'utilization', + 'resets_at', + 'reset_at' + ]); + + function isUsageURL(rawUrl) { + try { + const url = new URL(rawUrl, window.location.href); + return /^\/api\/organizations\/[^/]+\/usage$/.test(url.pathname); + } catch (_) { + return false; + } + } + + function sanitizeWindow(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const sanitized = {}; + for (const key of fieldKeys) { + const raw = value[key]; + if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw; + } + return Object.keys(sanitized).length ? sanitized : null; + } + + function collectWindows(value, out = {}) { + if (!value || typeof value !== 'object') return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectWindows(item, out)); + return out; + } + for (const [key, child] of Object.entries(value)) { + if (windowKeys.has(key)) { + const sanitized = sanitizeWindow(child); + if (sanitized) out[key] = sanitized; + } + collectWindows(child, out); + } + return out; + } + + function collectFields(value, prefix = '', out = new Set()) { + if (!value || typeof value !== 'object' || out.size > 80) return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectFields(item, prefix, out)); + return out; + } + for (const [key, child] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (windowKeys.has(key) || fieldKeys.has(key) || key === 'rate_limits' || key === 'usage') out.add(path); + collectFields(child, path, out); + } + return out; + } + + function post(payload) { + try { window.webkit.messageHandlers.claudeUsageProbe.postMessage(payload); } + catch (_) {} + } + + function inspect(url, contentType, text) { + if (!isUsageURL(url) || !/json/i.test(contentType || '')) return; + try { + const parsed = JSON.parse(String(text || '')); + const windows = collectWindows(parsed); + if (!Object.keys(windows).length) return; + post({ windows, fields: Array.from(collectFields(parsed)).slice(0, 80) }); + } catch (_) {} + } + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const response = await originalFetch.apply(this, arguments); + try { + const clone = response.clone(); + const url = typeof input === 'string' ? input : (input && input.url) || ''; + clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__cpClaudeUsageUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); } + catch (_) {} + }); + return originalSend.apply(this, arguments); + }; + })(); + """# +} + +final class ClaudeUsageScriptHandler: NSObject, WKScriptMessageHandler { + weak var owner: ClaudeUsageProbeModel? + + init(owner: ClaudeUsageProbeModel) { + self.owner = owner + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + Task { @MainActor [weak owner = self.owner] in + owner?.record(payload: payload) + } + } +} + +final class ClaudeUsageNavigationDelegate: NSObject, WKNavigationDelegate { + weak var owner: ClaudeUsageProbeModel? + + init(owner: ClaudeUsageProbeModel) { + self.owner = owner + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor [weak webView, weak owner] in + owner?.updateCurrentURL(webView?.url) + } + } +} + +struct ClaudeProbeWebView: NSViewRepresentable { + @ObservedObject var model: ClaudeUsageProbeModel + + func makeNSView(context: Context) -> WKWebView { + model.webView + } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +struct ProbeReportDocument: FileDocument { + static var readableContentTypes: [UTType] { [.plainText] } + + var report: String + + init(report: String) { + self.report = report + } + + init(configuration: ReadConfiguration) throws { + report = "" + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + FileWrapper(regularFileWithContents: Data(report.utf8)) + } +} diff --git a/docs/provider-usage-access.md b/docs/provider-usage-access.md index fd819c0..3ebb7aa 100644 --- a/docs/provider-usage-access.md +++ b/docs/provider-usage-access.md @@ -221,6 +221,15 @@ a user-visible embedded web session and records only sanitized fields such as `five_hour`, `seven_day`, `used_percentage`, `remaining_percentage`, `utilization`, and `resets_at`. +The local `ClaudeWebUsageProbe` executable implements that path. It opens +Claude's usage page in a visible WebKit session, lets the user complete login or +Cloudflare verification normally, observes only `/api/organizations/*/usage` +responses, and reduces the page response to whitelisted usage windows before +Swift receives anything. Saving from the probe writes normalized percent/reset +rows to Context Panel's snapshot store; it does not persist cookies, +authorization headers, tokens, local storage, account UUIDs, organization UUIDs, +emails, or raw response bodies. + No safe persisted local Claude Desktop file/cache containing official subscription percentages was found. The remaining research target is an explicit, privacy-safe metadata capture path for Every Code/non-interactive From c04f7e21ffcb72c8f47d4af54152d1325e6f38c7 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 19:23:13 -0400 Subject: [PATCH 30/34] Merge provider snapshots by account --- .../ClaudeWebUsageProbeApp.swift | 8 +-- Sources/ContextPanelCore/SnapshotStore.swift | 26 ++++++++ .../ContextPanelPreviewApp.swift | 2 +- .../SnapshotStoreTests.swift | 63 ++++++++++++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift index fdf5650..aa91d20 100644 --- a/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift +++ b/Sources/ClaudeWebUsageProbe/ClaudeWebUsageProbeApp.swift @@ -390,11 +390,9 @@ final class ClaudeUsageProbeModel: ObservableObject { limits: limits, status: .healthy ) - try snapshotStore.save( - StoredUsageSnapshot( - savedAt: Date(), - refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]) - ) + try snapshotStore.saveMerged( + refreshResult: ConnectorRefreshResult(generatedAt: Date(), reports: [report]), + savedAt: Date() ) } diff --git a/Sources/ContextPanelCore/SnapshotStore.swift b/Sources/ContextPanelCore/SnapshotStore.swift index 91969b6..d76dd06 100644 --- a/Sources/ContextPanelCore/SnapshotStore.swift +++ b/Sources/ContextPanelCore/SnapshotStore.swift @@ -138,6 +138,27 @@ public struct JSONSnapshotStore: Sendable { try data.write(to: historyURL, options: [.atomic]) } + public func saveMerged(refreshResult: ConnectorRefreshResult, savedAt: Date) throws { + let replacementAccounts = Set( + refreshResult.reports.map { ProviderAccountKey(provider: $0.provider, accountID: $0.accountID) } + ) + let current = loadCurrent().snapshot + let preservedLimits = current?.snapshot.limits.filter { limit in + !replacementAccounts.contains(ProviderAccountKey(provider: limit.provider, accountID: limit.accountID)) + } ?? [] + let preservedReports = current?.reports.filter { report in + !replacementAccounts.contains(ProviderAccountKey(provider: report.provider, accountID: report.accountID)) + } ?? [] + + let mergedSnapshot = UsageSnapshot( + generatedAt: refreshResult.generatedAt, + limits: preservedLimits + refreshResult.snapshot.limits + ) + let mergedReports = preservedReports + refreshResult.reports.map(StoredProviderReport.init(report:)) + + try save(StoredUsageSnapshot(savedAt: savedAt, snapshot: mergedSnapshot, reports: mergedReports)) + } + public func loadCurrent() -> SnapshotStoreLoadResult { guard FileManager.default.fileExists(atPath: currentSnapshotURL.path) else { return SnapshotStoreLoadResult(snapshot: nil, status: .unknown) @@ -227,6 +248,11 @@ public struct JSONSnapshotStore: Sendable { } } +private struct ProviderAccountKey: Hashable { + let provider: Provider + let accountID: String +} + extension ContextPanelDateFormatting { static func historyFileTimestamp(from date: Date) -> String { let formatter = ISO8601DateFormatter() diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 8bebc3c..0b97de8 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -704,7 +704,7 @@ final class ContextPanelAppModel: ObservableObject { let savedAt = Date() do { - try store.save(StoredUsageSnapshot(savedAt: savedAt, refreshResult: refreshResult)) + try store.saveMerged(refreshResult: refreshResult, savedAt: savedAt) lastRefreshAt = savedAt loadSnapshot() } catch { diff --git a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift index 9209266..3099d0a 100644 --- a/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift +++ b/Tests/ContextPanelCoreTests/SnapshotStoreTests.swift @@ -50,6 +50,68 @@ import Testing #expect(store.loadHistory(query: SnapshotStoreQuery(limit: 1)).count == 1) } +@Test func jsonSnapshotStoreMergesRefreshResultByProviderAccount() throws { + let root = try temporaryDirectory() + let store = JSONSnapshotStore(rootDirectory: root) + let first = Date(timeIntervalSince1970: 100) + let second = Date(timeIntervalSince1970: 200) + + let initial = ConnectorRefreshResult(generatedAt: first, reports: [ + ProviderConnectorReport( + provider: .openAI, + accountID: "openai-a", + accountName: "OpenAI A", + generatedAt: first, + limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 40, savedAt: first)] + ), + ProviderConnectorReport( + provider: .google, + accountID: "gemini-a", + accountName: "Gemini A", + generatedAt: first, + limits: [usageLimit(provider: .google, accountID: "gemini-a", used: 10, savedAt: first)] + ), + ]) + try store.save(StoredUsageSnapshot(savedAt: first, refreshResult: initial)) + + let claudeRefresh = ConnectorRefreshResult(generatedAt: second, reports: [ + ProviderConnectorReport( + provider: .anthropic, + accountID: "claude-web", + accountName: "Claude Web", + generatedAt: second, + limits: [usageLimit(provider: .anthropic, accountID: "claude-web", used: 3, savedAt: second)] + ) + ]) + + try store.saveMerged(refreshResult: claudeRefresh, savedAt: second) + + let limits = try #require(store.loadCurrent().snapshot?.snapshot.limits) + #expect(limits.map(\.provider).contains(.openAI)) + #expect(limits.map(\.provider).contains(.google)) + #expect(limits.map(\.provider).contains(.anthropic)) + #expect(store.loadCurrent().snapshot?.reports.count == 3) + #expect(store.loadHistory().count == 2) + + let replacement = ConnectorRefreshResult(generatedAt: second, reports: [ + ProviderConnectorReport( + provider: .openAI, + accountID: "openai-a", + accountName: "OpenAI A", + generatedAt: second, + limits: [usageLimit(provider: .openAI, accountID: "openai-a", used: 80, savedAt: second)] + ) + ]) + try store.saveMerged(refreshResult: replacement, savedAt: second.addingTimeInterval(1)) + + let replaced = try #require(store.loadCurrent().snapshot?.snapshot.limits) + let openAILimit = try #require(replaced.first { $0.provider == .openAI && $0.accountID == "openai-a" }) + #expect(openAILimit.used == 80) + #expect(replaced.filter { $0.provider == .openAI && $0.accountID == "openai-a" }.count == 1) + #expect(replaced.map(\.provider).contains(.google)) + #expect(replaced.map(\.provider).contains(.anthropic)) +} + @Test func jsonSnapshotStoreReportsMissingCurrentAsUnknown() throws { let store = JSONSnapshotStore(rootDirectory: try temporaryDirectory()) @@ -146,4 +208,3 @@ private func temporaryDirectory() throws -> URL { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url } - From 48cb431060c33fc03ad327dff2eee19e4da353f6 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 19:52:39 -0400 Subject: [PATCH 31/34] Add in-app Claude web capture --- .../ContextPanelPreviewApp.swift | 324 +++++++++++++++++- 1 file changed, 323 insertions(+), 1 deletion(-) diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 0b97de8..2182d41 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -1,5 +1,6 @@ import ContextPanelCore import SwiftUI +import WebKit @main struct ContextPanelPreviewApp: App { @@ -42,6 +43,10 @@ struct AppRoot: View { model.loadSnapshot() selectedID = selectedID ?? snapshot.mostConstrainedLimits.first?.id } + .sheet(isPresented: $model.isClaudeWebCapturePresented) { + ClaudeWebCaptureSheet(model: model) + .frame(minWidth: 980, minHeight: 680) + } } } @@ -77,8 +82,9 @@ struct AccountsSidebar: View { .disabled(model.isRefreshing) Button { + model.openClaudeWebCapture() } label: { - Label("Add Account", systemImage: "plus") + Label("Claude Web", systemImage: "gauge.with.dots.needle.67percent") .frame(maxWidth: .infinity) } } @@ -647,6 +653,7 @@ final class ContextPanelAppModel: ObservableObject { @Published private(set) var historyCount: Int = 0 @Published private(set) var configuredAccounts: [LocalProviderAccountConfiguration] = [] @Published private(set) var isRefreshing = false + @Published var isClaudeWebCapturePresented = false @Published private(set) var errorMessage: String? @Published private(set) var lastRefreshAt: Date? @@ -713,6 +720,38 @@ final class ContextPanelAppModel: ObservableObject { } } + func openClaudeWebCapture() { + isClaudeWebCapturePresented = true + } + + func closeClaudeWebCapture() { + isClaudeWebCapturePresented = false + } + + func saveClaudeWebLimits(_ limits: [UsageLimit]) { + guard !limits.isEmpty else { return } + let savedAt = Date() + let report = ProviderConnectorReport( + provider: .anthropic, + accountID: "claude-web", + accountName: "Claude Web", + generatedAt: savedAt, + limits: limits, + status: .healthy + ) + do { + try store.saveMerged( + refreshResult: ConnectorRefreshResult(generatedAt: savedAt, reports: [report]), + savedAt: savedAt + ) + lastRefreshAt = savedAt + loadSnapshot() + } catch { + storeStatus = .failure + errorMessage = error.localizedDescription + } + } + func relativeTime(_ date: Date) -> String { let seconds = max(Int(Date().timeIntervalSince(date)), 0) if seconds < 60 { return "just now" } @@ -816,6 +855,289 @@ struct StatusMark: View { } } +struct ClaudeWebCaptureSheet: View { + @ObservedObject var model: ContextPanelAppModel + @StateObject private var captureModel = ClaudeWebCaptureModel() + + var body: some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 8) { + Text("Claude Web") + .font(.system(size: 22, weight: .semibold)) + Text("Complete Claude verification here. The app captures only official usage windows from the Usage page.") + .font(.system(size: 13)) + .foregroundStyle(CPTheme.secondaryText) + } + + HStack { + Button("Open Usage") { captureModel.openUsagePage() } + Button("Reload") { captureModel.reload() } + Spacer() + Button("Done") { model.closeClaudeWebCapture() } + } + + Label(captureModel.statusText, systemImage: captureModel.statusIcon) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(captureModel.limits.isEmpty ? CPTheme.secondaryText : CPTheme.primaryText) + + Divider() + + Text("Captured windows") + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + .foregroundStyle(CPTheme.secondaryText) + + if captureModel.limits.isEmpty { + ContentUnavailableView( + "Waiting for Claude usage", + systemImage: "network", + description: Text("The sheet auto-saves when Claude's usage endpoint returns percent windows.") + ) + .frame(maxHeight: 220) + } else { + ScrollView { + VStack(spacing: 8) { + ForEach(captureModel.limits) { limit in + ClaudeWebCaptureLimitRow(limit: limit) + } + } + } + } + + Spacer() + + VStack(alignment: .leading, spacing: 6) { + Label("No cookies, tokens, headers, IDs, emails, local storage, or raw bodies are stored.", systemImage: "lock.shield") + Label("Saved rows are merged with OpenAI and Gemini instead of replacing them.", systemImage: "square.stack.3d.up") + } + .font(.system(size: 11)) + .foregroundStyle(CPTheme.secondaryText) + } + .frame(width: 330) + .padding(18) + .background(CPTheme.surface) + + Divider() + + ClaudeWebCaptureWebView(model: captureModel) + } + .onReceive(captureModel.$limits) { limits in + guard !limits.isEmpty else { return } + model.saveClaudeWebLimits(limits) + } + } +} + +struct ClaudeWebCaptureLimitRow: View { + let limit: UsageLimit + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(limit.displayLabel) + .font(.system(size: 13, weight: .semibold)) + Text(limit.contextLabel) + .font(.system(size: 11)) + .foregroundStyle(CPTheme.secondaryText) + } + Spacer() + Text(limit.compactUsageText) + .font(.system(size: 18, weight: .semibold, design: .rounded)) + } + CapacityBar(value: limit.usageRatio ?? 0, status: limit.status) + Text(limit.resetText) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(CPTheme.tertiaryText) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(CPTheme.background) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay(CPTheme.stroke(cornerRadius: 8)) + } +} + +@MainActor +final class ClaudeWebCaptureModel: ObservableObject { + @Published var limits: [UsageLimit] = [] + @Published var statusText = "Opening Claude usage page" + @Published var statusIcon = "safari" + + private lazy var navigationDelegate = ClaudeWebCaptureNavigationDelegate(owner: self) + + lazy var webView: WKWebView = { + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .default() + configuration.userContentController.add(ClaudeWebCaptureScriptHandler(owner: self), name: "claudeUsageCapture") + configuration.userContentController.addUserScript( + WKUserScript(source: Self.networkProbeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = navigationDelegate + return view + }() + + init() { + openUsagePage() + } + + func openUsagePage() { + statusText = "Opening Claude usage page" + statusIcon = "safari" + webView.load(URLRequest(url: URL(string: "https://claude.ai/settings/usage")!)) + } + + func reload() { + statusText = "Reloading Claude usage page" + statusIcon = "arrow.clockwise" + webView.reload() + } + + fileprivate func record(payload: [String: Any]) { + let windows = payload["windows"] as? [String: Any] ?? [:] + let wrapped = ["rate_limits": windows] + do { + let data = try JSONSerialization.data(withJSONObject: wrapped) + let parsed = try ClaudeWebUsageParser.usageLimits( + from: data, + accountID: "claude-web", + accountName: "Claude Web", + observedAt: Date() + ) + guard !parsed.isEmpty else { return } + limits = parsed + statusText = "Captured and saved Claude web usage" + statusIcon = "checkmark.circle.fill" + } catch { + statusText = "Capture failed: \(error.localizedDescription)" + statusIcon = "exclamationmark.triangle" + } + } + + fileprivate func didFinishNavigation(url: URL?) { + if let host = url?.host, host.contains("claude.ai"), limits.isEmpty { + statusText = "Claude page loaded; waiting for usage API" + statusIcon = "network" + } + } + + private static let networkProbeScript = #""" + (() => { + if (window.__contextPanelClaudeUsageCaptureInstalled) return; + window.__contextPanelClaudeUsageCaptureInstalled = true; + + const windowKeys = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet', 'seven_day_oauth_apps']); + const fieldKeys = new Set(['used_percentage', 'remaining_percentage', 'utilization', 'resets_at', 'reset_at']); + + function isUsageURL(rawUrl) { + try { return /^\/api\/organizations\/[^/]+\/usage$/.test(new URL(rawUrl, window.location.href).pathname); } + catch (_) { return false; } + } + + function sanitizeWindow(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const sanitized = {}; + for (const key of fieldKeys) { + const raw = value[key]; + if (typeof raw === 'number' || typeof raw === 'string') sanitized[key] = raw; + } + return Object.keys(sanitized).length ? sanitized : null; + } + + function collectWindows(value, out = {}) { + if (!value || typeof value !== 'object') return out; + if (Array.isArray(value)) { + value.slice(0, 3).forEach(item => collectWindows(item, out)); + return out; + } + for (const [key, child] of Object.entries(value)) { + if (windowKeys.has(key)) { + const sanitized = sanitizeWindow(child); + if (sanitized) out[key] = sanitized; + } + collectWindows(child, out); + } + return out; + } + + function post(payload) { + try { window.webkit.messageHandlers.claudeUsageCapture.postMessage(payload); } + catch (_) {} + } + + function inspect(url, contentType, text) { + if (!isUsageURL(url) || !/json/i.test(contentType || '')) return; + try { + const windows = collectWindows(JSON.parse(String(text || ''))); + if (Object.keys(windows).length) post({ windows }); + } catch (_) {} + } + + const originalFetch = window.fetch; + if (originalFetch) { + window.fetch = async function(input, init) { + const response = await originalFetch.apply(this, arguments); + try { + const clone = response.clone(); + const url = typeof input === 'string' ? input : (input && input.url) || ''; + clone.text().then(text => inspect(url, clone.headers.get('content-type') || '', text)).catch(() => {}); + } catch (_) {} + return response; + }; + } + + const originalOpen = XMLHttpRequest.prototype.open; + const originalSend = XMLHttpRequest.prototype.send; + XMLHttpRequest.prototype.open = function(method, url) { + this.__cpClaudeUsageUrl = url; + return originalOpen.apply(this, arguments); + }; + XMLHttpRequest.prototype.send = function() { + this.addEventListener('load', function() { + try { inspect(this.__cpClaudeUsageUrl || '', this.getResponseHeader('content-type') || '', this.responseText || ''); } + catch (_) {} + }); + return originalSend.apply(this, arguments); + }; + })(); + """# +} + +final class ClaudeWebCaptureScriptHandler: NSObject, WKScriptMessageHandler { + weak var owner: ClaudeWebCaptureModel? + + init(owner: ClaudeWebCaptureModel) { + self.owner = owner + } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + Task { @MainActor [weak owner] in owner?.record(payload: payload) } + } +} + +final class ClaudeWebCaptureNavigationDelegate: NSObject, WKNavigationDelegate { + weak var owner: ClaudeWebCaptureModel? + + init(owner: ClaudeWebCaptureModel) { + self.owner = owner + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + Task { @MainActor [weak webView, weak owner] in owner?.didFinishNavigation(url: webView?.url) } + } +} + +struct ClaudeWebCaptureWebView: NSViewRepresentable { + @ObservedObject var model: ClaudeWebCaptureModel + + func makeNSView(context: Context) -> WKWebView { model.webView } + + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + struct Sparkline: View { let values: [Double] From 5dda537ed51eea1c57e69c7db0d336d67003594e Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 20:19:38 -0400 Subject: [PATCH 32/34] Add signed app packaging script --- README.md | 15 +++ docs/README.md | 1 + docs/release.md | 47 ++++++++++ scripts/package-macos-app.sh | 175 +++++++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100644 docs/release.md create mode 100755 scripts/package-macos-app.sh diff --git a/README.md b/README.md index 6bb654c..1afb99b 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,23 @@ Useful entry points: - [Product Goals](docs/product-goals.md) - [Architecture](docs/architecture.md) +- [macOS Release Path](docs/release.md) - [Repository Settings](docs/repo-settings.md) +## Local App Bundle + +To build a launchable macOS app bundle from the current SwiftPM app shell: + +```sh +scripts/package-macos-app.sh --output dist --identity auto +open "dist/Context Panel.app" +``` + +When a Developer ID Application identity is available in Keychain, the script +uses it through `codesign`; otherwise it falls back to ad-hoc signing. This is +the interim friend-installable path until the formal Xcode app and WidgetKit +extension targets are in place. + ## Local Provider Probes The package includes development probes for validating provider limit signals diff --git a/docs/README.md b/docs/README.md index 0f724f2..3fb5baf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,4 +6,5 @@ - [Design Direction](design-direction.md) - [Local Limit Probe Design](local-limit-probe.md) - [Provider Usage Access Research](provider-usage-access.md) +- [macOS Release Path](release.md) - [Repository Settings](repo-settings.md) diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..34b76a0 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,47 @@ +# macOS Release Path + +Last verified: 2026-05-06. + +Context Panel can currently produce a launchable macOS app bundle from the +SwiftPM preview app. This is an interim friend-installable path while the formal +Xcode app and WidgetKit extension packaging is still being built. + +## Build A Signed App + +```sh +scripts/package-macos-app.sh --output dist --identity auto +``` + +The script builds `ContextPanelPreview` in release mode, wraps it as +`dist/Context Panel.app`, writes app metadata, signs the bundle, verifies the +signature, and runs a local Gatekeeper assessment. + +`--identity auto` asks Keychain for available code-signing identities and +prefers `Developer ID Application`, then `Apple Development`, then ad-hoc +signing. The script does not read private keys or credentials; signing is +performed by macOS Keychain through `codesign`. + +Useful variants: + +```sh +scripts/package-macos-app.sh --debug +scripts/package-macos-app.sh --identity - +scripts/package-macos-app.sh --product ClaudeWebUsageProbe --display-name "Claude Usage Probe" --bundle-id com.shinycomputers.contextpanel.claudeprobe +``` + +## Current Constraints + +- The package is signed but not notarized by this script. +- The app bundle contains the SwiftPM preview app, not the final Xcode app + target. +- WidgetKit extension packaging still needs the formal Xcode app/extension + target before friends can add the widget to Notification Center. +- The app reads and writes local snapshots under Context Panel's Application + Support directory. + +## Validation + +On 2026-05-06, `scripts/package-macos-app.sh --output dist --identity auto` +produced `dist/Context Panel.app`, signed with Developer ID Application: +Shiny Computers Leasing LLC, and `spctl --assess --type execute` accepted the +bundle as Developer ID signed. diff --git a/scripts/package-macos-app.sh b/scripts/package-macos-app.sh new file mode 100755 index 0000000..8e09c15 --- /dev/null +++ b/scripts/package-macos-app.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +product="ContextPanelPreview" +display_name="Context Panel" +bundle_id="com.shinycomputers.contextpanel" +configuration="release" +output_dir="dist" +signing_identity="auto" + +usage() { + cat <<'USAGE' +Usage: scripts/package-macos-app.sh [options] + +Builds a SwiftPM executable and wraps it in a launchable macOS .app bundle. + +Options: + --product NAME SwiftPM executable product. Default: ContextPanelPreview + --display-name NAME App display name. Default: Context Panel + --bundle-id ID CFBundleIdentifier. Default: com.shinycomputers.contextpanel + --debug Build debug instead of release + --output DIR Output directory. Default: dist + --identity VALUE codesign identity, "auto", or "-" for ad-hoc. Default: auto + -h, --help Show this help + +The script never reads private keys or credentials. When --identity auto is +used, it asks Keychain for signing identities and prefers Developer ID +Application, then Apple Development, then ad-hoc signing. +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --product) + product="${2:?--product requires a value}" + shift 2 + ;; + --display-name) + display_name="${2:?--display-name requires a value}" + shift 2 + ;; + --bundle-id) + bundle_id="${2:?--bundle-id requires a value}" + shift 2 + ;; + --debug) + configuration="debug" + shift + ;; + --output) + output_dir="${2:?--output requires a value}" + shift 2 + ;; + --identity) + signing_identity="${2:?--identity requires a value}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "unknown option: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +build_args=(--product "$product") +if [[ "$configuration" == "release" ]]; then + build_args+=(--configuration release) +fi + +swift build "${build_args[@]}" + +bin_path_args=(--show-bin-path) +if [[ "$configuration" == "release" ]]; then + bin_path_args+=(--configuration release) +fi +triple=$(swift build "${bin_path_args[@]}" 2>/dev/null || true) +if [[ -z "$triple" ]]; then + if [[ "$configuration" == "release" ]]; then + triple=".build/release" + else + triple=".build/debug" + fi +fi + +executable_path="$triple/$product" +if [[ ! -x "$executable_path" ]]; then + echo "built executable not found: $executable_path" >&2 + exit 1 +fi + +app_path="$output_dir/$display_name.app" +contents="$app_path/Contents" +macos="$contents/MacOS" +resources="$contents/Resources" + +rm -rf "$app_path" +mkdir -p "$macos" "$resources" +cp "$executable_path" "$macos/$product" + +cat >"$contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $product + CFBundleIdentifier + $bundle_id + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $display_name + CFBundleDisplayName + $display_name + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + +PLIST + +printf 'APPL????' >"$contents/PkgInfo" + +resolve_identity() { + if [[ "$signing_identity" != "auto" ]]; then + printf '%s\n' "$signing_identity" + return + fi + + local identity + identity=$(security find-identity -v -p codesigning 2>/dev/null | + sed -n 's/^.*"\(Developer ID Application:[^"]*\)".*$/\1/p' | + head -n 1) + if [[ -z "$identity" ]]; then + identity=$(security find-identity -v -p codesigning 2>/dev/null | + sed -n 's/^.*"\(Apple Development:[^"]*\)".*$/\1/p' | + head -n 1) + fi + printf '%s\n' "${identity:--}" +} + +resolved_identity=$(resolve_identity) +if [[ "$resolved_identity" == "-" ]]; then + codesign --force --sign - "$app_path" +else + if ! codesign --force --options runtime --timestamp --sign "$resolved_identity" "$app_path"; then + echo "Developer signing failed; retrying ad-hoc signing." >&2 + codesign --force --sign - "$app_path" + resolved_identity="-" + fi +fi + +codesign --verify --deep --strict --verbose=2 "$app_path" +spctl --assess --type execute --verbose=2 "$app_path" || true + +echo "Packaged: $app_path" +echo "Executable: $product" +echo "Bundle ID: $bundle_id" +echo "Signing identity: $resolved_identity" From 1c39c95e004049aaa2d8aa8cfd53e45312846c5a Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 20:19:53 -0400 Subject: [PATCH 33/34] Ignore packaged app artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 96556d8..ccb9d4a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ DerivedData/ *.xcworkspace/xcuserdata/ .swiftpm/ .idea/ +dist/ From 40e8ec0588cae6c409ca97028253fc3d274d4e08 Mon Sep 17 00:00:00 2001 From: Chris Busillo Date: Wed, 6 May 2026 20:59:40 -0400 Subject: [PATCH 34/34] Add native app widget packaging --- Config/ContextPanel.entitlements | 12 + Config/ContextPanelWidget-Info.plist | 27 + Config/ContextPanelWidget.entitlements | 12 + ContextPanel.xcodeproj/project.pbxproj | 651 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcschemes/ContextPanel.xcscheme | 105 +++ README.md | 19 +- .../ContextPanelLocations.swift | 3 +- .../ContextPanelPreviewApp.swift | 4 +- .../ContextPanelWidget.swift | 6 +- docs/release.md | 53 +- project.yml | 78 +++ 12 files changed, 961 insertions(+), 16 deletions(-) create mode 100644 Config/ContextPanel.entitlements create mode 100644 Config/ContextPanelWidget-Info.plist create mode 100644 Config/ContextPanelWidget.entitlements create mode 100644 ContextPanel.xcodeproj/project.pbxproj create mode 100644 ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme create mode 100644 project.yml diff --git a/Config/ContextPanel.entitlements b/Config/ContextPanel.entitlements new file mode 100644 index 0000000..5816106 --- /dev/null +++ b/Config/ContextPanel.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.shinycomputers.contextpanel + + + diff --git a/Config/ContextPanelWidget-Info.plist b/Config/ContextPanelWidget-Info.plist new file mode 100644 index 0000000..6ef3ac5 --- /dev/null +++ b/Config/ContextPanelWidget-Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/Config/ContextPanelWidget.entitlements b/Config/ContextPanelWidget.entitlements new file mode 100644 index 0000000..7320a3d --- /dev/null +++ b/Config/ContextPanelWidget.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.application-groups + + group.com.shinycomputers.contextpanel + + + diff --git a/ContextPanel.xcodeproj/project.pbxproj b/ContextPanel.xcodeproj/project.pbxproj new file mode 100644 index 0000000..459cb4a --- /dev/null +++ b/ContextPanel.xcodeproj/project.pbxproj @@ -0,0 +1,651 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */; }; + 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */; }; + 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */; }; + 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; }; + 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */; }; + 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = D459054C90410A784EBD308B /* UsageLimit.swift */; }; + 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */; }; + 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */; }; + 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */; }; + B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D8515D047F5A8117DB762C /* SnapshotStore.swift */; }; + B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */; }; + BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */ = {isa = PBXBuildFile; fileRef = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; }; + BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */; }; + C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */; }; + C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */; }; + E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */; }; + F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 350ED5589FB23C42ED32704A /* ProviderConnector.swift */; }; + FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8; + remoteInfo = ContextPanelCore; + }; + 3C3392E33451B979623C2996 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 92EF616FEBA46FCB02E887BE; + remoteInfo = ContextPanelWidgetExtension; + }; + A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 194387DBD3D0F10E17F284F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EE4B8035BB7A9BD03B668BB8; + remoteInfo = ContextPanelCore; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 85EBA57F3F1633F2D6E54103 /* ContextPanelWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ContextPanelWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiCodeAssistQuota.swift; sourceTree = ""; }; + 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountConfigurationStore.swift; sourceTree = ""; }; + 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleUsageData.swift; sourceTree = ""; }; + 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelLocations.swift; sourceTree = ""; }; + 32D8515D047F5A8117DB762C /* SnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotStore.swift; sourceTree = ""; }; + 350ED5589FB23C42ED32704A /* ProviderConnector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderConnector.swift; sourceTree = ""; }; + 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidgetViews.swift; sourceTree = ""; }; + 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetSnapshot.swift; sourceTree = ""; }; + 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeWebUsage.swift; sourceTree = ""; }; + 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LimitProbe.swift; sourceTree = ""; }; + 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastModeForecast.swift; sourceTree = ""; }; + 8D6350007C73BEF27D871AB0 /* ContextPanel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ContextPanel.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodexRateLimits.swift; sourceTree = ""; }; + A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelPreviewApp.swift; sourceTree = ""; }; + D459054C90410A784EBD308B /* UsageLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageLimit.swift; sourceTree = ""; }; + E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextPanelWidget.swift; sourceTree = ""; }; + E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeLocalStatus.swift; sourceTree = ""; }; + F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libContextPanelCore.a; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0A536561059AF7F95E2D3FD7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 36A035769881D4A450BA3BD8 /* libContextPanelCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7BFE158EBBBAE213DE50F249 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BE7F8E276624C8E6D93BC329 /* libContextPanelCore.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 45E9D2C527D1EF15E9811358 /* Products */ = { + isa = PBXGroup; + children = ( + 8D6350007C73BEF27D871AB0 /* ContextPanel.app */, + 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */, + F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */, + ); + name = Products; + sourceTree = ""; + }; + 537271134D87C7167DEBA0C1 /* ContextPanelCore */ = { + isa = PBXGroup; + children = ( + 186D607FE2EEFDFFE2E70446 /* AccountConfigurationStore.swift */, + E2E426A482568BBE85357D7E /* ClaudeLocalStatus.swift */, + 53715ACF982CE590F00C3A75 /* ClaudeWebUsage.swift */, + 9FA8FE4CC9BE058D8F46FBA2 /* CodexRateLimits.swift */, + 23E9C71457F4E1F101132FCF /* ContextPanelLocations.swift */, + 7BD985B0213AB5987D3B3170 /* FastModeForecast.swift */, + 0AC2F76895EE0EFC7F463447 /* GeminiCodeAssistQuota.swift */, + 60F09D3CAB56AE663B9F7ECA /* LimitProbe.swift */, + 350ED5589FB23C42ED32704A /* ProviderConnector.swift */, + 32D8515D047F5A8117DB762C /* SnapshotStore.swift */, + D459054C90410A784EBD308B /* UsageLimit.swift */, + 5233BBC86AE8057D749F71CD /* WidgetSnapshot.swift */, + ); + name = ContextPanelCore; + path = Sources/ContextPanelCore; + sourceTree = ""; + }; + 77BADA15D2FF6ABB6F666F43 = { + isa = PBXGroup; + children = ( + 537271134D87C7167DEBA0C1 /* ContextPanelCore */, + F71E88199DC89A225C8F07FC /* ContextPanelPreview */, + A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */, + 45E9D2C527D1EF15E9811358 /* Products */, + ); + sourceTree = ""; + }; + A538E84B22D9D75DE0FD30D4 /* ContextPanelWidget */ = { + isa = PBXGroup; + children = ( + E195C6E906DA9C0BF082622F /* ContextPanelWidget.swift */, + 407B0E4C039FFF22FA8F8542 /* ContextPanelWidgetViews.swift */, + ); + name = ContextPanelWidget; + path = Sources/ContextPanelWidget; + sourceTree = ""; + }; + F71E88199DC89A225C8F07FC /* ContextPanelPreview */ = { + isa = PBXGroup; + children = ( + A46D6040EB6F68FC91EC477D /* ContextPanelPreviewApp.swift */, + 2361E9EED490737FB8306CC7 /* SampleUsageData.swift */, + ); + name = ContextPanelPreview; + path = Sources/ContextPanelPreview; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */; + buildPhases = ( + 255CF9DC3E13CC5E897143A3 /* Sources */, + 7BFE158EBBBAE213DE50F249 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 8361FD30418655D673073A5A /* PBXTargetDependency */, + ); + name = ContextPanelWidgetExtension; + packageProductDependencies = ( + ); + productName = ContextPanelWidgetExtension; + productReference = 07D959D621E594C0CFC2CEF5 /* ContextPanelWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; + EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */; + buildPhases = ( + 5CBBB511F795B6E330EACF99 /* Sources */, + A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ContextPanelCore; + packageProductDependencies = ( + ); + productName = ContextPanelCore; + productReference = F09182E1FD96DAEEC38F3909 /* libContextPanelCore.a */; + productType = "com.apple.product-type.library.static"; + }; + FF80548BA14604BB9B1E9115 /* ContextPanel */ = { + isa = PBXNativeTarget; + buildConfigurationList = 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */; + buildPhases = ( + 8BE7305E45C25AEF658A9E47 /* Sources */, + 0A536561059AF7F95E2D3FD7 /* Frameworks */, + 09DAA1F320B84A007C14B253 /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */, + 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */, + ); + name = ContextPanel; + packageProductDependencies = ( + ); + productName = ContextPanel; + productReference = 8D6350007C73BEF27D871AB0 /* ContextPanel.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 194387DBD3D0F10E17F284F9 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 92EF616FEBA46FCB02E887BE = { + DevelopmentTeam = MM5YXC7T6E; + ProvisioningStyle = Automatic; + }; + EE4B8035BB7A9BD03B668BB8 = { + DevelopmentTeam = MM5YXC7T6E; + ProvisioningStyle = Automatic; + }; + FF80548BA14604BB9B1E9115 = { + DevelopmentTeam = MM5YXC7T6E; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = 77BADA15D2FF6ABB6F666F43; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 45E9D2C527D1EF15E9811358 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + FF80548BA14604BB9B1E9115 /* ContextPanel */, + EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */, + 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXShellScriptBuildPhase section */ + A6FD12E22FB2385AD98885D1 /* Copy Swift Objective-C Interface Header */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(DERIVED_SOURCES_DIR)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)", + ); + name = "Copy Swift Objective-C Interface Header"; + outputPaths = ( + "$(BUILT_PRODUCTS_DIR)/include/$(PRODUCT_MODULE_NAME)/$(SWIFT_OBJC_INTERFACE_HEADER_NAME)", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "ditto \"${SCRIPT_INPUT_FILE_0}\" \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 255CF9DC3E13CC5E897143A3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FE093A1996BA546A10A800DA /* ContextPanelWidget.swift in Sources */, + C6CA96F5E75E32FCCD329736 /* ContextPanelWidgetViews.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5CBBB511F795B6E330EACF99 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E77C3B3D8985239A004E8CDC /* AccountConfigurationStore.swift in Sources */, + 1DD89F5634070B75C09BDE99 /* ClaudeLocalStatus.swift in Sources */, + BF04DB8945D058C7A63BEAB3 /* ClaudeWebUsage.swift in Sources */, + 47414B1A42BE473BF1644296 /* CodexRateLimits.swift in Sources */, + C1DF7B59257060AC0A6AF44D /* ContextPanelLocations.swift in Sources */, + 9C74713AEFFAC6D85E7ACF3F /* FastModeForecast.swift in Sources */, + 10A2940E7E3FE6E253ECFEDE /* GeminiCodeAssistQuota.swift in Sources */, + 0BBD1DF433F345E62C6A26EF /* LimitProbe.swift in Sources */, + F4B9B4FB75A285E38AE52B1D /* ProviderConnector.swift in Sources */, + B6C35EBBE9327327BDCA324F /* SnapshotStore.swift in Sources */, + 459C8EC82194CE1A950FE1B5 /* UsageLimit.swift in Sources */, + B83302BDC1EAC3EE45DBE8B8 /* WidgetSnapshot.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8BE7305E45C25AEF658A9E47 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 38E9E0A22491522FF4E0A8F4 /* ContextPanelPreviewApp.swift in Sources */, + 64BA3F57751ADAB181558D58 /* SampleUsageData.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 4A468DA4B7F0B61AAE457FF9 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 92EF616FEBA46FCB02E887BE /* ContextPanelWidgetExtension */; + targetProxy = 3C3392E33451B979623C2996 /* PBXContainerItemProxy */; + }; + 8361FD30418655D673073A5A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */; + targetProxy = 32954FE71C65F1FD1E590272 /* PBXContainerItemProxy */; + }; + D29DE5B6DB7D5B324359BCA0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EE4B8035BB7A9BD03B668BB8 /* ContextPanelCore */; + targetProxy = A81BFB53BF2A07F0375FCF7E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 04634A97AA8175852ED3CA9D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Context Panel"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel; + PRODUCT_NAME = "Context Panel"; + SDKROOT = macosx; + }; + name = Release; + }; + 58ADB2EF94022C1803CA1F30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore; + PRODUCT_NAME = ContextPanelCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 7FFB726DFDD92831614DCE30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Config/ContextPanel.entitlements; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Context Panel"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel; + PRODUCT_NAME = "Context Panel"; + SDKROOT = macosx; + }; + name = Debug; + }; + 8A5FA37DF23FC4E3C4201B12 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + COMBINE_HIDPI_IMAGES = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.ContextPanelCore; + PRODUCT_NAME = ContextPanelCore; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + 8FBB69A1251FDC3C8BC250F6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget; + PRODUCT_NAME = ContextPanelWidgetExtension; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + AF229A6A5953793A6011AFFA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = MM5YXC7T6E; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + C37F715DCB05E50AB0896150 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = Config/ContextPanelWidget.entitlements; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = "Config/ContextPanelWidget-Info.plist"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.shinycomputers.contextpanel.widget; + PRODUCT_NAME = ContextPanelWidgetExtension; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; + D8D2A31D229BB9A52739BE36 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = MM5YXC7T6E; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 54E2B892F7409618FAE8F040 /* Build configuration list for PBXNativeTarget "ContextPanel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7FFB726DFDD92831614DCE30 /* Debug */, + 04634A97AA8175852ED3CA9D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 6CB2601991081CFCCB5C6E04 /* Build configuration list for PBXNativeTarget "ContextPanelWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8FBB69A1251FDC3C8BC250F6 /* Debug */, + C37F715DCB05E50AB0896150 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 9B6F6DC10F1BF3A02285845F /* Build configuration list for PBXProject "ContextPanel" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AF229A6A5953793A6011AFFA /* Debug */, + D8D2A31D229BB9A52739BE36 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 9CBD94C5FBD8FC3E4201DB61 /* Build configuration list for PBXNativeTarget "ContextPanelCore" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58ADB2EF94022C1803CA1F30 /* Debug */, + 8A5FA37DF23FC4E3C4201B12 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + }; + rootObject = 194387DBD3D0F10E17F284F9 /* Project object */; +} diff --git a/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ContextPanel.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme new file mode 100644 index 0000000..1d06c79 --- /dev/null +++ b/ContextPanel.xcodeproj/xcshareddata/xcschemes/ContextPanel.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 1afb99b..e1dabde 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,20 @@ Useful entry points: ## Local App Bundle -To build a launchable macOS app bundle from the current SwiftPM app shell: +To build the native macOS app with the embedded WidgetKit extension: + +```sh +xcodegen generate --spec project.yml +xcodebuild \ + -project ContextPanel.xcodeproj \ + -scheme ContextPanel \ + -configuration Debug \ + -destination 'platform=macOS' \ + -allowProvisioningUpdates \ + build +``` + +To build a quick launchable macOS app bundle from the SwiftPM app shell: ```sh scripts/package-macos-app.sh --output dist --identity auto @@ -64,8 +77,8 @@ open "dist/Context Panel.app" When a Developer ID Application identity is available in Keychain, the script uses it through `codesign`; otherwise it falls back to ad-hoc signing. This is -the interim friend-installable path until the formal Xcode app and WidgetKit -extension targets are in place. +the interim friend-installable path for the app shell only; use the Xcode build +when testing the widget extension. ## Local Provider Probes diff --git a/Sources/ContextPanelCore/ContextPanelLocations.swift b/Sources/ContextPanelCore/ContextPanelLocations.swift index 22ca282..85709f3 100644 --- a/Sources/ContextPanelCore/ContextPanelLocations.swift +++ b/Sources/ContextPanelCore/ContextPanelLocations.swift @@ -1,6 +1,8 @@ import Foundation public enum ContextPanelLocations { + public static let appGroupID = "group.com.shinycomputers.contextpanel" + public static func applicationSupportDirectory() -> URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: "Library/Application Support") @@ -25,4 +27,3 @@ public enum ContextPanelLocations { applicationSupportDirectory().appending(path: "accounts.json") } } - diff --git a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift index 2182d41..5ea5bb5 100644 --- a/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift +++ b/Sources/ContextPanelPreview/ContextPanelPreviewApp.swift @@ -686,7 +686,9 @@ final class ContextPanelAppModel: ObservableObject { } init() { - store = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory()) + store = JSONSnapshotStore( + rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID) + ) accountStore = AccountConfigurationStore(configurationURL: ContextPanelLocations.accountConfigurationURL()) } diff --git a/Sources/ContextPanelWidget/ContextPanelWidget.swift b/Sources/ContextPanelWidget/ContextPanelWidget.swift index 4705b54..101a103 100644 --- a/Sources/ContextPanelWidget/ContextPanelWidget.swift +++ b/Sources/ContextPanelWidget/ContextPanelWidget.swift @@ -10,7 +10,11 @@ struct ContextPanelWidgetEntry: TimelineEntry { struct ContextPanelTimelineProvider: TimelineProvider { let store: JSONSnapshotStore - init(store: JSONSnapshotStore = JSONSnapshotStore(rootDirectory: ContextPanelLocations.snapshotDirectory())) { + init( + store: JSONSnapshotStore = JSONSnapshotStore( + rootDirectory: ContextPanelLocations.snapshotDirectory(appGroupID: ContextPanelLocations.appGroupID) + ) + ) { self.store = store } diff --git a/docs/release.md b/docs/release.md index 34b76a0..bd1e890 100644 --- a/docs/release.md +++ b/docs/release.md @@ -2,9 +2,39 @@ Last verified: 2026-05-06. -Context Panel can currently produce a launchable macOS app bundle from the -SwiftPM preview app. This is an interim friend-installable path while the formal -Xcode app and WidgetKit extension packaging is still being built. +Context Panel has two local packaging paths: + +- a native Xcode app target with an embedded WidgetKit extension, generated by + XcodeGen +- an interim SwiftPM wrapper script for quickly sharing the preview app without + the widget extension + +The native Xcode path is the one to use for widget installability. + +## Build The Native App And Widget + +```sh +xcodegen generate --spec project.yml +xcodebuild \ + -project ContextPanel.xcodeproj \ + -scheme ContextPanel \ + -configuration Debug \ + -destination 'platform=macOS' \ + -allowProvisioningUpdates \ + build +``` + +This builds `Context Panel.app` and embeds +`ContextPanelWidgetExtension.appex` under `Contents/PlugIns`. The app and widget +both carry the `group.com.shinycomputers.contextpanel` App Group entitlement and +share snapshots through the App Group container when it is available. + +The Xcode project is generated from `project.yml`; update the spec and +regenerate rather than editing the project by hand. + +On 2026-05-06, the Debug Xcode build succeeded locally with automatic signing, +embedded the WidgetKit extension, included the WidgetKit extension point in the +extension Info.plist, and passed `codesign --verify --deep --strict`. ## Build A Signed App @@ -26,18 +56,21 @@ Useful variants: ```sh scripts/package-macos-app.sh --debug scripts/package-macos-app.sh --identity - -scripts/package-macos-app.sh --product ClaudeWebUsageProbe --display-name "Claude Usage Probe" --bundle-id com.shinycomputers.contextpanel.claudeprobe +scripts/package-macos-app.sh \ + --product ClaudeWebUsageProbe \ + --display-name "Claude Usage Probe" \ + --bundle-id com.shinycomputers.contextpanel.claudeprobe ``` ## Current Constraints - The package is signed but not notarized by this script. -- The app bundle contains the SwiftPM preview app, not the final Xcode app - target. -- WidgetKit extension packaging still needs the formal Xcode app/extension - target before friends can add the widget to Notification Center. -- The app reads and writes local snapshots under Context Panel's Application - Support directory. +- The script's app bundle contains the SwiftPM preview app, not the native Xcode + app target. +- The script does not embed the WidgetKit extension; use the Xcode build for + widget testing. +- Full friend-installable distribution still needs archive/export and + notarization polish. ## Validation diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..f9c3967 --- /dev/null +++ b/project.yml @@ -0,0 +1,78 @@ +--- +name: ContextPanel +options: + bundleIdPrefix: com.shinycomputers + deploymentTarget: + macOS: "14.0" +settings: + base: + DEVELOPMENT_TEAM: MM5YXC7T6E + CODE_SIGN_STYLE: Automatic + SWIFT_VERSION: "6.0" + MACOSX_DEPLOYMENT_TARGET: "14.0" +packages: {} +targets: + ContextPanelCore: + type: library.static + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ContextPanelCore + settings: + base: + PRODUCT_NAME: ContextPanelCore + SKIP_INSTALL: true + + ContextPanel: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ContextPanelPreview + dependencies: + - target: ContextPanelCore + - target: ContextPanelWidgetExtension + embed: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel + PRODUCT_NAME: Context Panel + GENERATE_INFOPLIST_FILE: true + INFOPLIST_KEY_CFBundleDisplayName: Context Panel + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + INFOPLIST_KEY_LSApplicationCategoryType: >- + public.app-category.productivity + CODE_SIGN_ENTITLEMENTS: Config/ContextPanel.entitlements + + ContextPanelWidgetExtension: + type: app-extension + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources/ContextPanelWidget + dependencies: + - target: ContextPanelCore + info: + path: Config/ContextPanelWidget-Info.plist + properties: + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.shinycomputers.contextpanel.widget + PRODUCT_NAME: ContextPanelWidgetExtension + GENERATE_INFOPLIST_FILE: false + CODE_SIGN_ENTITLEMENTS: Config/ContextPanelWidget.entitlements + SKIP_INSTALL: true + +schemes: + ContextPanel: + build: + targets: + ContextPanel: all + ContextPanelWidgetExtension: all + run: + config: Debug + archive: + config: Release