diff --git a/Resources/Info.plist b/Resources/Info.plist
index dcc9964..ee35d81 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -13,9 +13,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.2.2
+ 0.2.4
CFBundleVersion
- 4
+ 5
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
diff --git a/Sources/CodexLimits/AnalyticsWorkspace.swift b/Sources/CodexLimits/AnalyticsWorkspace.swift
index 5afc9f5..eda7962 100644
--- a/Sources/CodexLimits/AnalyticsWorkspace.swift
+++ b/Sources/CodexLimits/AnalyticsWorkspace.swift
@@ -18,8 +18,19 @@ enum AnalyticsGraph: String, CaseIterable, Codable, Identifiable, Sendable {
var id: String { rawValue }
+ static let coreCases: [AnalyticsGraph] = [
+ .usageRemaining,
+ .tokenActivity
+ ]
+
var usesAccountScope: Bool {
- self == .usageRemaining || self == .usagePerToken
+ self == .usageRemaining
+ || self == .tokenActivity
+ || self == .usagePerToken
+ }
+
+ var usesLocalAnalytics: Bool {
+ self == .usagePerToken || self == .concurrency
}
}
@@ -62,6 +73,57 @@ enum AnalyticsTimeRange: String, CaseIterable, Codable, Identifiable, Sendable {
}
}
+struct AccountTokenActivityRange: Equatable, Sendable {
+ let days: [TokenDay]
+ let completeDayCount: Int
+ let completeTokens: Int64?
+
+ init(days: [TokenDay], interval: DateInterval) {
+ let day: TimeInterval = 86_400
+ self.days = days
+ .filter {
+ $0.date < interval.end
+ && $0.date.addingTimeInterval(day) > interval.start
+ }
+ .sorted { $0.date < $1.date }
+
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)
+ ?? calendar.timeZone
+ let startOfDay = calendar.startOfDay(for: interval.start)
+ var expected = startOfDay < interval.start
+ ? startOfDay.addingTimeInterval(day)
+ : startOfDay
+ var expectedDates: [Date] = []
+ while expected.addingTimeInterval(day) <= interval.end {
+ expectedDates.append(expected)
+ expected = expected.addingTimeInterval(day)
+ }
+ completeDayCount = expectedDates.count
+
+ guard !expectedDates.isEmpty else {
+ completeTokens = nil
+ return
+ }
+ var total: Int64 = 0
+ for date in expectedDates {
+ guard let bucket = self.days.first(where: { $0.date == date }),
+ bucket.completeness == .complete,
+ bucket.tokens >= 0 else {
+ completeTokens = nil
+ return
+ }
+ let result = total.addingReportingOverflow(bucket.tokens)
+ guard !result.overflow else {
+ completeTokens = nil
+ return
+ }
+ total = result.partialValue
+ }
+ completeTokens = total
+ }
+}
+
struct WorkspaceFilters: Codable, Equatable, Sendable {
var projectID: String?
var taskTreeID: String?
@@ -101,6 +163,10 @@ struct AnalyticsExplorationState: Codable, Equatable, Sendable {
pinnedUsageBaselineID: nil,
pinnedUsageBaselineAccountPartitionID: nil
)
+
+ var usesLocalAnalytics: Bool {
+ section == .graphs && graph.usesLocalAnalytics
+ }
}
@MainActor
@@ -133,6 +199,12 @@ final class AnalyticsWorkspaceStore: ObservableObject {
) else {
return .initial
}
+ guard AnalyticsGraph.coreCases.contains(restored.graph) else {
+ var core = restored
+ core.graph = .usageRemaining
+ core.filters = .all
+ return core
+ }
return restored
}
diff --git a/Sources/CodexLimits/LocalActivityCollector.swift b/Sources/CodexLimits/LocalActivityCollector.swift
index 4b81c80..09aabd3 100644
--- a/Sources/CodexLimits/LocalActivityCollector.swift
+++ b/Sources/CodexLimits/LocalActivityCollector.swift
@@ -816,6 +816,21 @@ actor LocalActivityCollector {
importContinuationPending
}
+ func releaseCachedFacts() {
+ refreshGeneration = nextRevision(after: refreshGeneration)
+ guard stateURL != nil else { return }
+ if !changedPaths.isEmpty, !persist() {
+ return
+ }
+ guard changedPaths.isEmpty,
+ pendingFactWrites.isEmpty else {
+ return
+ }
+ restartPartialFactRestores()
+ clearPublishedContent()
+ unloadPersistedFacts(activePaths: [])
+ }
+
func deleteHistory(at deletedAt: Date = Date()) throws {
historyCutoff = deletedAt
historyDeletionPending = stateDirectory != nil
@@ -1256,9 +1271,9 @@ actor LocalActivityCollector {
guard let directory = stateURL else { return }
for path in Array(files.keys) {
guard var state = files[path],
- state.factsLoaded,
!changedPaths.contains(path),
- pendingFactWrites[path] == nil else {
+ pendingFactWrites[path] == nil,
+ state.factsLoaded || !activePaths.contains(path) else {
continue
}
let file = factsURL(
diff --git a/Sources/CodexLimits/MenuContentView.swift b/Sources/CodexLimits/MenuContentView.swift
index 203a8c9..1749f10 100644
--- a/Sources/CodexLimits/MenuContentView.swift
+++ b/Sources/CodexLimits/MenuContentView.swift
@@ -75,7 +75,16 @@ struct MenuContentView: View {
.padding(.vertical, 12)
}
.frame(width: layout.width, height: layout.height)
- .task { await monitor.refresh(forceHistorySync: false) }
+ .task(id: workspace.state) {
+ let state = workspace.state
+ await monitor.setLocalAnalyticsVisible(
+ state.usesLocalAnalytics
+ )
+ if state.section == .graphs,
+ state.graph == .tokenActivity {
+ await monitor.refreshAccountIfStale()
+ }
+ }
.environment(\.locale, Locale(identifier: "en_US"))
}
@@ -486,7 +495,7 @@ private struct GraphsWorkspace: View {
set: store.selectGraph
)
) {
- ForEach(AnalyticsGraph.allCases) { graph in
+ ForEach(AnalyticsGraph.coreCases) { graph in
Text(graph.rawValue).tag(graph)
}
}
@@ -518,9 +527,7 @@ private struct GraphsWorkspace: View {
Label("Account", systemImage: "person.crop.circle")
.font(.caption)
.foregroundStyle(.secondary)
- .help(
- "Project, Task Tree, model, and reasoning filters do not change \(store.state.graph.rawValue)."
- )
+ .help("Data from your Codex account.")
.accessibilityLabel("Account scope")
} else {
WorkspaceFilterMenu(reader: reader, store: store)
@@ -1511,66 +1518,72 @@ private struct TokenActivityWorkspace: View {
let reader: UsageReaderSnapshot
@ObservedObject var store: AnalyticsWorkspaceStore
- @State private var selectedPoint: LocalTokenActivityPoint?
+ @State private var selectedDay: TokenDay?
+ private let day: TimeInterval = 86_400
+
+ private var accountDays: [TokenDay] {
+ (reader.account?.tokenHistory ?? []).sorted { $0.date < $1.date }
+ }
+
+ private var currentWindowBounds: DateInterval? {
+ reader.weeklyUsageRemaining.map {
+ DateInterval(
+ start: $0.window.startsAt,
+ end: $0.window.resetsAt
+ )
+ }
+ }
private var bounds: DateInterval {
- reader.localTokenActivity.interval
+ let fallback = reader.fetchedAt ?? Date()
+ let first = accountDays.first?.date
+ ?? currentWindowBounds?.start
+ ?? fallback.addingTimeInterval(-day)
+ let last = accountDays.last?.date.addingTimeInterval(day)
+ ?? currentWindowBounds?.end
+ ?? fallback
+ return DateInterval(
+ start: min(first, currentWindowBounds?.start ?? first),
+ end: max(last, currentWindowBounds?.end ?? last)
+ )
}
private var visibleRange: DateInterval {
- store.effectiveRange(
+ if store.state.timeRange == .currentWindow,
+ let currentWindowBounds {
+ return currentWindowBounds
+ }
+ return store.effectiveRange(
within: bounds,
endingAt: min(
- reader.localTokenActivity.observedAt ?? bounds.end,
+ reader.fetchedAt ?? bounds.end,
bounds.end
)
)
}
- private var localSlice: LocalTokenActivitySlice {
- guard !store.state.filters.isEmpty else {
- return reader.localTokenActivity.slice(in: visibleRange)
- }
- return reader.usageReceipts.localTokenSlice(
- in: visibleRange,
- filters: store.state.filters
+ private var accountRange: AccountTokenActivityRange {
+ AccountTokenActivityRange(
+ days: accountDays,
+ interval: visibleRange
)
}
- private var accountCoversVisibleRange: Bool {
- guard let interval = reader.accountTokenActivity.interval else {
- return false
- }
- return abs(interval.start.timeIntervalSince(visibleRange.start)) < 1
- && abs(interval.end.timeIntervalSince(visibleRange.end)) < 1
- }
-
var body: some View {
- content(localSlice)
+ content(accountRange)
}
- private func content(_ slice: LocalTokenActivitySlice) -> some View {
+ private func content(_ range: AccountTokenActivityRange) -> some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Token activity")
.font(.title3.weight(.semibold))
- Text(
- "Account and local token counts may differ, so we show them separately."
- )
+ Text("Daily token totals from your Codex account.")
.font(.callout)
.foregroundStyle(.secondary)
}
- ViewThatFits(in: .horizontal) {
- HStack(alignment: .top, spacing: 12) {
- accountCard
- localCard(slice)
- }
- VStack(spacing: 12) {
- accountCard
- localCard(slice)
- }
- }
+ accountCard(range)
VStack(alignment: .leading, spacing: 10) {
ViewThatFits(in: .horizontal) {
@@ -1585,33 +1598,35 @@ private struct TokenActivityWorkspace: View {
}
}
- if slice.points.isEmpty {
+ if range.days.isEmpty {
WorkspaceMessage(
icon: "chart.xyaxis.line",
- title: "No local token activity",
- message: localEmptyMessage(slice)
+ title: "No account token activity",
+ message: "Codex did not return daily totals for this range."
) {
EmptyView()
}
.frame(minHeight: 170)
} else {
- localChart(slice)
+ accountChart(range)
}
- selectedPointDetail(slice)
+ selectedPointDetail(range)
}
}
.onChange(of: visibleRange) { _, range in
- if let selectedPoint, !range.contains(selectedPoint.date) {
- self.selectedPoint = nil
+ if let selectedDay,
+ selectedDay.date >= range.end
+ || selectedDay.date.addingTimeInterval(day) <= range.start {
+ self.selectedDay = nil
}
}
}
private var chartSourceLabel: some View {
ChartLegendItem(
- label: "Local Codex records",
- color: .purple
+ label: "Daily totals · Account",
+ color: .blue
)
}
@@ -1621,42 +1636,24 @@ private struct TokenActivityWorkspace: View {
.foregroundStyle(.tertiary)
}
- private var accountCard: some View {
+ private func accountCard(
+ _ range: AccountTokenActivityRange
+ ) -> some View {
TokenSourceCard(
title: "Account",
- source: accountSource,
- value: accountValue,
- detail: accountDetail,
- coverage: accountCoverage,
- freshness: reader.accountTokenActivity.interval?.end,
- freshnessLabel: "Through",
- color: .blue
- )
- }
-
- private func localCard(_ slice: LocalTokenActivitySlice) -> some View {
- TokenSourceCard(
- title: "Local",
- source: "Local Codex records",
- value: reader.localTokenActivity.tokens == nil
- ? "Not available"
- : compactTokenCount(slice.tokens),
- detail: localDetail(slice),
- coverage: coverageName(slice.coverage),
- freshness: reader.localTokenActivity.observedAt,
+ source: store.state.timeRange == .currentWindow
+ ? accountSource
+ : "Codex daily token totals",
+ value: summaryTokens(in: range).map(compactTokenCount)
+ ?? "Not available",
+ detail: summaryDetail(in: range),
+ coverage: summaryCoverage(in: range),
+ freshness: reader.fetchedAt,
freshnessLabel: "Updated",
- color: .purple
+ color: .blue
)
}
- private var accountValue: String {
- guard accountCoversVisibleRange,
- let tokens = reader.accountTokenActivity.tokens else {
- return "Not available"
- }
- return compactTokenCount(tokens)
- }
-
private var accountSource: String {
switch reader.accountTokenActivity.method {
case .lifetimeDelta:
@@ -1668,13 +1665,25 @@ private struct TokenActivityWorkspace: View {
}
}
- private var accountDetail: String {
- guard accountCoversVisibleRange else {
- if reader.accountTokenActivity.tokens != nil {
- return "No account total for this selected range"
+ private func summaryTokens(
+ in range: AccountTokenActivityRange
+ ) -> Int64? {
+ if store.state.timeRange == .currentWindow {
+ return reader.accountTokenActivity.tokens
+ }
+ return range.completeTokens
+ }
+
+ private func summaryDetail(
+ in range: AccountTokenActivityRange
+ ) -> String {
+ guard store.state.timeRange == .currentWindow else {
+ if range.completeDayCount == 0 {
+ return "No full days in this range"
}
- return reader.accountTokenActivity.reason
- ?? "Account token activity is unavailable"
+ return range.completeTokens == nil
+ ? "Codex returned only part of this range"
+ : "Sum of \(range.completeDayCount) complete days"
}
switch reader.accountTokenActivity.method {
case .lifetimeDelta:
@@ -1689,8 +1698,14 @@ private struct TokenActivityWorkspace: View {
}
}
- private var accountCoverage: String {
- guard accountCoversVisibleRange else { return "Unavailable" }
+ private func summaryCoverage(
+ in range: AccountTokenActivityRange
+ ) -> String {
+ guard store.state.timeRange == .currentWindow else {
+ return range.completeTokens == nil
+ ? "Unavailable"
+ : "Complete days"
+ }
switch reader.accountTokenActivity.state {
case .exact: return "Complete"
case .partial: return "Partial"
@@ -1698,57 +1713,33 @@ private struct TokenActivityWorkspace: View {
}
}
- private func localDetail(_ slice: LocalTokenActivitySlice) -> String {
- var details: [String] = []
- if let version = reader.localTokenActivity.sourceVersion {
- details.append("Codex \(version)")
- }
- if let reason = slice.reason {
- details.append(readerFacingLocalReason(reason))
- }
- return details.isEmpty
- ? "Local Codex records are unavailable"
- : details.joined(separator: " · ")
- }
-
- private func localEmptyMessage(_ slice: LocalTokenActivitySlice) -> String {
- slice.reason.map(readerFacingLocalReason)
- ?? "No local token events were found in this range."
- }
-
- private func renderedChartPoints(
- _ slice: LocalTokenActivitySlice
- ) -> [LocalTokenActivityPoint] {
- let points = slice.points
- if points.first?.date == visibleRange.start {
- return downsampledForDisplay(points)
- }
- return [LocalTokenActivityPoint(date: visibleRange.start, tokens: 0)]
- + downsampledForDisplay(points, limit: 999)
+ private func renderedDays(
+ _ range: AccountTokenActivityRange
+ ) -> [TokenDay] {
+ downsampledForDisplay(range.days)
}
- private func localChart(_ slice: LocalTokenActivitySlice) -> some View {
+ private func accountChart(
+ _ range: AccountTokenActivityRange
+ ) -> some View {
Chart {
- ForEach(renderedChartPoints(slice)) { point in
- LineMark(
- x: .value("Time", point.date),
- y: .value("Local tokens", point.tokens),
- series: .value("Source", "Local Codex records")
+ ForEach(renderedDays(range), id: \.date) { tokenDay in
+ BarMark(
+ x: .value("Day", tokenDay.date, unit: .day),
+ y: .value("Account tokens", tokenDay.tokens)
)
- .foregroundStyle(Color.purple)
- .lineStyle(StrokeStyle(lineWidth: 2))
- .interpolationMethod(.stepEnd)
+ .foregroundStyle(Color.blue)
}
- if let selectedPoint {
- RuleMark(x: .value("Selected time", selectedPoint.date))
+ if let selectedDay {
+ RuleMark(x: .value("Selected time", selectedDay.date))
.foregroundStyle(Color.primary.opacity(0.45))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))
PointMark(
- x: .value("Selected time", selectedPoint.date),
- y: .value("Local tokens", selectedPoint.tokens)
+ x: .value("Selected day", selectedDay.date),
+ y: .value("Account tokens", selectedDay.tokens)
)
- .foregroundStyle(Color.purple)
+ .foregroundStyle(Color.blue)
.symbolSize(52)
}
}
@@ -1777,21 +1768,21 @@ private struct TokenActivityWorkspace: View {
at: location,
proxy: proxy,
geometry: geometry,
- points: slice.points
+ days: range.days
)
case .ended:
- selectedPoint = nil
+ selectedDay = nil
}
}
}
}
- .frame(height: 220)
+ .frame(height: 180)
.accessibilityElement(children: .ignore)
- .accessibilityLabel("Local token activity")
+ .accessibilityLabel("Account token activity")
.accessibilityValue(
- selectedPoint.map {
- "\(compactTokenCount($0.tokens)) local tokens, \($0.date.formatted(date: .abbreviated, time: .shortened))"
- } ?? "\(compactTokenCount(slice.tokens)) local tokens in the selected range"
+ selectedDay.map {
+ "\(compactTokenCount($0.tokens)) account tokens, \($0.date.formatted(date: .abbreviated, time: .omitted))"
+ } ?? "Daily account token totals are shown."
)
.accessibilityHint(
"Use Previous point and Next point for exact values."
@@ -1800,36 +1791,36 @@ private struct TokenActivityWorkspace: View {
@ViewBuilder
private func selectedPointDetail(
- _ slice: LocalTokenActivitySlice
+ _ range: AccountTokenActivityRange
) -> some View {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 10) {
- if let selectedPoint {
+ if let selectedDay {
ViewThatFits(in: .horizontal) {
HStack(spacing: 10) {
- Text("Local Codex records")
+ Text("Daily account total")
.fontWeight(.semibold)
- Text(compactTokenCount(selectedPoint.tokens))
+ Text(compactTokenCount(selectedDay.tokens))
.monospacedDigit()
Text(
- selectedPoint.date.formatted(
+ selectedDay.date.formatted(
date: .abbreviated,
- time: .shortened
+ time: .omitted
)
)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
- Text("Local Codex records")
+ Text("Daily account total")
.fontWeight(.semibold)
- Text(compactTokenCount(selectedPoint.tokens))
+ Text(compactTokenCount(selectedDay.tokens))
.monospacedDigit()
}
Text(
- selectedPoint.date.formatted(
+ selectedDay.date.formatted(
date: .abbreviated,
- time: .shortened
+ time: .omitted
)
)
.foregroundStyle(.secondary)
@@ -1841,28 +1832,18 @@ private struct TokenActivityWorkspace: View {
}
Spacer()
Button {
- moveSelection(in: slice.points, by: -1)
+ moveSelection(in: range.days, by: -1)
} label: {
Image(systemName: "chevron.left")
}
.accessibilityLabel("Previous point")
Button {
- moveSelection(in: slice.points, by: 1)
+ moveSelection(in: range.days, by: 1)
} label: {
Image(systemName: "chevron.right")
}
.accessibilityLabel("Next point")
}
- if selectedPoint != nil {
- HStack(spacing: 10) {
- Text("Account · \(accountSource)")
- .fontWeight(.semibold)
- Text(accountValue)
- .monospacedDigit()
- Text("selected range")
- .foregroundStyle(.secondary)
- }
- }
}
.font(.caption)
.padding(10)
@@ -1874,80 +1855,34 @@ private struct TokenActivityWorkspace: View {
}
private func moveSelection(
- in points: [LocalTokenActivityPoint],
+ in days: [TokenDay],
by offset: Int
) {
- selectedPoint = steppedPoint(
- in: points,
- from: selectedPoint,
+ selectedDay = steppedPoint(
+ in: days,
+ from: selectedDay,
by: offset
)
}
- private func readerFacingLocalReason(_ reason: String) -> String {
- switch reason {
- case "Local token activity starts from an unbounded counter":
- "The first local reading has no earlier reading"
- case "Local rollout path is unavailable",
- "Local task records are missing":
- "Some local Codex records could not be found"
- case "Local task discovery is incomplete",
- "Local task metadata is incomplete",
- "Local task identity is missing":
- "Some local tasks could not be checked"
- case "This Codex CLI version has not been checked":
- "This Codex version has not been checked"
- case "Installed Codex CLI version is unavailable",
- "Codex CLI version is unavailable":
- "The installed Codex version could not be checked"
- case "Only local activity on this Mac is observed":
- "Only activity on this Mac is included"
- case "Saved local activity could not be read":
- "Saved local activity could not be read"
- case "Local activity could not be saved":
- "Local activity could not be saved"
- case "Local task import is still in progress":
- "Local activity is still loading"
- case "Local task record continuity changed":
- "A local task record changed"
- case "Local activity read was cancelled":
- "Local activity could not finish loading"
- case "Account changed during local activity read":
- "Local activity changed while loading"
- default:
- reason
- }
- }
-
private func selectNearestPoint(
at location: CGPoint,
proxy: ChartProxy,
geometry: GeometryProxy,
- points: [LocalTokenActivityPoint]
+ days: [TokenDay]
) {
guard let date = chartDate(
at: location,
proxy: proxy,
geometry: geometry
) else { return }
- selectedPoint = nearestPoint(
- in: points,
+ selectedDay = nearestPoint(
+ in: days,
to: date,
date: \.date
)
}
- private func coverageName(_ coverage: CoverageLevel) -> String {
- switch coverage {
- case .complete: "Complete"
- case .high: "High"
- case .partial: "Partial"
- case .low: "Low"
- case .unavailable: "Unavailable"
- case .notApplicable: "Not applicable"
- }
- }
-
private func intervalText(_ interval: DateInterval) -> String {
let start = interval.start.formatted(
date: .abbreviated,
@@ -2221,7 +2156,7 @@ private struct UsageRemainingChart: View {
.chartOverlay { proxy in
chartOverlay(proxy: proxy)
}
- .frame(height: 300)
+ .frame(height: 240)
.accessibilityElement(children: .ignore)
.accessibilityLabel("Usage remaining")
.accessibilityValue(
@@ -2791,13 +2726,6 @@ private struct FactsWorkspace: View {
}
}
- WorkspaceCard(title: "Active Time") {
- activeTimeContent
- }
-
- WorkspaceCard(title: "Usage Receipts") {
- receiptContent
- }
}
}
diff --git a/Sources/CodexLimits/UsageMonitor.swift b/Sources/CodexLimits/UsageMonitor.swift
index ec6458a..c9b5d6a 100644
--- a/Sources/CodexLimits/UsageMonitor.swift
+++ b/Sources/CodexLimits/UsageMonitor.swift
@@ -14,6 +14,7 @@ enum SafetyBufferPolicy {
@MainActor
final class UsageMonitor: ObservableObject {
+ private static let accountRefreshInterval: TimeInterval = 600
static let safetyBufferKey = "safetyBuffer"
@Published private(set) var readerSnapshot = UsageIntelligenceEngine.evaluate(
@@ -80,6 +81,8 @@ final class UsageMonitor: ObservableObject {
private var evaluationTask: Task?
private var localImportGeneration: UInt64 = 0
private var localImportTask: Task?
+ private var localAnalyticsVisible = false
+ private var localAnalyticsNeedsLoad = false
convenience init() {
self.init(
@@ -196,13 +199,15 @@ final class UsageMonitor: ObservableObject {
)
}
- Timer.publish(every: 600, on: .main, in: .common)
+ Timer.publish(
+ every: Self.accountRefreshInterval,
+ on: .main,
+ in: .common
+ )
.autoconnect()
.sink { [weak self] _ in
Task {
- @MainActor in await self?.refresh(
- forceHistorySync: false
- )
+ @MainActor in await self?.automaticRefresh()
}
}
.store(in: &cancellables)
@@ -211,21 +216,40 @@ final class UsageMonitor: ObservableObject {
.publisher(for: NSWorkspace.didWakeNotification)
.sink { [weak self] _ in
Task {
- @MainActor in await self?.refresh(
- forceHistorySync: false
- )
+ @MainActor in await self?.automaticRefresh()
}
}
.store(in: &cancellables)
- await refresh(forceHistorySync: false)
+ await automaticRefresh()
}
- func refresh(forceHistorySync: Bool = true) async {
+ func automaticRefresh() async {
+ await refresh(
+ forceHistorySync: false,
+ includeLocalActivity: false
+ )
+ }
+
+ func refreshAccountIfStale(now: Date = Date()) async {
+ guard let fetchedAt = accountSnapshot?.fetchedAt,
+ now.timeIntervalSince(fetchedAt)
+ < Self.accountRefreshInterval else {
+ await automaticRefresh()
+ return
+ }
+ }
+
+ func refresh(
+ forceHistorySync: Bool = true,
+ includeLocalActivity: Bool = true
+ ) async {
guard !isRefreshing else { return }
isRefreshing = true
defer { isRefreshing = false }
- cancelLocalImport()
+ if includeLocalActivity {
+ cancelLocalImport()
+ }
await restoreHistoryIfAvailable()
let fetchTask = Task { try await fetchUsage() }
@@ -239,14 +263,14 @@ final class UsageMonitor: ObservableObject {
)
accountSnapshot = result.snapshot
sourceState = .available
- await localActivityCollector?.selectPartition(
- historyPartition.id
- )
- await refreshLocalActivity(
- for: result.snapshot,
- observedAt: result.snapshot.fetchedAt,
- identityVerified: false
- )
+ if localAnalyticsVisible,
+ includeLocalActivity || localAnalyticsNeedsLoad {
+ await refreshLocalActivity(
+ for: result.snapshot,
+ observedAt: result.snapshot.fetchedAt,
+ identityVerified: false
+ )
+ }
let published = await recalculate(
now: result.snapshot.fetchedAt
)
@@ -297,10 +321,13 @@ final class UsageMonitor: ObservableObject {
}
accountSnapshot = newSnapshot
sourceState = .available
- await refreshLocalActivity(
- for: newSnapshot,
- observedAt: newSnapshot.fetchedAt
- )
+ if localAnalyticsVisible,
+ includeLocalActivity || localAnalyticsNeedsLoad {
+ await refreshLocalActivity(
+ for: newSnapshot,
+ observedAt: newSnapshot.fetchedAt
+ )
+ }
let published = await recalculate(now: newSnapshot.fetchedAt)
persist()
if published {
@@ -314,10 +341,9 @@ final class UsageMonitor: ObservableObject {
(error as? CodexClientError)?.localizedDescription
?? "Couldn’t read Codex usage. Try refreshing again."
)
- if let accountSnapshot {
- await localActivityCollector?.selectPartition(
- historyPartition.id
- )
+ if localAnalyticsVisible,
+ includeLocalActivity || localAnalyticsNeedsLoad,
+ let accountSnapshot {
await refreshLocalActivity(
for: accountSnapshot,
observedAt: Date(),
@@ -329,6 +355,46 @@ final class UsageMonitor: ObservableObject {
}
}
+ func setLocalAnalyticsVisible(_ isVisible: Bool) async {
+ if isVisible {
+ if !localAnalyticsVisible {
+ localAnalyticsVisible = true
+ localAnalyticsNeedsLoad = true
+ }
+ guard localAnalyticsNeedsLoad else { return }
+ } else {
+ guard localAnalyticsVisible || localAnalyticsNeedsLoad else {
+ return
+ }
+ localAnalyticsVisible = false
+ localAnalyticsNeedsLoad = false
+ cancelLocalImport()
+ localActivityCollection = .unavailable(
+ "Codex local records are unavailable"
+ )
+ await localActivityCollector?.releaseCachedFacts()
+ _ = await recalculate()
+ return
+ }
+ while isRefreshing {
+ guard !Task.isCancelled else { return }
+ try? await Task.sleep(for: .milliseconds(50))
+ }
+ guard localAnalyticsVisible,
+ localAnalyticsNeedsLoad,
+ let accountSnapshot else {
+ return
+ }
+ let identityVerified = sourceState == .available
+ && historyAccountIdentity != nil
+ await refreshLocalActivity(
+ for: accountSnapshot,
+ observedAt: identityVerified ? accountSnapshot.fetchedAt : Date(),
+ identityVerified: identityVerified
+ )
+ _ = await recalculate()
+ }
+
func updateSafetyBuffer(_ value: Double) {
let value = SafetyBufferPolicy.normalized(value)
defaults.set(value, forKey: Self.safetyBufferKey)
@@ -425,7 +491,6 @@ final class UsageMonitor: ObservableObject {
planType: String? = nil,
observedAt: Date
) async {
- cancelLocalImport()
let partition: AccountHistoryPartition
let authState: String
let previousAuthState = defaults.string(forKey: Self.historyAuthStateKey)
@@ -473,8 +538,8 @@ final class UsageMonitor: ObservableObject {
if let planType {
defaults.set(planType, forKey: Self.historyPlanTypeKey)
}
- await localActivityCollector?.selectPartition(partition.id)
guard partition != historyPartition else { return }
+ cancelLocalImport()
historyPartition = partition
historyConnectionActive = false
if let data = try? JSONEncoder().encode(partition) {
@@ -906,6 +971,8 @@ final class UsageMonitor: ObservableObject {
identityVerified: Bool = true
) async {
cancelLocalImport()
+ let generation = localImportGeneration
+ localAnalyticsNeedsLoad = false
guard let interval = UsageIntelligenceEngine.tokenActivityInterval(
account: snapshot,
samples: historyMatchesCurrentSnapshot ? samples : [],
@@ -922,6 +989,8 @@ final class UsageMonitor: ObservableObject {
)
return
}
+ await localActivityCollector.selectPartition(historyPartition.id)
+ guard generation == localImportGeneration else { return }
localActivityCollection = .unavailable(
"Codex local records are unavailable"
)
@@ -929,16 +998,22 @@ final class UsageMonitor: ObservableObject {
interval: interval,
observedAt: observedAt
)
- if await localActivityCollector.hasPendingHistoryDeletion() {
+ guard generation == localImportGeneration else { return }
+ let deletionPending =
+ await localActivityCollector.hasPendingHistoryDeletion()
+ guard generation == localImportGeneration else { return }
+ if deletionPending {
historyDeletionStatus = .pendingLocal
}
localActivityCollection = identityVerified
? collection
: collection.loweringCoverage(
"Codex account identity could not be checked"
- )
- guard await localActivityCollector.hasPendingImport() else { return }
- let generation = localImportGeneration
+ )
+ let importPending = await localActivityCollector.hasPendingImport()
+ guard generation == localImportGeneration, importPending else {
+ return
+ }
localImportTask = Task(priority: .background) { [weak self] in
try? await Task.sleep(for: .milliseconds(500))
await self?.continueLocalActivityImport(
diff --git a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift
index 1b25518..016326b 100644
--- a/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift
+++ b/Tests/CodexLimitsTests/AnalyticsWorkspaceTests.swift
@@ -27,7 +27,7 @@ final class AnalyticsWorkspaceTests: XCTestCase {
let first = AnalyticsWorkspaceStore(defaults: defaults)
first.selectSection(.facts)
- first.selectGraph(.concurrency)
+ first.selectGraph(.tokenActivity)
first.selectTimeRange(.threeDays)
first.updateFilters(
WorkspaceFilters(
@@ -51,7 +51,7 @@ final class AnalyticsWorkspaceTests: XCTestCase {
let restored = AnalyticsWorkspaceStore(defaults: defaults)
XCTAssertEqual(restored.state.section, .facts)
- XCTAssertEqual(restored.state.graph, .concurrency)
+ XCTAssertEqual(restored.state.graph, .tokenActivity)
XCTAssertEqual(restored.state.timeRange, .selected)
XCTAssertEqual(restored.state.filters.projectID, "codex-limits")
XCTAssertEqual(restored.state.filters.taskTreeID, "task-42")
@@ -112,10 +112,75 @@ final class AnalyticsWorkspaceTests: XCTestCase {
func testUsagePerTokenKeepsAccountScope() {
XCTAssertTrue(AnalyticsGraph.usagePerToken.usesAccountScope)
- XCTAssertFalse(AnalyticsGraph.tokenActivity.usesAccountScope)
+ XCTAssertTrue(AnalyticsGraph.tokenActivity.usesAccountScope)
XCTAssertFalse(AnalyticsGraph.concurrency.usesAccountScope)
}
+ func testLightweightCoreOffersOnlyAccountGraphs() {
+ XCTAssertEqual(
+ AnalyticsGraph.coreCases,
+ [.usageRemaining, .tokenActivity]
+ )
+ }
+
+ func testAccountTokenRangeSumsOnlyCompleteFullDays() throws {
+ let formatter = ISO8601DateFormatter()
+ let interval = DateInterval(
+ start: try XCTUnwrap(
+ formatter.date(from: "2026-07-01T12:00:00Z")
+ ),
+ end: try XCTUnwrap(
+ formatter.date(from: "2026-07-04T12:00:00Z")
+ )
+ )
+ let days = try [
+ ("2026-07-01T00:00:00Z", 100, TokenDayCompleteness.complete),
+ ("2026-07-02T00:00:00Z", 200, .complete),
+ ("2026-07-03T00:00:00Z", 300, .complete),
+ ("2026-07-04T00:00:00Z", 400, .partial)
+ ].map {
+ TokenDay(
+ date: try XCTUnwrap(formatter.date(from: $0.0)),
+ tokens: Int64($0.1),
+ completeness: $0.2
+ )
+ }
+
+ let range = AccountTokenActivityRange(
+ days: days,
+ interval: interval
+ )
+
+ XCTAssertEqual(range.days.count, 4)
+ XCTAssertEqual(range.completeDayCount, 2)
+ XCTAssertEqual(range.completeTokens, 500)
+
+ let missingDay = AccountTokenActivityRange(
+ days: days.filter { $0.date != days[2].date },
+ interval: interval
+ )
+ XCTAssertNil(missingDay.completeTokens)
+ }
+
+ func testRestoredLocalGraphFallsBackToUsageRemaining() throws {
+ let defaults = try XCTUnwrap(
+ UserDefaults(
+ suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)"
+ )
+ )
+ var state = AnalyticsExplorationState.initial
+ state.graph = .concurrency
+ defaults.set(
+ try JSONEncoder().encode(state),
+ forKey: AnalyticsWorkspaceStore.persistenceKey
+ )
+
+ XCTAssertEqual(
+ AnalyticsWorkspaceStore.restoredState(from: defaults).graph,
+ .usageRemaining
+ )
+ }
+
func testChangingGraphKeepsRangeAndFilters() {
let store = AnalyticsWorkspaceStore(
defaults: UserDefaults(suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)")!
@@ -146,7 +211,70 @@ final class AnalyticsWorkspaceTests: XCTestCase {
func testUsageRemainingAlwaysUsesAccountScope() {
XCTAssertTrue(AnalyticsGraph.usageRemaining.usesAccountScope)
- XCTAssertFalse(AnalyticsGraph.tokenActivity.usesAccountScope)
+ XCTAssertTrue(AnalyticsGraph.tokenActivity.usesAccountScope)
+ }
+
+ func testTokenActivityRendersAccountDailyBucketsWithoutLocalFacts() {
+ let fetchedAt = Date(timeIntervalSince1970: 10 * 86_400)
+ let account = UsageSnapshot(
+ mainLimit: LimitReading(
+ limitId: "weekly",
+ name: "Weekly",
+ window: UsageWindow(
+ remainingPercent: 75,
+ resetsAt: fetchedAt.addingTimeInterval(3 * 86_400),
+ durationMinutes: 10_080
+ )
+ ),
+ otherLimits: [],
+ tokenHistory: [
+ TokenDay(
+ date: fetchedAt.addingTimeInterval(-2 * 86_400),
+ tokens: 1_000,
+ completeness: .complete
+ ),
+ TokenDay(
+ date: fetchedAt.addingTimeInterval(-86_400),
+ tokens: 2_000,
+ completeness: .complete
+ ),
+ TokenDay(
+ date: fetchedAt,
+ tokens: 500,
+ completeness: .partial
+ )
+ ],
+ emergencyResetCount: 0,
+ fetchedAt: fetchedAt
+ )
+ let reader = UsageIntelligenceEngine.evaluate(
+ UsageIntelligenceInput(
+ account: account,
+ samples: [],
+ safetyBuffer: 3,
+ sourceState: .available,
+ now: fetchedAt,
+ previousStatus: nil
+ )
+ )
+ let store = AnalyticsWorkspaceStore(
+ defaults: UserDefaults(
+ suiteName: "AnalyticsWorkspaceTests-\(UUID().uuidString)"
+ )!
+ )
+ store.selectGraph(.tokenActivity)
+
+ XCTAssertTrue(
+ renders(
+ AnalyticsWorkspaceBody(
+ reader: reader,
+ store: store,
+ assistedInsights: CodexAssistedInsightStore()
+ ),
+ size: CGSize(width: 640, height: 780)
+ )
+ )
+ XCTAssertNil(reader.localTokenActivity.tokens)
}
func testPresetRangeEndsAtLatestObservedTimeAndIsClampedToWindow() {
diff --git a/Tests/CodexLimitsTests/LocalActivityCollectorTests.swift b/Tests/CodexLimitsTests/LocalActivityCollectorTests.swift
index 6591098..be5d2db 100644
--- a/Tests/CodexLimitsTests/LocalActivityCollectorTests.swift
+++ b/Tests/CodexLimitsTests/LocalActivityCollectorTests.swift
@@ -47,6 +47,87 @@ final class LocalActivityCollectorTests: XCTestCase {
XCTAssertEqual(second.observation.coverage, .high)
}
+ func testReleasedFactsRestoreFromThePersistedCache() async throws {
+ let fixture = try CollectorFixture()
+ _ = try fixture.rollout(
+ day: "2026/07/28",
+ threadID: "task-1",
+ lines: [
+ fixture.session(threadID: "task-1", ordinal: 0),
+ fixture.tokens(total: 100, ordinal: 1, minute: 1),
+ fixture.tokens(total: 600, ordinal: 2, minute: 2)
+ ]
+ )
+ let collector = LocalActivityCollector(
+ rootDirectory: fixture.root,
+ stateDirectory: fixture.root.appendingPathComponent(
+ "collector-state",
+ isDirectory: true
+ )
+ )
+ await collector.selectPartition("stable-account")
+ let interval = try fixture.interval()
+ let first = await collector.refresh(interval: interval)
+
+ await collector.releaseCachedFacts()
+ let restored = await collector.refresh(interval: interval)
+
+ XCTAssertEqual(restored.facts, first.facts)
+ }
+
+ func testReleasedFactsStayReleasedWhenARefreshWasSuspended() async throws {
+ let fixture = try CollectorFixture()
+ let file = try fixture.rollout(
+ day: "2026/07/28",
+ threadID: "task-1",
+ lines: [
+ fixture.session(threadID: "task-1", ordinal: 0),
+ fixture.tokens(total: 100, ordinal: 1, minute: 1),
+ fixture.tokens(total: 600, ordinal: 2, minute: 2)
+ ]
+ )
+ let versionDelay = SecondInstalledVersionDelay()
+ let collector = LocalActivityCollector(
+ rootDirectory: fixture.root,
+ stateDirectory: fixture.root.appendingPathComponent(
+ "collector-state",
+ isDirectory: true
+ ),
+ installedCLIVersion: {
+ await versionDelay.response()
+ }
+ )
+ await collector.selectPartition("stable-account")
+ let interval = try fixture.interval()
+ let first = await collector.refresh(interval: interval)
+ try fixture.append(
+ fixture.tokens(total: 800, ordinal: 3, minute: 3),
+ to: file
+ )
+ let suspended = Task {
+ await collector.refresh(interval: interval)
+ }
+ await versionDelay.waitUntilSecondRequest()
+
+ await collector.releaseCachedFacts()
+ await versionDelay.releaseSecondRequest()
+ _ = await suspended.value
+ let restored = await collector.refresh(
+ interval: interval,
+ refreshMetadata: false
+ )
+
+ XCTAssertEqual(
+ first.facts.filter { $0.key == .token }.compactMap(\.numericDelta),
+ [500]
+ )
+ XCTAssertEqual(
+ restored.facts.filter { $0.key == .token }
+ .compactMap(\.numericDelta),
+ [500, 200]
+ )
+ }
+
func testMissingTrackedFileKeepsFactsAndNamesTheSourceGap() async throws {
let fixture = try CollectorFixture()
let file = try fixture.rollout(
@@ -2402,6 +2483,32 @@ private actor CancellableProjectionDelay {
}
}
+private actor SecondInstalledVersionDelay {
+ private var requestCount = 0
+ private var secondRequestStarted = false
+ private var secondRequestContinuation: CheckedContinuation?
+
+ func response() async -> String? {
+ requestCount += 1
+ guard requestCount == 2 else { return "0.145.0" }
+ secondRequestStarted = true
+ return await withCheckedContinuation { continuation in
+ secondRequestContinuation = continuation
+ }
+ }
+
+ func waitUntilSecondRequest() async {
+ while !secondRequestStarted {
+ await Task.yield()
+ }
+ }
+
+ func releaseSecondRequest() {
+ secondRequestContinuation?.resume(returning: "0.145.0")
+ secondRequestContinuation = nil
+ }
+}
+
private actor SupersededProjectionDelay {
private var listStarted = false
private var listContinuation: CheckedContinuation?
diff --git a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift
index c11db76..bb79e6f 100644
--- a/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift
+++ b/Tests/CodexLimitsTests/UsageMonitorHistoryTests.swift
@@ -587,6 +587,44 @@ final class UsageMonitorHistoryTests: XCTestCase {
)
}
+ func testTokenActivityRefreshesOnlyWhenAccountDataIsStale() async throws {
+ let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let fetchedAt = Date(timeIntervalSince1970: 1_700_000)
+ let source = FetchSequence([
+ makeFetchResult(
+ identity: "user@example.com",
+ fetchedAt: fetchedAt,
+ remaining: 90
+ ),
+ makeFetchResult(
+ identity: "user@example.com",
+ fetchedAt: fetchedAt.addingTimeInterval(600),
+ remaining: 89
+ )
+ ])
+ let monitor = UsageMonitor(
+ defaults: defaults,
+ historyDirectory: temporaryDirectory(),
+ startsAutomatically: false,
+ fetchUsage: { try await source.next() }
+ )
+
+ await monitor.refresh()
+ await monitor.refreshAccountIfStale(
+ now: fetchedAt.addingTimeInterval(599)
+ )
+ var callCount = await source.callCount
+ XCTAssertEqual(callCount, 1)
+
+ await monitor.refreshAccountIfStale(
+ now: fetchedAt.addingTimeInterval(600)
+ )
+ callCount = await source.callCount
+ XCTAssertEqual(callCount, 2)
+ }
+
func testBlockedEvaluationDoesNotFreezeMainActorOrPublishAStaleSnapshot() async throws {
let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
@@ -802,6 +840,10 @@ final class UsageMonitorHistoryTests: XCTestCase {
)
await firstMonitor.refresh()
await firstMonitor.refresh()
+ XCTAssertNil(firstMonitor.readerSnapshot.localTokenActivity.tokens)
+
+ await firstMonitor.setLocalAnalyticsVisible(true)
+
XCTAssertEqual(
firstMonitor.readerSnapshot.localTokenActivity.tokens,
400
@@ -818,6 +860,9 @@ final class UsageMonitorHistoryTests: XCTestCase {
fetchUsage: { throw CodexClientError.invalidResponse }
)
await restarted.refresh()
+ XCTAssertNil(restarted.readerSnapshot.localTokenActivity.tokens)
+
+ await restarted.setLocalAnalyticsVisible(true)
XCTAssertEqual(restarted.readerSnapshot.localTokenActivity.tokens, 400)
XCTAssertEqual(restarted.readerSnapshot.localTokenActivity.coverage, .low)
@@ -827,7 +872,139 @@ final class UsageMonitorHistoryTests: XCTestCase {
)
}
- func testMonitorFinishesABoundedLocalImportWithoutAnotherAccountRead()
+ func testLocalCollectorReadsOnlyForVisibleAnalyticsAndManualRefresh()
+ async throws
+ {
+ let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let root = temporaryDirectory()
+ let localRoot = root.appendingPathComponent(
+ "rollouts",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: localRoot,
+ withIntermediateDirectories: true
+ )
+ let requests = RequestCounter()
+ let fetches = DelayedFetchSource(
+ makeFetchResult(
+ identity: "user@example.com",
+ fetchedAt: Date(timeIntervalSince1970: 1_700_000),
+ remaining: 90
+ )
+ )
+ let monitor = UsageMonitor(
+ defaults: defaults,
+ historyDirectory: root.appendingPathComponent("history"),
+ startsAutomatically: false,
+ localActivityCollector: LocalActivityCollector(
+ rootDirectory: localRoot,
+ stateDirectory: root.appendingPathComponent("local-state"),
+ projectionSource: ReadOnlyThreadProjectionSource { _ in
+ await requests.record()
+ return Data(
+ #"{"result":{"data":[],"nextCursor":null}}"#.utf8
+ )
+ }
+ ),
+ fetchUsage: { try await fetches.next() }
+ )
+
+ await monitor.start()
+ var requestCount = await requests.count
+ XCTAssertEqual(requestCount, 0)
+
+ await monitor.refresh()
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 0)
+
+ for graph in AnalyticsGraph.coreCases {
+ var state = AnalyticsExplorationState.initial
+ state.graph = graph
+ await monitor.setLocalAnalyticsVisible(
+ state.usesLocalAnalytics
+ )
+ }
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 0)
+
+ let automatic = Task { @MainActor in
+ await monitor.automaticRefresh()
+ }
+ try await Task.sleep(for: .milliseconds(10))
+ await monitor.setLocalAnalyticsVisible(true)
+ await automatic.value
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 1)
+ await monitor.setLocalAnalyticsVisible(true)
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 1)
+
+ await monitor.automaticRefresh()
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 1)
+ await monitor.refresh()
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 2)
+
+ await monitor.setLocalAnalyticsVisible(false)
+ await monitor.refresh()
+ requestCount = await requests.count
+ XCTAssertEqual(requestCount, 2)
+ }
+
+ func testHidingAnalyticsDiscardsAnInFlightLocalRead() async throws {
+ let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ let root = temporaryDirectory()
+ let localRoot = root.appendingPathComponent(
+ "rollouts",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: localRoot,
+ withIntermediateDirectories: true
+ )
+ let gate = ProjectionGate()
+ let fetchResult = makeFetchResult(
+ identity: "user@example.com",
+ fetchedAt: Date(timeIntervalSince1970: 1_700_000),
+ remaining: 90
+ )
+ let monitor = UsageMonitor(
+ defaults: defaults,
+ historyDirectory: root.appendingPathComponent("history"),
+ startsAutomatically: false,
+ localActivityCollector: LocalActivityCollector(
+ rootDirectory: localRoot,
+ stateDirectory: root.appendingPathComponent("local-state"),
+ projectionSource: ReadOnlyThreadProjectionSource { _ in
+ await gate.response()
+ }
+ ),
+ fetchUsage: { fetchResult }
+ )
+ await monitor.refresh()
+
+ let load = Task { @MainActor in
+ await monitor.setLocalAnalyticsVisible(true)
+ }
+ await gate.waitUntilStarted()
+ let hide = Task { @MainActor in
+ await monitor.setLocalAnalyticsVisible(false)
+ }
+ try await Task.sleep(for: .milliseconds(10))
+ await gate.release()
+ await load.value
+ await hide.value
+
+ XCTAssertNil(monitor.readerSnapshot.localTokenActivity.tokens)
+ }
+
+ func testMonitorFinishesABoundedLocalImportAcrossAutomaticRefresh()
async throws
{
let suiteName = "UsageMonitorHistoryTests-\(UUID().uuidString)"
@@ -876,6 +1053,11 @@ final class UsageMonitorHistoryTests: XCTestCase {
identity: "user@example.com",
fetchedAt: fetchedAt,
remaining: 80
+ ),
+ makeFetchResult(
+ identity: "user@example.com",
+ fetchedAt: fetchedAt.addingTimeInterval(60),
+ remaining: 79
)
])
let monitor = UsageMonitor(
@@ -886,9 +1068,11 @@ final class UsageMonitorHistoryTests: XCTestCase {
fetchUsage: { try await fetches.next() }
)
+ await monitor.setLocalAnalyticsVisible(true)
await monitor.refresh()
try Data(rollout.utf8).write(to: rolloutURL)
await monitor.refresh()
+ await monitor.automaticRefresh()
for _ in 0 ..< 100 {
if await collector.hasPendingImport() == false,
monitor.readerSnapshot.localTokenActivity.tokens == 1_009_900 {
@@ -904,7 +1088,7 @@ final class UsageMonitorHistoryTests: XCTestCase {
1_009_900
)
let accountReadCount = await fetches.callCount
- XCTAssertEqual(accountReadCount, 2)
+ XCTAssertEqual(accountReadCount, 3)
}
func testMissingWeeklyRefreshClearsWeeklyOutputsAndKeepsOtherLimits() async throws {
@@ -1861,6 +2045,43 @@ private actor FetchSequence {
var callCount: Int { calls }
}
+private actor RequestCounter {
+ private var requests = 0
+
+ func record() {
+ requests += 1
+ }
+
+ var count: Int { requests }
+}
+
+private actor ProjectionGate {
+ private var started = false
+ private var continuation: CheckedContinuation?
+
+ func response() async -> Data {
+ started = true
+ return await withCheckedContinuation { continuation in
+ self.continuation = continuation
+ }
+ }
+
+ func waitUntilStarted() async {
+ while !started {
+ await Task.yield()
+ }
+ }
+
+ func release() {
+ continuation?.resume(
+ returning: Data(
+ #"{"result":{"data":[],"nextCursor":null}}"#.utf8
+ )
+ )
+ continuation = nil
+ }
+}
+
private actor DelayedFetchSource {
private let result: CodexFetchResult
private var calls = 0
diff --git a/docs/research/app-server-architecture.md b/docs/research/app-server-architecture.md
new file mode 100644
index 0000000..1c7e2f4
--- /dev/null
+++ b/docs/research/app-server-architecture.md
@@ -0,0 +1,245 @@
+# App Server as the light core
+
+Date: 2026-07-30
+Source baseline: installed stable `codex-cli 0.145.0`; official tag [`rust-v0.145.0`](https://github.com/openai/codex/tree/25af12f7e61572b0bc18ddb1008be543b91519b0), commit `25af12f7e61572b0bc18ddb1008be543b91519b0`
+
+## Decision
+
+Use Codex App Server as the only source for the default account view.
+
+The light core should:
+
+1. Reuse the existing persistent App Server process.
+2. Read `account/rateLimits/read` and `account/usage/read` after connection.
+3. Reconcile sparse rate-limit updates seen during account reads.
+4. Keep a small local cache of the last good account responses.
+5. Never scan rollout JSONL, import local token facts, or sync derived token facts in the default mode.
+
+This is a high-confidence cut for Codex CLI 0.145.0.
+
+The proposed `synced_token_fact` design may suit a later, optional local analytics mode. It should not enter the light core. The main Token Activity data already comes from the account, so a second cross-Mac copy would add work without adding data.
+
+## What the stable API supplies
+
+| Need | Stable source | What it supplies | Important limit |
+|---|---|---|---|
+| Usage remaining and reset time | `account/rateLimits/read` | Primary and secondary windows, `usedPercent`, window length, reset time, all named limit buckets | The percent is not a token quota |
+| Live limit change | `account/rateLimits/updated` | One sparse rate-limit snapshot | It does not include all buckets or banked reset details |
+| Banked resets | `account/rateLimits/read` | Authoritative available count and, when present, reset details and expiry dates | Detail rows may be absent or capped; there is no reset-credit event |
+| Account Token Activity | `account/usage/read` | Daily token buckets and account summary facts | Daily only; no update event; retention and bucket time zone are not promised |
+| Account facts | `account/usage/read` | Lifetime tokens, peak daily tokens, longest turn, current streak, longest streak | Each field may be absent |
+| Local Task and project list | `thread/list` with `useStateDbOnly: true` | Stored Task metadata, working directory, parent link, source, dates and status | No token history, actual model ID, or reasoning effort |
+| Live token use for one Task | `thread/tokenUsage/updated` | Thread ID, turn ID, cumulative and last token use, context-window size | Only for a Task started, forked, or resumed by that App Server connection |
+
+The contracts appear in the official [App Server guide](https://developers.openai.com/codex/app-server), [`account.rs`](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/account.rs), [`thread.rs`](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/thread.rs), and [`thread_data.rs`](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs). They also appear in the stable schema generated by the installed CLI.
+
+None of these methods needs the experimental API flag in 0.145.0.
+
+## Account Token Activity needs no cross-Mac sync
+
+`account/usage/read` returns account data, not a scan of the current Mac. Two Macs signed in to the same Codex account and workspace should therefore receive the same daily Token Activity, subject to refresh time and backend delay.
+
+Codex Limits should not publish or import its own copy of those daily buckets. Each Mac can cache the last response for offline display. The cache is disposable and does not need folder sync.
+
+This removes the main reasons for the proposed remote-fact path:
+
+- no shared writer;
+- no project projection merge;
+- no remote generation import;
+- no remote fact deduplication;
+- no cross-Mac Token Activity coverage calculation.
+
+The same conclusion does **not** apply to future local Task receipts. Those facts describe work observed by one Mac. If the product later syncs them, the per-device manifest and separate `synced_token_fact` table are a sound starting boundary. That later design still needs a proven event identity. `thread/tokenUsage/updated` gives a cumulative snapshot, not a documented stable event ID, so deduplication cannot yet rest on a generic `eventID`.
+
+## There is no `account/usage/updated`
+
+The stable 0.145.0 protocol defines `account/usage/read`, but no account-usage notification. The App Server guide, generated stable schema, and official protocol source contain no `account/usage/updated` method.
+
+Token Activity therefore needs a read:
+
+- after the first connection;
+- after reconnect;
+- after wake when the cached response is old;
+- on explicit refresh;
+- on a slow schedule, or near a daily bucket boundary.
+
+Opening the menu should show the cache. It should not trigger a full read on every open.
+
+Rate limits differ. `account/rateLimits/updated` exists, but it is a sparse rolling update. The client must merge values into the last full snapshot by `limitId`. A missing optional value does not clear an older value. The event carries no banked reset summary, so Codex Limits must still read the full snapshot after reset use, reconnect, wake, or another reason to suspect missed events. The official source describes this merge-or-refetch rule in [`AccountRateLimitsUpdatedNotification`](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/account.rs).
+
+## One connection, not one process per refresh
+
+The official transport is JSONL over stdio. A client sends `initialize` exactly once for each transport connection, then `initialized`, and keeps reading responses and events. The App Server uses bounded queues; when it reports `-32001` because it is busy, the client should retry with exponential backoff and jitter. See the official [App Server README](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server/README.md).
+
+Codex Limits already has most of the process lifetime needed for this design:
+
+- [`CodexClient.shared`](../../Sources/CodexLimits/CodexClient.swift) owns a stored connection;
+- `activeConnection()` reuses the running process while the Codex executable identity stays the same;
+- `invalidateConnection()` stops it after a failed connection and allows a new one.
+
+The pivot does not need a second service or a new language. It needs a better reader around the existing connection.
+
+Today, Codex Limits reads stdout only while waiting for a request response. A true event-driven client would need one background reader that owns stdout for the life of the connection. That is a later transport change, not part of this lightweight-core cut. It should:
+
+1. Route responses to the pending request by ID.
+2. Apply rate-limit events to the cache.
+3. Treat EOF or a dead child as a lost connection.
+4. Start a new process, initialize once, and read fresh account snapshots.
+5. Back off after repeated failure.
+
+The official protocol does not promise replay of missed account events. Refetching both account snapshots after reconnect is therefore a client recovery rule, not a server guarantee.
+
+## `thread/list` avoids JSONL repair, but it is not account usage
+
+`thread/list` with `useStateDbOnly: true` reads the Codex state database without scanning rollout JSONL to repair metadata. Omit the flag and the server may scan rollouts to repair the list.
+
+This method is enough for a lazy local list of:
+
+- Tasks;
+- Codex project labels derived from `cwd`;
+- parent links already stored by Codex;
+- created, updated, and recent dates.
+
+It is not enough for per-Task token use, model, reasoning, concurrency, or receipts. The stable `Thread` record includes `modelProvider`, but not the effective model ID or reasoning effort.
+
+The response also contains fields Codex Limits should not retain, such as the Task preview, full path, and Git metadata. The app should extract its small allowlist and drop the response.
+
+`useStateDbOnly: true` trades repair for speed. If Codex has not yet placed a Task in its state database, this call does not scan JSONL to recover it. That is the correct trade for the light core.
+
+## Task token events are not a global feed
+
+`thread/tokenUsage/updated` is an active-Task notification. A connection receives Task events after it starts, forks, or resumes that Task. `thread/list` and `thread/read` do not subscribe to the Task.
+
+The server can replay the last persisted cumulative token count after `thread/resume`, as shown by the official [`token_usage_replay.rs`](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server/src/request_processors/token_usage_replay.rs). This is not a history API:
+
+- it does not list past token events;
+- it does not backfill every Task;
+- it can rebuild persisted thread history;
+- resuming a Task makes the caller a live subscriber and crosses the product's read-only Task boundary.
+
+A separate Codex Limits App Server process does not receive live Task events owned by another Codex process. Its subscription registry is process-local. This is covered in the prior [local activity source spike](local-activity-source-spike-2026-07-27.md).
+
+For the light core, do not use Task token events. For later optional analytics, collect them only for Tasks the analytics connection truly observes, and state the coverage. Do not promise complete historical backfill.
+
+## What `account/usage/read` can and cannot support
+
+It can support:
+
+- the main daily Token Activity graph;
+- daily, weekly, four-week, and twelve-week sums when the returned buckets cover the selected range;
+- lifetime and peak daily token facts;
+- account trends shared across the user's Macs.
+
+It cannot support:
+
+- an exact hidden token allowance;
+- a model, project, Task, agent, or reasoning breakdown;
+- per-turn receipts;
+- concurrency;
+- exact intra-day timing;
+- history older than the backend returns.
+
+Daily buckets also make partial boundary days uncertain. A four-week or twelve-week comparison should use complete returned days and show that limit. If the backend does not return the full range, the UI should say that the range is unavailable. It should not start a JSONL scan as a silent fallback.
+
+Account token activity and limit use are different measures. The app may show both, but it must not infer a true token quota from daily tokens and `usedPercent`.
+
+`account/usage/read` also requires Codex-service-backed authentication. API-key-only and Bedrock modes do not supply this account profile. In those modes, show Token Activity as unavailable. Do not turn on local history scanning without the user's action.
+
+## OpenTelemetry belongs to optional analytics
+
+Codex can export OpenTelemetry logs, traces, and metrics. The official [observability guide](https://developers.openai.com/codex/config-advanced#observability-and-telemetry) documents:
+
+- async OTLP export over HTTP or gRPC;
+- per-turn token-use metrics split into input, cached input, output, and reasoning output;
+- model and session tags;
+- turn and tool timing;
+- prompt logging off by default.
+
+It is a useful future source, but not a light-core dependency:
+
+- the user must change Codex config;
+- Codex Limits must run or connect to an OTLP receiver;
+- collection starts after enablement and has no promised history backfill;
+- documented metrics are aggregates, not Task receipts;
+- logs can contain tool-result snippets even when prompt content stays off.
+
+Use OpenTelemetry only behind a clear extra-analytics switch and a privacy review. Do not enable it or edit Codex config in the default mode.
+
+## Delivered light-core architecture
+
+```text
+Codex App Server, one supervised stdio child
+ ├─ account/rateLimits/read ─┐
+ ├─ rateLimits/updated ──────┼─> bounded account cache ─> Usage remaining
+ │ (seen during reads) │
+ └─ account/usage/read ──────┘ └─> Token Activity
+
+Optional later analytics
+ ├─ thread/list(useStateDbOnly: true)
+ ├─ observed Task events
+ ├─ explicit, slow JSONL history import
+ └─ user-enabled OpenTelemetry
+```
+
+SQLite may remain as the bounded cache or local store. It is not the cause of the current load. The costly work comes from scanning, decoding, normalizing, projecting, and syncing data that the account API already supplies.
+
+## Remove or defer
+
+Remove from the light-core path:
+
+- rollout JSONL discovery and scan;
+- automatic local token-fact import;
+- task projections built for the account graph;
+- cross-Mac Token Activity export and import;
+- generation merge logic for account token activity;
+- refresh on each menu open.
+
+Defer:
+
+- per-Task and per-agent token use;
+- model and reasoning breakdown;
+- concurrency;
+- receipts;
+- passive and Codex-assisted insights based on local Source Content;
+- OTLP receiver;
+- remote `synced_token_fact` and device manifests.
+
+The UI can keep these views hidden until their source is enabled and has data. It should not show a loading state for work the default mode never starts.
+
+## Delivery checks
+
+Before release, verify on both test Macs:
+
+1. Default launch and menu open read no rollout JSONL.
+2. One Codex Limits process owns at most one ordinary App Server child.
+3. Opening and closing the menu does not start another child or rescan history.
+4. A sparse rate-limit event seen during a read triggers a bounded full
+ account reconciliation; opening the menu alone does not refresh.
+5. Reconnect reads fresh rate limits, banked resets, account usage, and account identity.
+6. Token Activity comes only from `account/usage/read`.
+7. Two Macs on the same account and workspace show the same complete daily buckets within normal backend and refresh delay.
+8. A missing or unsupported `account/usage/read` shows a clear unavailable state and starts no fallback scan.
+9. Memory and refresh time stay within the agreed budgets on real account data.
+
+## First-machine measurement
+
+Measured on the developer Mac with real account data on 2026-07-30:
+
+- one Codex Limits process and one App Server child;
+- no open rollout JSONL or local analytics store;
+- steady `top` memory: about 45 MB for Codex Limits and 41–58 MB for App Server;
+- peak refresh CPU: 19.2% for Codex Limits and 1.4% for App Server;
+- no measured memory increase during refresh;
+- Token Activity rendered in about one second, including the UI test tool's settle time.
+
+The second-Mac check remains a release gate.
+
+## Sources
+
+- [Codex App Server guide](https://developers.openai.com/codex/app-server)
+- [App Server README and protocol lifecycle](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server/README.md)
+- [Stable account protocol](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/account.rs)
+- [Stable thread request protocol](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/thread.rs)
+- [Stable Thread data](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs)
+- [Persisted token-usage replay](https://github.com/openai/codex/blob/25af12f7e61572b0bc18ddb1008be543b91519b0/codex-rs/app-server/src/request_processors/token_usage_replay.rs)
+- [Observability and telemetry](https://developers.openai.com/codex/config-advanced#observability-and-telemetry)