diff --git a/CHANGELOG.md b/CHANGELOG.md index fe5746b665..1f1a500e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Pi: add standalone local Pi/OMP token and estimated-cost history across the app, CLI, Overview, Usage & Spend, and widgets, with scoped cache recovery and source accounting that prevents duplicate Claude/Codex totals (#3246). Thanks @Yuxin-Qiao! - Dashboard: include provider-reported 30-day USD spend when no local cost row exists, preserving OpenRouter's completed UTC history without inventing a local Today total (#3748). Thanks @Chipagosfinest! - Widgets: select DeepSeek and OpenRouter, see their balances, and keep live update ages visible in small widgets (#3743). Thanks @brzvsk! diff --git a/README.md b/README.md index e86dc96731..30388c1f28 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ [![License: MIT](https://img.shields.io/badge/license-MIT-6e5aff?style=flat-square)](LICENSE) [![Site](https://img.shields.io/badge/site-codexbar.app-16d3b4?style=flat-square)](https://codexbar.app) -CodexBar — every AI coding limit in your menu bar. 74 providers. +CodexBar — every AI coding limit in your menu bar. 75 providers. Tiny macOS 14+ menu bar app that keeps **AI coding-provider limits visible** and shows when each window resets. Codex, OpenAI, Claude, Cursor, Gemini, Copilot, Grok, GroqCloud, ElevenLabs, Deepgram, z.ai, MiniMax, Kiro, Zed, Vertex AI, Augment, OpenRouter, LiteLLM, LLM Proxy, Codebuff, Command Code, ClinePass, AWS Bedrock, and many newer coding providers. One status item per provider, or Merge Icons mode with a provider switcher. No Dock icon, minimal UI, dynamic bar icons. diff --git a/Sources/CodexBar/LoadingPattern.swift b/Sources/CodexBar/LoadingPattern.swift index a32fbe4060..e1e7108cae 100644 --- a/Sources/CodexBar/LoadingPattern.swift +++ b/Sources/CodexBar/LoadingPattern.swift @@ -24,6 +24,7 @@ enum LoadingPattern: String, CaseIterable, Identifiable { } /// Secondary offset so the lower bar moves differently. + /// Provider-specific by design: these are mathematical phase constants, not provider cases. var secondaryOffset: Double { switch self { case .knightRider: .pi diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index 168fb31632..0d0dfd667b 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -568,7 +568,7 @@ struct SpendDashboardCurrencySection: View { value: UsageFormatter.currencyString(metered, currencyCode: self.group.currencyCode)) } SpendSummaryValue( - title: L("Subscriptions"), + title: spendDashboardProviderCountTitle(self.group), value: codexBarLocalizedInteger(self.group.providers.count)) Spacer() } @@ -643,7 +643,7 @@ private struct SpendProviderPanel: View { var body: some View { SpendDashboardPanel { VStack(alignment: .leading, spacing: 0) { - Text(L("By subscription")).font(.headline).padding(.bottom, 8) + Text(spendDashboardProviderPanelTitle(self.group)).font(.headline).padding(.bottom, 8) ForEach(self.group.providers) { row in if row.rank > 1 { Divider() @@ -1487,8 +1487,22 @@ func spendDashboardGroupTokenText(_ group: SpendDashboardModel.CurrencyGroup) -> return group.hasPartialTokens ? "~\(formatted)" : formatted } -func spendDashboardPartialSubscriptionsText(_ group: SpendDashboardModel.CurrencyGroup) -> String { - L("%d of %d subscriptions have spend", group.pricedProviderCount, group.providers.count) +private func spendDashboardIncludesLocalHistory(_ group: SpendDashboardModel.CurrencyGroup) -> Bool { + group.providers.contains { $0.sourceKind == .localHistory } +} + +func spendDashboardProviderCountTitle(_ group: SpendDashboardModel.CurrencyGroup) -> String { + spendDashboardIncludesLocalHistory(group) ? L("Sources") : L("Subscriptions") +} + +func spendDashboardProviderPanelTitle(_ group: SpendDashboardModel.CurrencyGroup) -> String { + spendDashboardIncludesLocalHistory(group) ? L("By source") : L("By subscription") +} + +func spendDashboardPartialSourceCoverageText(_ group: SpendDashboardModel.CurrencyGroup) -> String { + let template = spendDashboardIncludesLocalHistory(group) + ? "%d of %d sources have spend" : "%d of %d subscriptions have spend" + return L(template, group.pricedProviderCount, group.providers.count) } func spendDashboardHistoryCaption( @@ -1499,7 +1513,7 @@ func spendDashboardHistoryCaption( if group.hasPartialCost || group.hasPartialTokens { parts.append(L("Partial estimate")) if group.hasPartialCost { - parts.append(spendDashboardPartialSubscriptionsText(group)) + parts.append(spendDashboardPartialSourceCoverageText(group)) } } else { parts.append(L("Local estimated history")) diff --git a/Sources/CodexBar/Providers/Pi/PiProviderImplementation.swift b/Sources/CodexBar/Providers/Pi/PiProviderImplementation.swift new file mode 100644 index 0000000000..a96f7ce922 --- /dev/null +++ b/Sources/CodexBar/Providers/Pi/PiProviderImplementation.swift @@ -0,0 +1,6 @@ +import CodexBarCore +import Foundation + +struct PiProviderImplementation: ProviderImplementation { + let id: UsageProvider = .pi +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift index 65b5679788..ccbd7e3f51 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationManifest.swift @@ -79,5 +79,6 @@ enum ProviderImplementationManifest { CodeRabbitProviderImplementation(), ReplicateProviderImplementation(), HuggingFaceProviderImplementation(), + PiProviderImplementation(), ] } diff --git a/Sources/CodexBar/Resources/ProviderIcon-pi.svg b/Sources/CodexBar/Resources/ProviderIcon-pi.svg new file mode 100644 index 0000000000..579e61c618 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-pi.svg @@ -0,0 +1,3 @@ + + + diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 7f8dcaaec2..467c8182f7 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1352,6 +1352,8 @@ "Tracked tokens" = "الرموز المتتبعة"; "Subscriptions" = "الاشتراكات"; "By subscription" = "حسب الاشتراك"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "لا يوجد سجل على مستوى النموذج"; "Daily estimated spend" = "الإنفاق اليومي التقديري"; "Hourly estimated spend" = "الإنفاق الساعي التقديري"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index fb1da5d7e0..a9f43aff30 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1351,6 +1351,8 @@ "Tracked tokens" = "Tokens registrats"; "Subscriptions" = "Subscripcions"; "By subscription" = "Per subscripció"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Sense historial per model"; "Daily estimated spend" = "Despesa diària estimada"; "Hourly estimated spend" = "Despesa horària estimada"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 758e7c3ada..dffb566f60 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1349,6 +1349,8 @@ "Tracked tokens" = "Erfasste Token"; "Subscriptions" = "Abonnements"; "By subscription" = "Nach Abonnement"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Kein Verlauf auf Modellebene"; "Daily estimated spend" = "Geschätzte tägliche Ausgaben"; "Hourly estimated spend" = "Geschätzte stündliche Ausgaben"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index ba05ea4c71..807a531189 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1336,6 +1336,8 @@ "Tracked tokens" = "Tracked tokens"; "Subscriptions" = "Subscriptions"; "By subscription" = "By subscription"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "No model-level history"; "Daily estimated spend" = "Daily estimated spend"; "Hourly estimated spend" = "Hourly estimated spend"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index fd956b39f0..6300778e99 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1347,6 +1347,8 @@ "Tracked tokens" = "Tokens registrados"; "Subscriptions" = "Suscripciones"; "By subscription" = "Por suscripción"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "No hay historial por modelo"; "Daily estimated spend" = "Gasto diario estimado"; "Hourly estimated spend" = "Gasto horario estimado"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 69051ae5b2..59d49c257e 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1352,6 +1352,8 @@ "Tracked tokens" = "توکن‌های پیگیری‌شده"; "Subscriptions" = "اشتراک‌ها"; "By subscription" = "بر اساس اشتراک"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "تاریخچه‌ای در سطح مدل وجود ندارد"; "Daily estimated spend" = "برآورد هزینه روزانه"; "Hourly estimated spend" = "برآورد هزینه ساعتی"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 53a02c7ca5..79e8a518ef 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1348,6 +1348,8 @@ "Tracked tokens" = "Jetons suivis"; "Subscriptions" = "Abonnements"; "By subscription" = "Par abonnement"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Aucun historique au niveau des modèles"; "Daily estimated spend" = "Dépenses quotidiennes estimées"; "Hourly estimated spend" = "Dépenses horaires estimées"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index ba91d4ebdb..094344e4ca 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1348,6 +1348,8 @@ "Tracked tokens" = "Tokens rexistrados"; "Subscriptions" = "Subscricións"; "By subscription" = "Por subscrición"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Sen historial por modelo"; "Daily estimated spend" = "Gasto diario estimado"; "Hourly estimated spend" = "Gasto horario estimado"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 12c1821169..9cecd12a4a 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1352,6 +1352,8 @@ "Tracked tokens" = "Token yang dilacak"; "Subscriptions" = "Langganan"; "By subscription" = "Berdasarkan langganan"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Tidak ada riwayat tingkat model"; "Daily estimated spend" = "Perkiraan pengeluaran harian"; "Hourly estimated spend" = "Perkiraan pengeluaran per jam"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index fb565cf7b0..75c111dd76 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1352,6 +1352,8 @@ "Tracked tokens" = "Token tracciati"; "Subscriptions" = "Abbonamenti"; "By subscription" = "Per abbonamento"; +"By source" = "Per fonte"; +"%d of %d sources have spend" = "%d di %d fonti hanno dati di spesa"; "No model-level history" = "Nessuna cronologia a livello di modello"; "Daily estimated spend" = "Spesa giornaliera stimata"; "Hourly estimated spend" = "Spesa oraria stimata"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index fafe5c0794..89c38b29d7 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1349,6 +1349,8 @@ "Tracked tokens" = "追跡対象トークン"; "Subscriptions" = "サブスクリプション"; "By subscription" = "サブスクリプション別"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "モデル別の履歴はありません"; "Daily estimated spend" = "日別推定支出"; "Hourly estimated spend" = "時間別推定支出"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 93de73eb06..a73d157881 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1318,6 +1318,8 @@ "Tracked tokens" = "추적된 토큰"; "Subscriptions" = "구독"; "By subscription" = "구독별"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "모델별 내역이 없습니다"; "Daily estimated spend" = "일별 예상 지출"; "Hourly estimated spend" = "시간별 예상 지출"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 875fe640f9..4b37133301 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1348,6 +1348,8 @@ "Tracked tokens" = "Bijgehouden tokens"; "Subscriptions" = "Abonnementen"; "By subscription" = "Per abonnement"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Geen geschiedenis op modelniveau"; "Daily estimated spend" = "Geschatte dagelijkse uitgaven"; "Hourly estimated spend" = "Geschatte uitgaven per uur"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 23ab188840..8181e1c2fb 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1352,6 +1352,8 @@ "Tracked tokens" = "Śledzone tokeny"; "Subscriptions" = "Subskrypcje"; "By subscription" = "Według subskrypcji"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Brak historii na poziomie modeli"; "Daily estimated spend" = "Szacowane dzienne wydatki"; "Hourly estimated spend" = "Szacowane wydatki godzinowe"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 7ffd690dd6..ceb3eb09b8 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1349,6 +1349,8 @@ "Tracked tokens" = "Tokens acompanhados"; "Subscriptions" = "Assinaturas"; "By subscription" = "Por assinatura"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Sem histórico por modelo"; "Daily estimated spend" = "Gasto diário estimado"; "Hourly estimated spend" = "Gasto horário estimado"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 5399d17474..03f26d7c00 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1350,6 +1350,8 @@ "Tracked tokens" = "Отслеживаемые токены"; "Subscriptions" = "Подписки"; "By subscription" = "По подпискам"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Нет истории по моделям"; "Daily estimated spend" = "Предполагаемые ежедневные расходы"; "Hourly estimated spend" = "Предполагаемые почасовые расходы"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 6b4d624556..9e74a21027 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1347,6 +1347,8 @@ "Tracked tokens" = "Spårade token"; "Subscriptions" = "Abonnemang"; "By subscription" = "Per abonnemang"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Ingen historik på modellnivå"; "Daily estimated spend" = "Uppskattade dagliga utgifter"; "Hourly estimated spend" = "Uppskattade utgifter per timme"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index d9291ce526..1fd05badd6 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1352,6 +1352,8 @@ "Tracked tokens" = "โทเค็นที่ติดตาม"; "Subscriptions" = "การสมัครสมาชิก"; "By subscription" = "แยกตามการสมัครสมาชิก"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "ไม่มีประวัติระดับโมเดล"; "Daily estimated spend" = "ค่าใช้จ่ายรายวันโดยประมาณ"; "Hourly estimated spend" = "ค่าใช้จ่ายรายชั่วโมงโดยประมาณ"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 4b49027aaa..d79a936e9d 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1350,6 +1350,8 @@ "Tracked tokens" = "İzlenen tokenlar"; "Subscriptions" = "Abonelikler"; "By subscription" = "Aboneliğe göre"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Model düzeyinde geçmiş yok"; "Daily estimated spend" = "Günlük tahmini harcama"; "Hourly estimated spend" = "Saatlik tahmini harcama"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 65b257828c..c412776d5a 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1348,6 +1348,8 @@ "Tracked tokens" = "Відстежувані токени"; "Subscriptions" = "Підписки"; "By subscription" = "За підписками"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Немає історії за моделями"; "Daily estimated spend" = "Орієнтовні щоденні витрати"; "Hourly estimated spend" = "Орієнтовні погодинні витрати"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index bc0a215172..88c20717a3 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1349,6 +1349,8 @@ "Tracked tokens" = "Token được theo dõi"; "Subscriptions" = "Gói đăng ký"; "By subscription" = "Theo gói đăng ký"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "Không có lịch sử theo mô hình"; "Daily estimated spend" = "Chi tiêu ước tính hằng ngày"; "Hourly estimated spend" = "Chi tiêu ước tính theo giờ"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 51e8873e97..ed99fb7a74 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1331,6 +1331,8 @@ "Tracked tokens" = "已跟踪 token"; "Subscriptions" = "订阅"; "By subscription" = "按订阅"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "暂无模型级历史"; "Daily estimated spend" = "每日估算支出"; "Hourly estimated spend" = "每小时估算支出"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index a9c307662a..34e0aadede 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1379,6 +1379,8 @@ "Tracked tokens" = "已追蹤 token"; "Subscriptions" = "訂閱"; "By subscription" = "依訂閱"; +"By source" = "By source"; +"%d of %d sources have spend" = "%d of %d sources have spend"; "No model-level history" = "尚無模型層級歷史"; "Daily estimated spend" = "每日預估支出"; "Hourly estimated spend" = "每小時預估支出"; diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index d57fd06ddc..65d0defe05 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -23,8 +23,13 @@ extension SettingsStore { Task { @MainActor [weak self] in guard let self else { return } + let environment = ProcessInfo.processInfo.environment let hasSources = await Task.detached(priority: .utility) { - Self.hasAnyTokenCostUsageSources() + let processContexts = await LocalAgentSessionScanner().piSessionProcessContexts( + environment: environment) + return Self.hasAnyTokenCostUsageSources( + env: environment, + processContexts: processContexts) }.value guard hasSources else { return } guard UserDefaults.standard.object(forKey: "tokenCostUsageEnabled") == nil else { return } @@ -36,9 +41,10 @@ extension SettingsStore { env: [String: String] = ProcessInfo.processInfo.environment, fileManager: FileManager = .default, homeDirectory: URL? = nil, - workingDirectory: URL? = nil) -> Bool + workingDirectory: URL? = nil, + processContexts: [PiSessionProcessContext] = []) -> Bool { - // Provider-specific by design: only Codex and Claude have local JSONL scanners that can auto-enable token cost. + // Provider-specific by design: Codex, Claude, and Pi-family stores can auto-enable token cost. let home = homeDirectory ?? fileManager.homeDirectoryForCurrentUser func hasAnyJsonl(in root: URL) -> Bool { @@ -79,6 +85,21 @@ extension SettingsStore { return true } + var piEnvironment = env + if piEnvironment["HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { + piEnvironment["HOME"] = home.path + } + let piBaseDirectory = workingDirectory ?? URL( + fileURLWithPath: fileManager.currentDirectoryPath, + isDirectory: true) + let piRoots = PiFamilySessionRootResolver.costSessionRootURLs( + environment: piEnvironment, + baseDirectory: piBaseDirectory, + processContexts: processContexts) + if piRoots.contains(where: hasAnyJsonl(in:)) { + return true + } + let claudeRoots: [URL] = { if let configuredRoot = env[ClaudeConfigPaths.configDirectoryEnvironmentKey], !configuredRoot.isEmpty diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index a4d76e8167..ef9c74feaa 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -285,6 +285,14 @@ enum SpendDashboardSource { force: mode.forcesLoader) } + // A native projection is disjoint from the inclusive Claude/Codex publication while + // Pi owns the same rows, even when the Pi input is hidden from the chart. + let piBaseline = providerBaselines.first { $0.provider == .pi } + let piCurrent = self.capturedTokenPublication(store: store, provider: .pi) + let piOwnsSource = providers.contains(.pi) + && piBaseline != nil + && piCurrent.publication?.snapshot != nil + && !(piBaseline?.shouldRefresh == true && piBaseline?.publicationRevision == piCurrent.revision) var inputs: [SpendDashboardModel.ProviderInput] = [] var unavailableSourceIDs: Set = [] var confirmedEmptySourceIDs: Set = [] @@ -324,7 +332,8 @@ enum SpendDashboardSource { guard let snapshot = self.dashboardTokenSnapshot( store: store, provider: provider, - publication: currentPublication) + publication: currentPublication, + piOwnsSource: piOwnsSource) else { confirmedEmptySourceIDs.insert(provider.rawValue) continue @@ -332,7 +341,10 @@ enum SpendDashboardSource { inputs.append(SpendDashboardModel.ProviderInput( provider: provider, displayName: store.metadata(for: provider).displayName, - snapshot: snapshot)) + snapshot: snapshot, + // Provider-specific by design: Pi reports local history rather than a subscription feed. + sourceKind: provider == .pi ? .localHistory : .native, + accounting: currentPublication.accounting)) } return SpendDashboardLoadRequest( configuration: configuration, @@ -869,7 +881,8 @@ enum SpendDashboardSource { private static func dashboardTokenSnapshot( store: UsageStore, provider: UsageProvider, - publication: CurrentProviderConfigTokenPublication) -> CostUsageTokenSnapshot? + publication: CurrentProviderConfigTokenPublication, + piOwnsSource: Bool) -> CostUsageTokenSnapshot? { // Provider-specific by design: Grok's catalog input is the local session scan, even when // the remote billing snapshot is missing. @@ -888,6 +901,17 @@ enum SpendDashboardSource { { return derived } + // An inclusive publication can predate Pi becoming a separate source. + // Project its retained native portion while the replacement refresh is pending, + // so the combined rows stay disjoint throughout that transition. + // Provider-specific by design: Claude and Codex publications expose a native projection when Pi is + // accounted for separately in the combined dashboard. + if piOwnsSource, + provider == .claude || provider == .codex, + case let .includesPi(_, native) = publication.accounting + { + return native + } return publication.snapshot } @@ -1691,11 +1715,22 @@ final class SpendDashboardController { } else { .unavailable } + // Provider-specific by design: Pi remains local history while its snapshot is loading, + // unavailable, or confirmed empty, when no ProviderInput is available yet. + let role: SpendSourcePublication.Role = if provider == .pi { + .localHistory + } else { + switch input?.sourceKind { + case .openCodex: .enrichment + case .localHistory: .localHistory + case .native, nil: .subscription + } + } return SpendSourcePublication( id: sourceID, provider: provider, displayName: input?.displayName ?? self.displayName(for: sourceID, provider: provider), - role: input?.sourceKind == .openCodex ? .enrichment : .subscription, + role: role, state: state) } if self.configuration?.openCodexUsageLogsEnabled == true, @@ -1743,6 +1778,7 @@ final class SpendDashboardController { { var ids: [String] = [] for providerID in self.configuration?.providerIDs ?? [] { + // Provider-specific by design: source ordering expands the fixed Codex account namespace. if providerID == UsageProvider.codex.rawValue { ids.append(contentsOf: (self.configuration?.codexAccountIdentities ?? []).compactMap { identity in guard let separator = identity.lastIndex(of: "|") else { return nil } @@ -1760,6 +1796,7 @@ final class SpendDashboardController { } private func provider(for sourceID: String) -> UsageProvider? { + // Provider-specific by design: account source IDs map to Codex. if sourceID.hasPrefix("codex:") { return .codex } return UsageProvider(rawValue: sourceID) } @@ -1798,7 +1835,8 @@ final class SpendDashboardController { modelProviderName: input.modelProviderName, snapshot: input.snapshot, tokenActivityCache: input.tokenActivityCache, - sourceKind: input.sourceKind) + sourceKind: input.sourceKind, + accounting: input.accounting) } private static func sameSourceOwnership( diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index 6cbed96446..4f6ab30a1f 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -6,6 +6,7 @@ struct SpendDashboardModel: Equatable, Sendable { enum SourceKind: String, Sendable, Equatable { case native case openCodex + case localHistory } static let openCodexSourceID = "opencodex" @@ -16,6 +17,7 @@ struct SpendDashboardModel: Equatable, Sendable { let modelProviderName: String let snapshot: CostUsageTokenSnapshot let tokenActivityCache: CostUsageTokenActivityCache? + let accounting: PiSnapshotAccounting? init( id: String? = nil, @@ -24,7 +26,8 @@ struct SpendDashboardModel: Equatable, Sendable { modelProviderName: String? = nil, snapshot: CostUsageTokenSnapshot, tokenActivityCache: CostUsageTokenActivityCache? = nil, - sourceKind: SpendDashboardModel.SourceKind = .native) + sourceKind: SpendDashboardModel.SourceKind = .native, + accounting: PiSnapshotAccounting? = nil) { self.id = id ?? provider.rawValue self.provider = provider @@ -33,6 +36,7 @@ struct SpendDashboardModel: Equatable, Sendable { self.snapshot = snapshot self.tokenActivityCache = tokenActivityCache self.sourceKind = sourceKind + self.accounting = accounting } let sourceKind: SpendDashboardModel.SourceKind diff --git a/Sources/CodexBar/SpendDashboardPublication.swift b/Sources/CodexBar/SpendDashboardPublication.swift index 087f27218b..14b62b265f 100644 --- a/Sources/CodexBar/SpendDashboardPublication.swift +++ b/Sources/CodexBar/SpendDashboardPublication.swift @@ -5,6 +5,7 @@ struct SpendSourcePublication: Sendable, Equatable { enum Role: Sendable, Equatable { case subscription case enrichment + case localHistory } enum State: Sendable, Equatable { @@ -79,7 +80,14 @@ struct SpendDashboardPublication: Sendable { hiddenSourceIDs: hiddenSourceIDs, hideNativeCodexWhenOpenCodexPresent: hideNativeCodexWhenOpenCodexPresent) if rosterSources.isEmpty, coverageSources.isEmpty { - count += hiddenSourceIDs.contains(provider.rawValue) ? 0 : 1 + let hasVisibleLocalHistory = self.sources.contains { + $0.provider == provider && + $0.role == .localHistory && + !hiddenSourceIDs.contains($0.id) + } + if !hasVisibleLocalHistory { + count += hiddenSourceIDs.contains(provider.rawValue) ? 0 : 1 + } } else { count += coverageSources.count } diff --git a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift index 6042dd2f43..8813c77f91 100644 --- a/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift +++ b/Sources/CodexBar/SpendDashboardSource+OpenCodex.swift @@ -146,7 +146,8 @@ extension SpendDashboardSource { historyDays: self.scanDays, calendar: request.configuration.bucketCalendar), tokenActivityCache: input.tokenActivityCache, - sourceKind: input.sourceKind) + sourceKind: input.sourceKind, + accounting: input.accounting) } static func shouldPublishOpenCodexSnapshot(_ snapshot: CostUsageTokenSnapshot) -> Bool { diff --git a/Sources/CodexBar/StatusItemController+OverviewSpend.swift b/Sources/CodexBar/StatusItemController+OverviewSpend.swift index 49d2704660..2892a26c65 100644 --- a/Sources/CodexBar/StatusItemController+OverviewSpend.swift +++ b/Sources/CodexBar/StatusItemController+OverviewSpend.swift @@ -19,9 +19,10 @@ struct OverviewSpendSummary: Equatable { knownTokenProviderCount: Int? = nil) { let includedProviders = model.groups.flatMap(\.providers) - let providerCount = max(max(0, providerCount), includedProviders.count) - let pricedProviderCount = includedProviders.count { $0.totalCost != nil } - let tokenProviderCount = includedProviders.count { $0.totalTokens != nil } + let subscriptionProviders = includedProviders.filter { $0.sourceKind != .localHistory } + let providerCount = max(max(0, providerCount), subscriptionProviders.count) + let pricedProviderCount = subscriptionProviders.count { $0.totalCost != nil } + let tokenProviderCount = subscriptionProviders.count { $0.totalTokens != nil } let resolvedKnownCostProviderCount = knownCostProviderCount.map { min(providerCount, max(pricedProviderCount, $0)) } @@ -217,7 +218,8 @@ extension StatusItemController { settings: self.settings, store: self.store) else { - return providerScope.count + // Provider-specific by design: Pi contributes local history, not subscription coverage. + return providerScope.count { $0 != .pi } } return publication.subscriptionCount( providerScope: providerScope, diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index 281b9fc7c3..5576d2a833 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -8,6 +8,9 @@ private struct CodexCostCatchUpContext { let scopeSignature: String let providerConfigRevision: UInt64 let costUsageSettingsRevision: UInt64 + let includePiSessions: Bool + let environment: [String: String] + let piHistoryScopeGeneration: UInt64 } private enum CodexCostCatchUpPublicationError: LocalizedError { @@ -53,7 +56,10 @@ extension UsageStore { historyDays: self.settings.costUsageHistoryDays, scopeSignature: scopeSignature, providerConfigRevision: self.settings.providerConfigRevision(for: .codex), - costUsageSettingsRevision: self.settings.costUsageSettingsRevision) + costUsageSettingsRevision: self.settings.costUsageSettingsRevision, + includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: .codex), + environment: self.environmentBase, + piHistoryScopeGeneration: self.piHistoryScopeGeneration) self.codexCostCatchUpToken = token self.codexCostCatchUpScopeSignature = scopeSignature self.codexCostCatchUpMode = mode @@ -262,14 +268,19 @@ extension UsageStore { now: now, context: context) try Task.checkCancellation() + guard await self.refreshPiHistoryScope(for: .codex) else { throw CancellationError() } guard self.codexCostCatchUpContextIsCurrent(context), self.tokenSnapshotPublicationRevision(for: .codex) == publicationRevision else { + if context.includePiSessions, self.piHistoryScopeGeneration != context.piHistoryScopeGeneration { + self.requestTokenRefreshAfterStaleCompletion(for: .codex) + } throw CancellationError() } guard let result, result.snapshot.historyCoverageIsEstablished, - result.staleSnapshotUpdatedAt == nil + result.staleSnapshotUpdatedAt == nil, + self.tokenAccountingScopeIsCurrent(result.accounting, for: .codex) else { return nil } let snapshot = result.snapshot @@ -278,10 +289,10 @@ extension UsageStore { self.lastTokenFetchScope[.codex] = context.scopeSignature } if snapshot.daily.isEmpty, snapshot.meteredCostUSD == nil { - self.publishConfirmedEmptyTokenSnapshot(for: .codex) + self.publishConfirmedEmptyTokenSnapshot(for: .codex, accounting: result.accounting) self.tokenErrors[.codex] = Self.tokenCostNoDataMessage(for: .codex) } else { - self.publishTokenSnapshot(snapshot, for: .codex) + self.publishTokenSnapshot(snapshot, for: .codex, accounting: result.accounting) self.tokenErrors[.codex] = nil } self.tokenFailureGates[.codex]?.recordSuccess() @@ -300,17 +311,21 @@ extension UsageStore { context: CodexCostCatchUpContext) async -> ( snapshot: CostUsageTokenSnapshot, lastRefreshAt: Date?, - staleSnapshotUpdatedAt: Date?)? + staleSnapshotUpdatedAt: Date?, + accounting: PiSnapshotAccounting?)? { if let override = self._test_cachedCodexTokenSnapshotLoaderOverride { return await override(now, context.codexHomePath, context.historyDays) + .map { ($0.snapshot, $0.lastRefreshAt, $0.staleSnapshotUpdatedAt, nil) } } return await self.costUsageFetcher.loadCompletedCodexTokenSnapshotResult( now: now, codexHomePath: context.codexHomePath, historyDays: context.historyDays, - calendar: self.settings.costUsageBucketCalendar) - .map { ($0.snapshot, $0.lastRefreshAt, $0.staleSnapshotUpdatedAt) } + includePiSessions: context.includePiSessions, + calendar: self.settings.costUsageBucketCalendar, + environment: context.environment) + .map { ($0.snapshot, $0.lastRefreshAt, $0.staleSnapshotUpdatedAt, $0.accounting) } } private func codexCostCatchUpContextIsCurrent(_ context: CodexCostCatchUpContext) -> Bool { diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index 2f390dbe9b..626eb26175 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -25,7 +25,8 @@ extension UsageStore { else { return nil } return CurrentProviderConfigTokenPublication( snapshot: publication.snapshot, - publicationRevision: publication.publicationRevision) + publicationRevision: publication.publicationRevision, + accounting: publication.accounting) } func spendDashboardTokenSnapshotPublicationRevision(for provider: UsageProvider) -> UInt64 { @@ -84,6 +85,8 @@ extension UsageStore { return } + guard await self.refreshPiHistoryScope(for: provider) else { return } + guard !self.spendDashboardTokenRefreshInFlight.contains(provider.instanceID) else { return } let now = Date() @@ -117,14 +120,19 @@ extension UsageStore { } do { - let snapshot = try await self.loadTokenUsageSnapshot( + let result = try await self.loadTokenUsageSnapshot( provider: provider, force: force, now: now, codexHomePath: costScope.codexHomePath, historyDays: historyDays, - cursorCookieHeaderOverride: cursorCookieHeaderOverride) + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider)) + let snapshot = result.snapshot try Task.checkCancellation() + guard self.tokenAccountingScopeIsCurrent(result.accounting, for: provider) else { + throw TokenSnapshotError.historyUnavailable + } let completedCostScopeSignature = self.completedTokenCostScopeSignature( provider: provider, historyDays: historyDays, @@ -156,10 +164,13 @@ extension UsageStore { self.spendDashboardTokenFailedTriggers.removeValue(forKey: provider.instanceID) guard hasUsage else { - self.publishSpendDashboardTokenSnapshot(nil, for: provider) + self.publishSpendDashboardTokenSnapshot( + nil, + for: provider, + accounting: result.accounting) return } - self.publishSpendDashboardTokenSnapshot(snapshot, for: provider) + self.publishSpendDashboardTokenSnapshot(snapshot, for: provider, accounting: result.accounting) } catch { guard self.spendDashboardTokenRefreshPublicationIsCurrent( provider: provider, @@ -188,25 +199,28 @@ extension UsageStore { #if DEBUG func _setSpendDashboardTokenSnapshotForTesting( _ snapshot: CostUsageTokenSnapshot?, - for provider: UsageProvider) + for provider: UsageProvider, + accounting: PiSnapshotAccounting? = nil) { self.spendDashboardTokenIncorporatedTriggers[provider.instanceID] = self.spendDashboardTokenRefreshTrigger( for: provider) self.spendDashboardTokenFailedTriggers.removeValue(forKey: provider.instanceID) - self.publishSpendDashboardTokenSnapshot(snapshot, for: provider) + self.publishSpendDashboardTokenSnapshot(snapshot, for: provider, accounting: accounting) } #endif private func publishSpendDashboardTokenSnapshot( _ snapshot: CostUsageTokenSnapshot?, - for provider: UsageProvider) + for provider: UsageProvider, + accounting: PiSnapshotAccounting? = nil) { self.spendDashboardTokenPublicationRevisions[provider.instanceID, default: 0] &+= 1 self.spendDashboardTokenPublications[provider.instanceID] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.spendDashboardTokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.spendDashboardTokenSnapshotScopeSignature(for: provider)) + scopeSignature: self.spendDashboardTokenSnapshotScopeSignature(for: provider), + accounting: accounting) self.synchronizeSharedSpendDashboardAfterTokenPublication(for: provider) } diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index cf3acb6d7e..c30f1655c9 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -4,11 +4,13 @@ import Foundation struct CurrentProviderConfigTokenSnapshot: Sendable, Equatable { let snapshot: CostUsageTokenSnapshot let publicationRevision: UInt64 + let accounting: PiSnapshotAccounting? } struct CurrentProviderConfigTokenPublication: Sendable, Equatable { let snapshot: CostUsageTokenSnapshot? let publicationRevision: UInt64 + let accounting: PiSnapshotAccounting? } struct TokenSnapshotPublication: Sendable, Equatable { @@ -16,6 +18,7 @@ struct TokenSnapshotPublication: Sendable, Equatable { let publicationRevision: UInt64 let providerConfigRevision: UInt64 let scopeSignature: String + let accounting: PiSnapshotAccounting? } extension UsageStore { @@ -59,20 +62,103 @@ extension UsageStore { return .proceed(header) } + /// Provider-specific by design: Pi, Claude, and unscoped Codex share the Pi history scope lifecycle. + private func usesPiHistoryScope(_ provider: UsageProvider) -> Bool { + provider == .pi || (self.shouldIncludePiSessionsInTokenSnapshot(for: provider) && + (provider == .claude || + (provider == .codex && self.tokenCostScope(for: provider).codexHomePath == nil))) + } + + func tokenAccountingScopeIsCurrent(_ accounting: PiSnapshotAccounting?, for provider: UsageProvider) -> Bool { + guard self.usesPiHistoryScope(provider), let scope = accounting?.scope else { return true } + guard let current = self.piHistoryScopeFingerprint else { return true } + return scope == current + } + + func refreshPiHistoryScope(for provider: UsageProvider) async -> Bool { + guard self.usesPiHistoryScope(provider) else { return true } + // Synthetic snapshot/cache overrides own their source and must not resolve real processes. + if self._test_piHistoryScopeResolver == nil, + self._test_tokenUsageResultLoaderOverride != nil || + self._test_tokenUsageSnapshotLoaderOverride != nil || + self._test_tokenUsageRefreshOverride != nil || + self._test_cachedCodexTokenSnapshotLoaderOverride != nil + { + return true + } + if let pending = self.piHistoryScopeRefreshTask { return await pending.value } + // One resolver publishes the shared scope; older concurrent completions cannot overwrite it. + let task = Task { @MainActor [weak self] in + guard let self else { return false } + defer { self.piHistoryScopeRefreshTask = nil } + let fingerprint: String + do { + if let resolver = self._test_piHistoryScopeResolver { + fingerprint = try await resolver(self.environmentBase) + } else { + fingerprint = try await CostUsageFetcher.piRootScope(environment: self.environmentBase) + } + } catch { + self.tokenErrors[provider.instanceID] = "Pi history configuration is unavailable." + return false + } + guard self.piHistoryScopeFingerprint != fingerprint else { return true } + self.piHistoryScopeFingerprint = fingerprint + self.piHistoryScopeGeneration &+= 1 + // Provider-specific by design: invalidate only consumers that include Pi history. + for scopedProvider in [UsageProvider.pi, .claude, .codex] where self.usesPiHistoryScope(scopedProvider) { + self.clearTokenSnapshot(for: scopedProvider) + self.clearSpendDashboardTokenSnapshot(for: scopedProvider) + self.lastTokenFetchAt.removeValue(forKey: scopedProvider.instanceID) + self.lastTokenFetchScope.removeValue(forKey: scopedProvider.instanceID) + } + self.synchronizeSharedSpendDashboardAfterTokenPublication(for: .pi) + return true + } + self.piHistoryScopeRefreshTask = task + return await task.value + } + + /// Reports used by the combined dashboard can describe Pi as a separate + /// source. The fetcher still supports inclusive standalone reads; this + /// helper keeps the existing ownership label for scope invalidation. + func shouldIncludePiSessionsInTokenSnapshot(for provider: UsageProvider) -> Bool { + guard provider == .claude || provider == .codex else { return true } + if provider == .codex, self.tokenCostScope(for: provider).codexHomePath != nil { return false } + let piIsCostSource = self.settings.isProviderEnabledCached( + provider: .pi, + metadataByProvider: self.providerMetadata) && + self.settings.isCostUsageEffectivelyEnabled(for: .pi) + return !piIsCostSource + } + + func piRowsScopeSignature(for provider: UsageProvider) -> String? { + guard provider == .claude || + (provider == .codex && self.tokenCostScope(for: provider).codexHomePath == nil) + else { return nil } + return self.shouldIncludePiSessionsInTokenSnapshot(for: provider) ? "fallback" : "owned" + } + func loadTokenUsageSnapshot( provider: UsageProvider, force: Bool, now: Date, codexHomePath: String?, historyDays: Int, - cursorCookieHeaderOverride: String? = nil) async throws -> CostUsageTokenSnapshot + cursorCookieHeaderOverride: String? = nil, + includePiSessions: Bool = true) async throws -> CostUsageTokenResult { + if let override = self._test_tokenUsageResultLoaderOverride { + return try await override(provider, force, now, codexHomePath, historyDays, includePiSessions) + } if let override = self._test_tokenUsageSnapshotLoaderOverride { - return try await override(provider, force, now, codexHomePath, historyDays) + let snapshot = try await override(provider, force, now, codexHomePath, historyDays) + return CostUsageTokenResult(snapshot: snapshot) } let fetcher = self.costUsageFetcher let timeoutSeconds = self.tokenFetchTimeout + let effectiveIncludePiSessions = includePiSessions // Provider-specific by design: the Codex ledger owns pricing refresh while Bedrock resolves AWS environment. let allowPricingRefresh = provider != .codex || !self.settings.codexLocalSessionCostLedgerEnabled let environment = provider == .bedrock @@ -82,9 +168,19 @@ extension UsageStore { settings: self.settings, tokenOverride: nil) : self.environmentBase - return try await withThrowingTaskGroup(of: CostUsageTokenSnapshot.self) { group in + let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) + // Provider-specific by design: only Pi-owned, Claude-inclusive, or unscoped Codex scans consume Pi roots. + let shouldDiscoverPiSessionProcessContexts = provider == .pi || + (effectiveIncludePiSessions && + (provider == .claude || (provider == .codex && scopedCodexHomePath?.isEmpty != false))) + let piSessionProcessContexts: [PiSessionProcessContext] = if shouldDiscoverPiSessionProcessContexts { + await LocalAgentSessionScanner().piSessionProcessContexts(environment: environment) + } else { + [] + } + return try await withThrowingTaskGroup(of: CostUsageTokenResult.self) { group in group.addTask(priority: .utility) { - try await fetcher.loadTokenSnapshot( + try await fetcher.loadTokenResult( provider: provider, environment: environment, now: now, @@ -94,6 +190,8 @@ extension UsageStore { historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, + includePiSessions: effectiveIncludePiSessions, + piSessionProcessContexts: piSessionProcessContexts, bypassScannerDebounce: true, calendar: self.settings.costUsageBucketCalendar) } @@ -119,7 +217,8 @@ extension UsageStore { else { return nil } return CurrentProviderConfigTokenSnapshot( snapshot: snapshot, - publicationRevision: publication.publicationRevision) + publicationRevision: publication.publicationRevision, + accounting: publication.accounting) } func tokenSnapshotPublicationForCurrentProviderConfig( @@ -130,7 +229,9 @@ extension UsageStore { publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) else { return nil } return CurrentProviderConfigTokenPublication( - snapshot: publication.snapshot, publicationRevision: publication.publicationRevision) + snapshot: publication.snapshot, + publicationRevision: publication.publicationRevision, + accounting: publication.accounting) } func tokenSnapshotPublicationRevision(for provider: UsageProvider) -> UInt64 { @@ -159,31 +260,48 @@ extension UsageStore { return false } - func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + func publishTokenSnapshot( + _ snapshot: CostUsageTokenSnapshot, + for provider: UsageProvider, + accounting: PiSnapshotAccounting? = nil) + { if self.retainsEstablishedTokenHistory(snapshot, for: provider) { return } - self.publishTokenSnapshotState(snapshot, for: provider) + self.publishTokenSnapshotState(snapshot, for: provider, accounting: accounting) } - func publishConfirmedEmptyTokenSnapshot(for provider: UsageProvider) { - self.publishTokenSnapshotState(nil, for: provider) + func publishConfirmedEmptyTokenSnapshot( + for provider: UsageProvider, + accounting: PiSnapshotAccounting? = nil) + { + self.publishTokenSnapshotState(nil, for: provider, accounting: accounting) } - private func publishTokenSnapshotState(_ snapshot: CostUsageTokenSnapshot?, for provider: UsageProvider) { + private func publishTokenSnapshotState( + _ snapshot: CostUsageTokenSnapshot?, + for provider: UsageProvider, + accounting: PiSnapshotAccounting?) + { self.tokenSnapshotPublicationRevisions[provider.instanceID, default: 0] &+= 1 self.tokenSnapshotPublications[provider.instanceID] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + scopeSignature: self.tokenSnapshotScopeSignature(for: provider), + accounting: accounting) self.synchronizeSharedSpendDashboardAfterTokenPublication(for: provider) } - func installCachedTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + func installCachedTokenSnapshot( + _ snapshot: CostUsageTokenSnapshot, + for provider: UsageProvider, + accounting: PiSnapshotAccounting? = nil) + { self.tokenSnapshotPublications[provider.instanceID] = TokenSnapshotPublication( snapshot: snapshot, publicationRevision: self.tokenSnapshotPublicationRevision(for: provider), providerConfigRevision: self.settings.providerConfigRevision(for: provider), - scopeSignature: self.tokenSnapshotScopeSignature(for: provider)) + scopeSignature: self.tokenSnapshotScopeSignature(for: provider), + accounting: accounting) } func clearTokenSnapshot(for provider: UsageProvider) { @@ -253,40 +371,52 @@ extension UsageStore { return nil } - let scope = self.tokenCostScope(for: .codex) - let historyDays = self.settings.costUsageHistoryDays - let publicationRevision = self.providerPublicationRevision(for: .codex) - let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) - let costUsageSettingsRevision = self.settings.costUsageSettingsRevision - let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) - let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) return Task { @MainActor [weak self] in guard let self else { return } + guard await self.refreshPiHistoryScope(for: .codex) else { return } + let scope = self.tokenCostScope(for: .codex) + let historyDays = self.settings.costUsageHistoryDays + let publicationRevision = self.providerPublicationRevision(for: .codex) + let providerConfigRevision = self.settings.providerConfigRevision(for: .codex) + let costUsageSettingsRevision = self.settings.costUsageSettingsRevision + let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex) + let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex) + let includePiSessions = self.shouldIncludePiSessionsInTokenSnapshot(for: .codex) guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return } let result: ( snapshot: CostUsageTokenSnapshot, lastRefreshAt: Date?, - staleSnapshotUpdatedAt: Date?)? = if let override = self._test_cachedCodexTokenSnapshotLoaderOverride + staleSnapshotUpdatedAt: Date?, + accounting: PiSnapshotAccounting?)? = if let override = + self._test_cachedCodexTokenSnapshotLoaderOverride { - await override(now, scope.codexHomePath, historyDays) + await override(now, scope.codexHomePath, historyDays).map { + ($0.snapshot, $0.lastRefreshAt, $0.staleSnapshotUpdatedAt, nil) + } } else { await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: scope.codexHomePath, historyDays: historyDays, - calendar: self.settings.costUsageBucketCalendar) + includePiSessions: includePiSessions, + calendar: self.settings.costUsageBucketCalendar, + environment: self.environmentBase) .map { ( snapshot: $0.snapshot, lastRefreshAt: $0.lastRefreshAt, - staleSnapshotUpdatedAt: $0.staleSnapshotUpdatedAt) + staleSnapshotUpdatedAt: $0.staleSnapshotUpdatedAt, + accounting: $0.accounting) } } guard let result else { return } - guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex), + // Provider-specific by design: cache hydration publishes only after all fixed Codex scope checks pass. + guard await self.refreshPiHistoryScope(for: .codex), + self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex), + self.tokenAccountingScopeIsCurrent(result.accounting, for: .codex), self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, self.settings.costUsageSettingsRevision == costUsageSettingsRevision, self.settings.isCostUsageEffectivelyEnabled(for: .codex), @@ -299,7 +429,7 @@ extension UsageStore { else { return } - self.installCachedTokenSnapshot(result.snapshot, for: .codex) + self.installCachedTokenSnapshot(result.snapshot, for: .codex, accounting: result.accounting) self.tokenErrors[.codex] = nil if result.staleSnapshotUpdatedAt != nil { self.startCodexCostCatchUpIfNeeded() @@ -385,6 +515,12 @@ extension UsageStore { { let scope = self.tokenCostScope(for: provider) var base = "\(scope.signature)|historyDays=\(historyDays)" + if self.usesPiHistoryScope(provider) { + base += "|piHistoryGeneration=\(self.piHistoryScopeGeneration)" + } + if let piRowsScope = self.piRowsScopeSignature(for: provider) { + base += "|piRows=\(piRowsScope)" + } if includeSettingsRevision { base += "|settingsRevision=\(self.settings.costUsageSettingsRevision)" } @@ -621,6 +757,103 @@ extension UsageStore { return false } + struct TokenUsageRefreshContext { + let provider: UsageProvider + let now: Date + let historyDays: Int + let costScopeSignature: String + let publicationScope: TokenRefreshPublicationScope + let startedAt: Date + } + + func commitTokenUsageResult( + _ result: CostUsageTokenResult, + context: TokenUsageRefreshContext) throws + { + let snapshot = result.snapshot + try Task.checkCancellation() + let completedCostScopeSignature = self.completedTokenCostScopeSignature( + provider: context.provider, + historyDays: context.historyDays, + initialSignature: context.costScopeSignature, + snapshot: snapshot) + let disposition = self.tokenRefreshPublicationDisposition( + provider: context.provider, + scope: context.publicationScope, + fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) + guard disposition == .current else { + self.clearTokenFetchMetadataIfMatching( + provider: context.provider, + attemptedAt: context.now, + costScopeSignature: context.costScopeSignature) + if disposition == .scopeChanged { + self.requestTokenRefreshAfterStaleCompletion(for: context.provider) + } + return + } + // An unavailable replacement root can retain the old report; retrying the same scope immediately loops. + guard self.tokenAccountingScopeIsCurrent(result.accounting, for: context.provider) else { + throw TokenSnapshotError.historyUnavailable + } + self.lastTokenFetchScope[context.provider.instanceID] = completedCostScopeSignature + self.startCodexCostCatchUpIfNeeded(afterRefreshing: context.provider) + + if try self.regularTokenSnapshotIsConfirmedEmpty(snapshot, for: context.provider) { + self.publishConfirmedEmptyTokenSnapshot(for: context.provider, accounting: result.accounting) + self.tokenErrors[context.provider.instanceID] = Self.tokenCostNoDataMessage(for: context.provider) + self.tokenFailureGates[context.provider.instanceID]?.recordSuccess() + return + } + self.logTokenUsageSuccess( + provider: context.provider, + snapshot: snapshot, + historyDays: context.historyDays, + startedAt: context.startedAt) + self.publishTokenSnapshot(snapshot, for: context.provider, accounting: result.accounting) + self.tokenErrors[context.provider.instanceID] = nil + self.tokenFailureGates[context.provider.instanceID]?.recordSuccess() + self.persistWidgetSnapshot(reason: "token-usage") + } + + func resetTokenUsageState(for provider: UsageProvider) { + // Provider-specific by design: resetting Codex token state also cancels its two ledger catch-up workflows. + if provider == .codex { + self.cancelCodexCostCatchUp() + self.cancelSpendDashboardCodexCostCatchUp() + } + self.clearTokenSnapshot(for: provider) + self.clearSpendDashboardTokenSnapshot(for: provider) + self.tokenErrors[provider.instanceID] = nil + self.tokenFailureGates[provider.instanceID]?.reset() + self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) + self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) + self.lastSpendDashboardTokenFetchAt.removeValue(forKey: provider.instanceID) + self.lastSpendDashboardTokenFetchScope.removeValue(forKey: provider.instanceID) + } + + func clearTokenFetchMetadataIfMatching( + provider: UsageProvider, + attemptedAt: Date, + costScopeSignature: String) + { + guard self.lastTokenFetchAt[provider.instanceID] == attemptedAt, + self.lastTokenFetchScope[provider.instanceID] == costScopeSignature + else { + return + } + self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) + self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) + } + + /// Fast failures may retry on the next scheduled pass instead of waiting out the fetch + /// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans. + nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool { + if case CostUsageError.timedOut = error { + return false + } + return true + } + func tokenCostIsAccountAgnostic(for provider: UsageProvider) -> Bool { // Provider-specific by design: only Codex's explicit ambient scope spans local accounts. provider == .codex && self.tokenCostScope(for: provider).signature == "codex:ambient" diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index ffbdf16962..287081f3eb 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -283,9 +283,12 @@ extension UsageStore { nil } + // Provider-specific by design: Pi's local strategy has no quota measurement; age belongs to its history. + let historyUpdatedAt = provider == .pi ? tokenSnapshot?.updatedAt : nil return WidgetSnapshot.ProviderEntry( provider: provider, - updatedAt: snapshot?.updatedAt ?? preservedClaudeUsage?.updatedAt ?? tokenSnapshot?.updatedAt ?? now, + updatedAt: historyUpdatedAt ?? snapshot?.updatedAt ?? preservedClaudeUsage?.updatedAt + ?? tokenSnapshot?.updatedAt ?? now, primary: snapshot?.primary ?? preservedClaudeUsage?.primary, secondary: snapshot?.secondary ?? preservedClaudeUsage?.secondary, tertiary: snapshot?.tertiary ?? preservedClaudeUsage?.tertiary, diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b363de85e0..43a3cdb7eb 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -277,6 +277,13 @@ final class UsageStore { @ObservationIgnored var _test_cursorCostCredentialFingerprintOverride: (() -> String?)? #endif @ObservationIgnored var _test_tokenUsageRefreshOverride: (@MainActor (UsageProvider, Bool) async -> Void)? + @ObservationIgnored var _test_tokenUsageResultLoaderOverride: (@MainActor ( + UsageProvider, + Bool, + Date, + String?, + Int, + Bool) async throws -> CostUsageTokenResult)? @ObservationIgnored var _test_tokenUsageSnapshotLoaderOverride: (@MainActor ( UsageProvider, Bool, @@ -440,6 +447,10 @@ final class UsageStore { @ObservationIgnored var lastPermissionPromptNotificationAt: [ProviderInstanceID: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [ProviderInstanceID: Date] = [:] @ObservationIgnored var lastTokenFetchScope: [ProviderInstanceID: String] = [:] + @ObservationIgnored var piHistoryScopeFingerprint: String? + @ObservationIgnored var piHistoryScopeGeneration: UInt64 = 0 + @ObservationIgnored var piHistoryScopeRefreshTask: Task? + @ObservationIgnored var _test_piHistoryScopeResolver: (@Sendable ([String: String]) async throws -> String)? @ObservationIgnored var lastSpendDashboardTokenFetchAt: [ProviderInstanceID: Date] = [:] @ObservationIgnored var lastSpendDashboardTokenFetchScope: [ProviderInstanceID: String] = [:] var spendDashboardTokenRefreshInFlight: Set = [] @@ -1486,6 +1497,8 @@ extension UsageStore { return } + guard await self.refreshPiHistoryScope(for: provider) else { return } + guard !self.tokenRefreshInFlight.contains(provider.instanceID) else { return } let now = Date() @@ -1523,55 +1536,26 @@ extension UsageStore { let startedAt = Date() self.tokenCostLogger .debug("cost usage start provider=\(provider.rawValue) force=\(force)") + let refreshContext = TokenUsageRefreshContext( + provider: provider, + now: now, + historyDays: historyDays, + costScopeSignature: costScopeSignature, + publicationScope: publicationScope, + startedAt: startedAt) do { // Codex cost usage scans the explicit token-cost scope: selected managed account by // default, or this Mac's ambient Codex home when the local ledger is enabled. - let snapshot = try await self.loadTokenUsageSnapshot( + let result = try await self.loadTokenUsageSnapshot( provider: provider, force: force, now: now, codexHomePath: costScope.codexHomePath, historyDays: historyDays, - cursorCookieHeaderOverride: cursorCookieHeaderOverride) - try Task.checkCancellation() - let completedCostScopeSignature = self.completedTokenCostScopeSignature( - provider: provider, - historyDays: historyDays, - initialSignature: costScopeSignature, - snapshot: snapshot) - let publicationDisposition = self.tokenRefreshPublicationDisposition( - provider: provider, - scope: publicationScope, - fetchedCredentialScopeFingerprint: snapshot.credentialScopeFingerprint) - guard publicationDisposition == .current else { - self.clearTokenFetchMetadataIfMatching( - provider: provider, - attemptedAt: now, - costScopeSignature: costScopeSignature) - if publicationDisposition == .scopeChanged { - self.requestTokenRefreshAfterStaleCompletion(for: provider) - } - return - } - self.lastTokenFetchScope[provider.instanceID] = completedCostScopeSignature - self.startCodexCostCatchUpIfNeeded(afterRefreshing: provider) - - if try self.regularTokenSnapshotIsConfirmedEmpty(snapshot, for: provider) { - self.publishConfirmedEmptyTokenSnapshot(for: provider) - self.tokenErrors[provider.instanceID] = Self.tokenCostNoDataMessage(for: provider) - self.tokenFailureGates[provider.instanceID]?.recordSuccess() - return - } - self.logTokenUsageSuccess( - provider: provider, - snapshot: snapshot, - historyDays: historyDays, - startedAt: startedAt) - self.publishTokenSnapshot(snapshot, for: provider) - self.tokenErrors[provider.instanceID] = nil - self.tokenFailureGates[provider.instanceID]?.recordSuccess() - self.persistWidgetSnapshot(reason: "token-usage") + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider)) + try self.commitTokenUsageResult(result, context: refreshContext) } catch { guard self.tokenRefreshPublicationDisposition( provider: provider, @@ -1613,45 +1597,6 @@ extension UsageStore { } } } - - private func resetTokenUsageState(for provider: UsageProvider) { - // Provider-specific by design: resetting Codex token state also cancels its two ledger catch-up workflows. - if provider == .codex { - self.cancelCodexCostCatchUp() - self.cancelSpendDashboardCodexCostCatchUp() - } - self.clearTokenSnapshot(for: provider) - self.clearSpendDashboardTokenSnapshot(for: provider) - self.tokenErrors[provider.instanceID] = nil - self.tokenFailureGates[provider.instanceID]?.reset() - self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) - self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) - self.lastSpendDashboardTokenFetchAt.removeValue(forKey: provider.instanceID) - self.lastSpendDashboardTokenFetchScope.removeValue(forKey: provider.instanceID) - } - - private func clearTokenFetchMetadataIfMatching( - provider: UsageProvider, - attemptedAt: Date, - costScopeSignature: String) - { - guard self.lastTokenFetchAt[provider.instanceID] == attemptedAt, - self.lastTokenFetchScope[provider.instanceID] == costScopeSignature - else { - return - } - self.lastTokenFetchAt.removeValue(forKey: provider.instanceID) - self.lastTokenFetchScope.removeValue(forKey: provider.instanceID) - } - - /// Fast failures may retry on the next scheduled pass instead of waiting out the fetch - /// TTL; timed-out scans keep the TTL so a slow corpus cannot thrash back-to-back rescans. - nonisolated static func tokenFetchFailureAllowsEarlyRetry(_ error: Error) -> Bool { - if case CostUsageError.timedOut = error { - return false - } - return true - } } extension UsageStore { diff --git a/Sources/CodexBarCLI/CLICostCommand.swift b/Sources/CodexBarCLI/CLICostCommand.swift index cc8f04a77e..b9a64d206e 100644 --- a/Sources/CodexBarCLI/CLICostCommand.swift +++ b/Sources/CodexBarCLI/CLICostCommand.swift @@ -73,12 +73,16 @@ extension CodexBarCLI { let bucketCalendar = CostUsageBucketTimeZone.calendar( identifier: Self.stringFromAppDefaults("tokenCostUsageBucketTimeZone")) let fetcher = CostUsageFetcher(calendar: bucketCalendar) + let outputProviders = Self.costProviders(providers, groupBy: groupBy, format: format) + let piSessionProcessContexts = await Self.piSessionProcessContextsForCost( + providers: outputProviders, + includePiSessions: includePiSessions) var sections: [String] = [] var payload: [CostPayload] = [] var exitCode: ExitCode = .success // Provider-specific by design: project/session grouping is available only for Codex local session data. - for provider in Self.costProviders(providers, groupBy: groupBy, format: format) { + for provider in outputProviders { if let error = Self.cursorCostAvailabilityError( provider, settings: cursorCookieSettings, @@ -103,9 +107,11 @@ extension CodexBarCLI { refreshPricingInBackground: false, includePiSessions: Self.costIncludePiSessions( provider: provider, + selectedProviders: outputProviders, groupBy: groupBy, format: format, - includePiSessions: includePiSessions)) + includePiSessions: includePiSessions), + piSessionProcessContexts: piSessionProcessContexts) switch format { case .text: sections.append(Self.renderCostText( @@ -558,6 +564,18 @@ extension CodexBarCLI { selection.asList.filter { Self.costSupportedProviders.contains($0) } } + /// Provider-specific by design: historical Pi/OMP cost roots must include the working directories and + /// selectors of live Pi-family processes, even when the CLI itself runs from another directory. + static func piSessionProcessContextsForCost( + providers: [UsageProvider], + includePiSessions: Bool) async -> [PiSessionProcessContext] + { + let hasPiConsumer = providers.contains(.pi) || + (includePiSessions && providers.contains { $0 == .claude || $0 == .codex }) + guard hasPiConsumer else { return [] } + return await LocalAgentSessionScanner().piSessionProcessContexts() + } + /// Providers participating in a cost run: text-mode project/session grouping is Codex-only, /// while JSON output always keeps every requested provider. static func costProviders( @@ -572,10 +590,16 @@ extension CodexBarCLI { /// Session text reports need native Codex rows, so keep Pi/OMP aggregate merging out of that path. static func costIncludePiSessions( provider: UsageProvider, + selectedProviders: [UsageProvider] = [], groupBy: CostGroupBy, format: OutputFormat, includePiSessions: Bool) -> Bool { + // Provider-specific by design: Pi owns its rows when it is selected alongside native + // local providers, so the two provider snapshots cannot publish the same usage twice. + if provider == .claude || provider == .codex, selectedProviders.contains(.pi) { + return false + } // Provider-specific by design: only Codex local session text bypasses Pi/OMP merging. guard provider == .codex, groupBy == .session, format == .text else { return includePiSessions } return false diff --git a/Sources/CodexBarCLI/CLIDashboardCommand.swift b/Sources/CodexBarCLI/CLIDashboardCommand.swift index 3ce1f23a01..1110fabb59 100644 --- a/Sources/CodexBarCLI/CLIDashboardCommand.swift +++ b/Sources/CodexBarCLI/CLIDashboardCommand.swift @@ -112,6 +112,9 @@ struct DashboardSnapshotProducer: Sendable { }, collectCost: { providers, config in let costFetcher = CostUsageFetcher() + let piSessionProcessContexts = await CodexBarCLI.piSessionProcessContextsForCost( + providers: providers, + includePiSessions: true) return await CodexBarCLI.collectConfiguredCostPayloads( providers: providers, config: config, @@ -122,7 +125,14 @@ struct DashboardSnapshotProducer: Sendable { provider: provider, forceRefresh: false, cursorCookieHeaderOverride: cursorCookieHeaderOverride, - refreshPricingInBackground: context.costRefreshesPricingInBackground) + refreshPricingInBackground: context.costRefreshesPricingInBackground, + includePiSessions: CodexBarCLI.costIncludePiSessions( + provider: provider, + selectedProviders: providers, + groupBy: .none, + format: .json, + includePiSessions: true), + piSessionProcessContexts: piSessionProcessContexts) return CodexBarCLI.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) } catch { return CodexBarCLI.makeCostPayload(provider: provider, snapshot: nil, error: error) diff --git a/Sources/CodexBarCLI/CLIServeCommand.swift b/Sources/CodexBarCLI/CLIServeCommand.swift index 975574f995..77b0565283 100644 --- a/Sources/CodexBarCLI/CLIServeCommand.swift +++ b/Sources/CodexBarCLI/CLIServeCommand.swift @@ -1490,6 +1490,9 @@ extension CodexBarCLI { } let fetcher = CostUsageFetcher() + let piSessionProcessContexts = await Self.piSessionProcessContextsForCost( + providers: providers, + includePiSessions: true) let payload = await Self.collectConfiguredCostPayloads( providers: providers, config: context.config, @@ -1500,7 +1503,14 @@ extension CodexBarCLI { provider: provider, forceRefresh: false, cursorCookieHeaderOverride: cursorCookieHeaderOverride, - refreshPricingInBackground: Self.serveCostRefreshesPricingInBackground) + refreshPricingInBackground: Self.serveCostRefreshesPricingInBackground, + includePiSessions: Self.costIncludePiSessions( + provider: provider, + selectedProviders: providers, + groupBy: .none, + format: .json, + includePiSessions: true), + piSessionProcessContexts: piSessionProcessContexts) return Self.makeCostPayload(provider: provider, snapshot: snapshot, error: nil) } catch { return Self.makeCostPayload(provider: provider, snapshot: nil, error: error) @@ -1552,6 +1562,9 @@ extension CodexBarCLI { { // Preserve the established scan order. The injected fetch decides whether // pricing refresh is awaited; provider deadlines still bound each row. + // Provider-specific by design: the same provider may be requested once as + // an inclusive Claude/Codex row and once as native-only when Pi is emitted + // separately. Keep those operations from coalescing under one config key. var payload: [CostPayload] = [] for provider in providers { let deadline = Self.serveCostProviderDeadline( @@ -1562,9 +1575,17 @@ extension CodexBarCLI { provider: provider, snapshot: nil, error: CLIServeCostTimeoutError(provider: provider)) + let includesPi = Self.costIncludePiSessions( + provider: provider, + selectedProviders: providers, + groupBy: .none, + format: .json, + includePiSessions: true) + let mode = includesPi ? "inclusive" : "native" + let operationFingerprint = "\(context.configFingerprint)|piAccounting=\(provider.rawValue):\(mode)" let item = await context.providerOperations.value( for: provider.rawValue, - fingerprint: context.configFingerprint, + fingerprint: operationFingerprint, deadline: deadline, timeoutValue: timeout) { diff --git a/Sources/CodexBarCore/AgentSession.swift b/Sources/CodexBarCore/AgentSession.swift index 03b49a7d72..436572852d 100644 --- a/Sources/CodexBarCore/AgentSession.swift +++ b/Sources/CodexBarCore/AgentSession.swift @@ -197,16 +197,30 @@ public struct AgentProcessRecord: Equatable, Sendable { public let ppid: Int32 public let startedAt: Date? public let command: String + /// Original argv when the platform exposes it. `command` remains the portable fallback. + public let arguments: [String]? + /// Only Pi root selectors; nil means unavailable and an empty map means a known empty selection. + public let piSelectorEnvironment: [String: String]? - public init(pid: Int32, ppid: Int32, startedAt: Date?, command: String) { + public init( + pid: Int32, + ppid: Int32, + startedAt: Date?, + command: String, + arguments: [String]? = nil, + piSelectorEnvironment: [String: String]? = nil) + { self.pid = pid self.ppid = ppid self.startedAt = startedAt self.command = command + self.arguments = arguments + self.piSelectorEnvironment = PiProcessEnvironment.filtered(piSelectorEnvironment) } public var executableBasename: String { - let firstToken = self.command.split(whereSeparator: \ .isWhitespace).first.map(String.init) ?? "" + let firstToken = self.arguments?.first ?? self.command.split(whereSeparator: \ .isWhitespace).first + .map(String.init) ?? "" let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent if firstBasename == "disclaimer" { return firstBasename @@ -246,7 +260,7 @@ public enum AgentPSOutputParser { return !self.isObviousPiFamilyHelper(record.command) } if basename == AgentSession.Provider.codex.rawValue { - let arguments = self.arguments(record.command) + let arguments = self.arguments(record) return self.isCodexAgentExecutable(record.command) && !arguments.contains("app-server") && !arguments.contains("--help") && @@ -284,7 +298,7 @@ public enum AgentPSOutputParser { } public static func piDialect(for record: AgentProcessRecord) -> AgentSession.Dialect? { - let tokens = record.command.split(whereSeparator: \ .isWhitespace).map(String.init) + let tokens = [record.executableBasename] + self.arguments(record) guard let firstToken = tokens.first else { return nil } let firstBasename = URL(fileURLWithPath: firstToken).lastPathComponent.lowercased() @@ -309,7 +323,7 @@ public enum AgentPSOutputParser { records.contains { record in record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue && self.isCodexAgentExecutable(record.command) && - self.arguments(record.command).contains("app-server") + self.arguments(record).contains("app-server") } } @@ -326,15 +340,23 @@ public enum AgentPSOutputParser { return records.lazy.compactMap { record -> String? in guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue, - self.arguments(record.command).contains("app-server"), - let executable = record.command.split(whereSeparator: \ .isWhitespace).first + self.arguments(record).contains("app-server"), + let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace) + .first.map(String.init) else { return nil } - let path = URL(fileURLWithPath: String(executable)).standardizedFileURL.path + let path = URL(fileURLWithPath: executable).standardizedFileURL.path return allowedPaths.contains(path) ? path : nil }.first } + private static func arguments(_ record: AgentProcessRecord) -> [String] { + if let arguments = record.arguments { + return Array(arguments.dropFirst()) + } + return self.arguments(record.command) + } + private static func arguments(_ command: String) -> [String] { command.split(whereSeparator: \ .isWhitespace).dropFirst().map(String.init) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index bf689e109b..dd739feb51 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -31,8 +31,20 @@ public enum CostUsageError: LocalizedError, Sendable { public struct CostUsageFetcher: Sendable { private static let codexAutomaticScanDurationPerRefresh: TimeInterval = 2 + package static func piRootScope(environment: [String: String]) async throws -> String { + let contexts = await LocalAgentSessionScanner().piSessionProcessContexts(environment: environment) + return try await CostUsageScanExecutor.run { checkCancellation in + try checkCancellation() + return PiSessionCostScanner.scopeFingerprint( + options: PiSessionCostScanner.Options( + environment: environment, + processContexts: contexts)) + } + } + package struct CachedCodexTokenSnapshotResult: Sendable { package let snapshot: CostUsageTokenSnapshot + package var accounting: PiSnapshotAccounting? package let lastRefreshAt: Date? package let staleSnapshotUpdatedAt: Date? } @@ -118,13 +130,17 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, - calendar: Calendar? = nil) async -> CachedCodexTokenSnapshotResult? + includePiSessions: Bool = true, + calendar: Calendar? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment) async -> CachedCodexTokenSnapshotResult? { await Self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, - scannerOptions: self.scannerOptions(calendar: calendar)) + includePiSessions: includePiSessions, + scannerOptions: self.scannerOptions(calendar: calendar), + environment: environment) } package func loadCachedCodexTokenSnapshotForScopedHome( @@ -149,15 +165,19 @@ public struct CostUsageFetcher: Sendable { now: Date = Date(), codexHomePath: String? = nil, historyDays: Int = 30, - calendar: Calendar? = nil) async -> CachedCodexTokenSnapshotResult? + includePiSessions: Bool = true, + calendar: Calendar? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment) async -> CachedCodexTokenSnapshotResult? { await Self.loadCachedCodexTokenSnapshotResult( now: now, codexHomePath: codexHomePath, historyDays: historyDays, allowScopedCodexHome: true, + includePiSessions: includePiSessions, requireCompleteHistory: true, - scannerOptions: self.scannerOptions(calendar: calendar)) + scannerOptions: self.scannerOptions(calendar: calendar), + environment: environment) } public func loadCachedCodexLocalProjectUsageSnapshot( @@ -210,7 +230,9 @@ public struct CostUsageFetcher: Sendable { cursorCookieHeaderOverride: String? = nil, allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, - includePiSessions: Bool = true) async throws -> CostUsageTokenSnapshot + includePiSessions: Bool = true, + piWorkingDirectories: [URL] = [], + piSessionProcessContexts: [PiSessionProcessContext] = []) async throws -> CostUsageTokenSnapshot { try await Self.loadTokenSnapshot( provider: provider, @@ -225,6 +247,8 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: false, + piWorkingDirectories: piWorkingDirectories, + piSessionProcessContexts: piSessionProcessContexts, scannerOptions: self.scannerOptionsOverride()) } @@ -240,6 +264,8 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + piWorkingDirectories: [URL] = [], + piSessionProcessContexts: [PiSessionProcessContext] = [], bypassScannerDebounce: Bool, calendar: Calendar? = nil) async throws -> CostUsageTokenSnapshot { @@ -260,6 +286,47 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, bypassScannerDebounce: bypassScannerDebounce, + piWorkingDirectories: piWorkingDirectories, + piSessionProcessContexts: piSessionProcessContexts, + scannerOptions: options) + } + + package func loadTokenResult( + provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + forceRefresh: Bool = false, + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, + allowPricingRefresh: Bool = true, + refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true, + piWorkingDirectories: [URL] = [], + piSessionProcessContexts: [PiSessionProcessContext] = [], + bypassScannerDebounce: Bool, + calendar: Calendar? = nil) async throws -> CostUsageTokenResult + { + var options = self.scannerOptionsOverride() ?? CostUsageScanner.Options() + if let calendar { + options.calendar = calendar + } + return try await Self.loadTokenResult( + provider: provider, + environment: environment, + now: now, + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + allowPricingRefresh: allowPricingRefresh, + refreshPricingInBackground: refreshPricingInBackground, + includePiSessions: includePiSessions, + bypassScannerDebounce: bypassScannerDebounce, + piWorkingDirectories: piWorkingDirectories, + piSessionProcessContexts: piSessionProcessContexts, scannerOptions: options) } @@ -325,7 +392,7 @@ public struct CostUsageFetcher: Sendable { { var options = Self.resolvedScannerOptions( self.scannerOptions(calendar: calendar), - provider: .codex, + provider: .codex, // Provider-specific by design: this catch-up operation is owned by Codex's ledger. codexHomePath: codexHomePath) options.forceRescan = false options.refreshMinIntervalSeconds = 0 @@ -395,11 +462,56 @@ public struct CostUsageFetcher: Sendable { refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, bypassScannerDebounce: Bool = false, + piWorkingDirectories: [URL] = [], + piSessionProcessContexts: [PiSessionProcessContext] = [], scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, piScannerOptions overridePiScannerOptions: PiSessionCostScanner .Options? = nil, modelsDevClient: ModelsDevClient = ModelsDevClient(), retryUnknownPricing: Bool = true) async throws -> CostUsageTokenSnapshot + { + try await self.loadTokenResult( + provider: provider, + environment: environment, + now: now, + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + allowPricingRefresh: allowPricingRefresh, + refreshPricingInBackground: refreshPricingInBackground, + includePiSessions: includePiSessions, + bypassScannerDebounce: bypassScannerDebounce, + piWorkingDirectories: piWorkingDirectories, + piSessionProcessContexts: piSessionProcessContexts, + scannerOptions: overrideScannerOptions, + piScannerOptions: overridePiScannerOptions, + modelsDevClient: modelsDevClient, + retryUnknownPricing: retryUnknownPricing).snapshot + } + + // swiftlint:disable:next function_body_length cyclomatic_complexity + static func loadTokenResult( + provider: UsageProvider, + environment: [String: String] = ProcessInfo.processInfo.environment, + now: Date = Date(), + forceRefresh: Bool = false, + allowVertexClaudeFallback: Bool = false, + codexHomePath: String? = nil, + historyDays: Int = 30, + cursorCookieHeaderOverride: String? = nil, + allowPricingRefresh: Bool = true, + refreshPricingInBackground: Bool = true, + includePiSessions: Bool = true, + bypassScannerDebounce: Bool = false, + piWorkingDirectories: [URL] = [], + piSessionProcessContexts: [PiSessionProcessContext] = [], + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, + piScannerOptions overridePiScannerOptions: PiSessionCostScanner + .Options? = nil, + modelsDevClient: ModelsDevClient = ModelsDevClient(), + retryUnknownPricing: Bool = true) async throws -> CostUsageTokenResult { guard self.supportsTokenSnapshot(provider) else { throw CostUsageError.unsupportedProvider(provider) @@ -424,7 +536,7 @@ public struct CostUsageFetcher: Sendable { remoteError = error } if let remoteSnapshot { - return remoteSnapshot + return CostUsageTokenResult(snapshot: remoteSnapshot) } // Provider-specific by design: Cursor and Antigravity local readers backfill providers without remote history. @@ -437,15 +549,15 @@ public struct CostUsageFetcher: Sendable { if let local = await self.loadCursorLocalSnapshot( now: now, historyDays: clampedHistoryDays, calendar: fallbackCalendar) { - return local + return CostUsageTokenResult(snapshot: local) } if let remoteError { throw remoteError } - return Self.unavailableLocalSnapshot( + return CostUsageTokenResult(snapshot: Self.unavailableLocalSnapshot( now: now, historyDays: clampedHistoryDays, - calendar: fallbackCalendar) + calendar: fallbackCalendar)) } // Provider-specific by design: Antigravity uses recognized local stores without generic pricing or cache scans. if provider == .antigravity { @@ -455,28 +567,130 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: fallbackCalendar) { - return local + return CostUsageTokenResult(snapshot: local) } if let remoteError { throw remoteError } - return Self.unavailableLocalSnapshot( + return CostUsageTokenResult(snapshot: Self.unavailableLocalSnapshot( now: now, historyDays: clampedHistoryDays, - calendar: fallbackCalendar) + calendar: fallbackCalendar)) } // Provider-specific by design: Muse local history has token evidence but no established dollar rates. if provider == .muse { - return try await Self.loadMuseLocalSnapshot( + return try await CostUsageTokenResult(snapshot: Self.loadMuseLocalSnapshot( environment: environment, now: now, historyDays: clampedHistoryDays, - options: fallbackOptions) + options: fallbackOptions)) } if let remoteError { throw remoteError } + // Provider-specific by design: Pi has an independent aggregate token-cost history over its local JSONL logs. + if provider == .pi { + var piOptionsOnly = overridePiScannerOptions ?? PiSessionCostScanner.Options() + if piOptionsOnly.cacheRoot == nil { + piOptionsOnly.cacheRoot = overrideScannerOptions?.cacheRoot + } + if piOptionsOnly.piSessionsRoot == nil, piOptionsOnly.ompSessionsRoot == nil { + piOptionsOnly.environment = environment + } + // Provider-specific by design: Pi scans receive live process project roots for project-level settings. + if piOptionsOnly.workingDirectories.isEmpty, !piWorkingDirectories.isEmpty { + piOptionsOnly.workingDirectories = piWorkingDirectories + } + if piOptionsOnly.processContexts.isEmpty, !piSessionProcessContexts.isEmpty { + piOptionsOnly.processContexts = piSessionProcessContexts + } + if overrideScannerOptions != nil { + piOptionsOnly.calendar = Self.resolvedScannerOptions( + overrideScannerOptions, + provider: .pi, + codexHomePath: codexHomePath).calendar + } + if forceRefresh || bypassScannerDebounce { + piOptionsOnly.refreshMinIntervalSeconds = 0 + } + piOptionsOnly.forceRescan = piOptionsOnly.forceRescan || forceRefresh + let piOptions = piOptionsOnly + let piSince = piOptionsOnly.calendar.date(byAdding: .day, value: -(clampedHistoryDays - 1), to: now) ?? now + await Self.refreshPricingIfAllowed( + options: PricingRefreshOptions( + provider: .claude, + isAllowed: allowPricingRefresh, + retryUnknown: retryUnknownPricing, + inBackground: refreshPricingInBackground), + now: now, + cacheRoot: piOptionsOnly.cacheRoot, + client: modelsDevClient) + let piScanResult: PiSessionCostScanner.DailyReportResult = try await CostUsageScanExecutor + .run { checkCancellation in + try PiSessionCostScanner.loadDailyReportResultCancellable( + // Provider-specific by design: this call reads Pi's local aggregate session ledger. + provider: .pi, + since: piSince, + until: now, + now: now, + options: piOptions, + checkCancellation: checkCancellation) + } + let piDaily = piScanResult.report + if allowPricingRefresh, retryUnknownPricing { + var didRefresh = false + // Provider-specific by design: Pi model names reuse the Codex and Claude pricing catalogs. + for pricingProvider in [UsageProvider.codex, UsageProvider.claude] { + if let request = Self.unknownPricingRefreshRequest( + provider: pricingProvider, + daily: piDaily, + now: now, + cacheRoot: piOptionsOnly.cacheRoot, + client: modelsDevClient), + await Self.refreshUnknownPricingIfNeeded(request, inBackground: refreshPricingInBackground) + { + didRefresh = true + } + } + if didRefresh, !refreshPricingInBackground { + return try await self.loadTokenResult( + provider: provider, + environment: environment, + now: now, + forceRefresh: forceRefresh, + allowVertexClaudeFallback: allowVertexClaudeFallback, + codexHomePath: codexHomePath, + historyDays: historyDays, + cursorCookieHeaderOverride: cursorCookieHeaderOverride, + allowPricingRefresh: allowPricingRefresh, + refreshPricingInBackground: false, + includePiSessions: includePiSessions, + piWorkingDirectories: piWorkingDirectories, + piSessionProcessContexts: piSessionProcessContexts, + scannerOptions: overrideScannerOptions, + piScannerOptions: piOptionsOnly, + modelsDevClient: modelsDevClient, + retryUnknownPricing: false) + } + } + let snapshot = Self.tokenSnapshot( + from: piDaily, + now: now, + historyDays: clampedHistoryDays, + calendar: piOptionsOnly.calendar, + historyCoverageIsEstablished: piScanResult.isComplete, + costProvenance: .listPriceEstimate, + projects: [], + sessions: [], + // An incomplete Pi scan may be serving a retained cache report. Keep its + // original scan time so stale usage is not presented as freshly read. + updatedAt: piScanResult.isComplete ? now : piScanResult.lastScanAt ?? now) + return CostUsageTokenResult( + snapshot: snapshot, + accounting: piScanResult.scopeFingerprint.map { .piOnly(scope: $0) }) + } + var options = Self.resolvedScannerOptions( overrideScannerOptions, provider: provider, @@ -506,10 +720,20 @@ public struct CostUsageFetcher: Sendable { if resolvedPiOptions.cacheRoot == nil { resolvedPiOptions.cacheRoot = options.cacheRoot } + if resolvedPiOptions.piSessionsRoot == nil, resolvedPiOptions.ompSessionsRoot == nil { + resolvedPiOptions.environment = environment + } + if resolvedPiOptions.workingDirectories.isEmpty, !piWorkingDirectories.isEmpty { + resolvedPiOptions.workingDirectories = piWorkingDirectories + } + if resolvedPiOptions.processContexts.isEmpty, !piSessionProcessContexts.isEmpty { + resolvedPiOptions.processContexts = piSessionProcessContexts + } resolvedPiOptions.calendar = options.calendar if forceRefresh || bypassScannerDebounce { resolvedPiOptions.refreshMinIntervalSeconds = 0 } + resolvedPiOptions.forceRescan = resolvedPiOptions.forceRescan || forceRefresh let piOptions = resolvedPiOptions let scanOptions = options @@ -529,13 +753,13 @@ public struct CostUsageFetcher: Sendable { retryUnknownPricing, let request = Self.unknownPricingRefreshRequest( provider: provider, - daily: scanResult.daily, + daily: scanResult.inclusive.daily, now: now, cacheRoot: options.cacheRoot, client: modelsDevClient), await Self.refreshUnknownPricingIfNeeded(request, inBackground: refreshPricingInBackground) { - return try await self.loadTokenSnapshot( + return try await self.loadTokenResult( provider: provider, environment: environment, now: now, @@ -547,25 +771,45 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + piWorkingDirectories: piWorkingDirectories, + piSessionProcessContexts: piSessionProcessContexts, scannerOptions: options, piScannerOptions: piOptions, modelsDevClient: modelsDevClient, retryUnknownPricing: false) } - return Self.tokenSnapshot( - from: scanResult.daily, + let snapshot = Self.tokenSnapshot( + from: scanResult.inclusive.daily, now: now, historyDays: clampedHistoryDays, calendar: scanOptions.calendar, - historyCoverageIsEstablished: scanResult.historyCoverageIsEstablished, + historyCoverageIsEstablished: scanResult.inclusive.historyCoverageIsEstablished, costProvenance: .listPriceEstimate, - projects: scanResult.projects, - sessions: scanResult.sessions, - updatedAt: scanResult.staleSnapshotUpdatedAt) + projects: scanResult.inclusive.projects, + sessions: scanResult.inclusive.sessions, + updatedAt: scanResult.inclusive.staleSnapshotUpdatedAt) + // Provider-specific by design: native projections exist for the two transcript families. + let accounting: PiSnapshotAccounting? = if let scope = scanResult.piScope { + .includesPi(scope: scope, native: Self.tokenSnapshot( + from: scanResult.native.daily, + now: now, + historyDays: clampedHistoryDays, + calendar: scanOptions.calendar, + historyCoverageIsEstablished: scanResult.native.historyCoverageIsEstablished, + costProvenance: .listPriceEstimate, + projects: scanResult.native.projects, + sessions: scanResult.native.sessions, + updatedAt: scanResult.native.staleSnapshotUpdatedAt)) + } else if provider == .codex || provider == .claude { + .nativeOnly + } else { + nil + } + return CostUsageTokenResult(snapshot: snapshot, accounting: accounting) } - private struct LocalTokenScanResult: Sendable { + private struct LocalTokenScanReport: Sendable { let daily: CostUsageDailyReport let projects: [CostUsageProjectBreakdown] let sessions: [CostUsageSessionBreakdown] @@ -573,6 +817,12 @@ public struct CostUsageFetcher: Sendable { let historyCoverageIsEstablished: Bool } + private struct LocalTokenScanResult: Sendable { + let inclusive: LocalTokenScanReport + let native: LocalTokenScanReport + let piScope: String? + } + private struct LocalTokenScanOptions: Sendable { let allowVertexClaudeFallback: Bool let includePiSessions: Bool @@ -634,6 +884,7 @@ public struct CostUsageFetcher: Sendable { var projects: [CostUsageProjectBreakdown] = [] var sessions: [CostUsageSessionBreakdown] = [] + var piScanIsComplete = true var staleSnapshotUpdatedAt: Date? var historyCoverageIsEstablished = provider != .codex if provider == .codex { @@ -660,10 +911,17 @@ public struct CostUsageFetcher: Sendable { roots: roots) } } + let native = LocalTokenScanReport( + daily: daily, + projects: projects, + sessions: sessions, + staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, + historyCoverageIsEstablished: historyCoverageIsEstablished) + var piScope: String? if options.includePiSessions, provider == .claude || (provider == .codex && options.shouldMergePiUsage) { - let piReport = try PiSessionCostScanner.loadDailyReportCancellable( + let piScanResult = try PiSessionCostScanner.loadDailyReportResultCancellable( provider: provider, since: since, until: now, @@ -671,23 +929,40 @@ public struct CostUsageFetcher: Sendable { options: options.piOptions, checkCancellation: checkCancellation) try checkCancellation() - if provider == .codex, let project = Self.unknownProjectBreakdown(from: piReport) { - projects.append(project) - sessions = [] + piScanIsComplete = piScanResult.isComplete + if !piScanResult.isComplete, + let piLastScanAt = piScanResult.lastScanAt + { + staleSnapshotUpdatedAt = [staleSnapshotUpdatedAt, piLastScanAt] + .compactMap(\.self) + .min() + } + // Provider-specific by design: only the Codex local ledger contributes Pi rows to this scan. + if provider == .codex { + if let project = Self.unknownProjectBreakdown(from: piScanResult.report) { + projects.append(project) + sessions = [] + } } + // The scanner can restore a previous cache scope after an incomplete root + // transition; carry the scope that the returned report actually represents. + piScope = piScanResult.scopeFingerprint daily = CostUsageDailyReport.merged( - [daily, piReport], + [daily, piScanResult.report], calendar: options.scanOptions.calendar) } if provider == .codex { projects = Self.mergedProjectBreakdowns(projects) } return LocalTokenScanResult( - daily: daily, - projects: projects, - sessions: sessions, - staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, - historyCoverageIsEstablished: historyCoverageIsEstablished) + inclusive: LocalTokenScanReport( + daily: daily, + projects: projects, + sessions: sessions, + staleSnapshotUpdatedAt: staleSnapshotUpdatedAt, + historyCoverageIsEstablished: native.historyCoverageIsEstablished && piScanIsComplete), + native: native, + piScope: piScope) } } @@ -821,7 +1096,8 @@ public struct CostUsageFetcher: Sendable { allowScopedCodexHome: Bool = false, includePiSessions: Bool = true, includeProjectAndSessionBreakdowns: Bool = true, - scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async -> CostUsageTokenSnapshot? + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, + piScannerOptions: PiSessionCostScanner.Options? = nil) async -> CostUsageTokenSnapshot? { await self.loadCachedCodexTokenSnapshotResult( now: now, @@ -830,7 +1106,8 @@ public struct CostUsageFetcher: Sendable { allowScopedCodexHome: allowScopedCodexHome, includePiSessions: includePiSessions, includeProjectAndSessionBreakdowns: includeProjectAndSessionBreakdowns, - scannerOptions: overrideScannerOptions)?.snapshot + scannerOptions: overrideScannerOptions, + piScannerOptions: piScannerOptions)?.snapshot } static func loadCachedCodexTokenActivity( @@ -841,6 +1118,7 @@ public struct CostUsageFetcher: Sendable { -> CostUsageTokenActivityCache? { try? await CostUsageScanExecutor.run { _ -> CostUsageTokenActivityCache? in + // Provider-specific by design: cached activity is read from the Codex ledger's fixed scanner scope. let options = Self.resolvedScannerOptions( overrideScannerOptions, provider: .codex, @@ -905,12 +1183,18 @@ public struct CostUsageFetcher: Sendable { includePiSessions: Bool = true, includeProjectAndSessionBreakdowns: Bool = true, requireCompleteHistory: Bool = false, - scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil) async + scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment, + piScannerOptions: PiSessionCostScanner.Options? = nil) async -> CachedCodexTokenSnapshotResult? { let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) - if scopedCodexHomePath?.isEmpty == false, !allowScopedCodexHome { - return nil + guard scopedCodexHomePath?.isEmpty != false || allowScopedCodexHome else { return nil } + let piHistoryRequested = includePiSessions && scopedCodexHomePath?.isEmpty != false + let processContexts: [PiSessionProcessContext] = if piHistoryRequested, piScannerOptions == nil { + await LocalAgentSessionScanner().piSessionProcessContexts(environment: environment) + } else { + [] } // Snapshot assembly can touch many SQLite rows; keep it off the cooperative pool @@ -918,6 +1202,7 @@ public struct CostUsageFetcher: Sendable { return try? await CostUsageScanExecutor.run { check -> CachedCodexTokenSnapshotResult? in try check() let clampedHistoryDays = max(1, min(365, historyDays)) + // Provider-specific by design: cached Codex token publication uses the Codex scanner and its roots. let options = Self.resolvedScannerOptions( overrideScannerOptions, provider: .codex, @@ -930,7 +1215,6 @@ public struct CostUsageFetcher: Sendable { since: since, until: now, calendar: options.calendar) - let shouldMergePiUsage = scopedCodexHomePath?.isEmpty != false let roots = CostUsageScanner.codexSessionsRoots(options: options) let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) let cache = Self.codexReportView(options: options, range: range) @@ -941,7 +1225,7 @@ public struct CostUsageFetcher: Sendable { // time, every constituent scan time, and whether a second source joined the merge. var nativeScanAt: Date? var scanTimes: [Date] = [] - var piMerged = false + var piHistoryIsComplete = false var staleSnapshotUpdatedAt: Date? let nativeHistoryCoverageIsEstablished = cache.historyCoverageIsEstablished( range: range, @@ -1000,7 +1284,27 @@ public struct CostUsageFetcher: Sendable { } } - if includePiSessions, shouldMergePiUsage { + let nativeSnapshot: CostUsageTokenSnapshot? = reports.isEmpty ? nil : Self.tokenSnapshot( + from: CostUsageDailyReport.merged(reports, calendar: options.calendar), + now: now, + historyDays: clampedHistoryDays, + calendar: options.calendar, + historyCoverageIsEstablished: nativeHistoryCoverageIsEstablished + || staleSnapshotUpdatedAt != nil, + costProvenance: .listPriceEstimate, + projects: Self.mergedProjectBreakdowns(projects), + sessions: sessions, + updatedAt: scanTimes.min()) + var accounting: PiSnapshotAccounting = .nativeOnly + + if piHistoryRequested { + let piOptions = piScannerOptions ?? PiSessionCostScanner.Options( + cacheRoot: options.cacheRoot, + calendar: options.calendar, + environment: environment, + processContexts: processContexts) + // Provider-specific by design: this optional merge reads Pi's Codex-priced history into the + // inclusive Codex cache publication. let piResult = PiSessionCostScanner.loadCachedDailyReportResult( provider: .codex, since: since, @@ -1008,12 +1312,20 @@ public struct CostUsageFetcher: Sendable { now: now, cacheRoot: options.cacheRoot, calendar: options.calendar, - allowEstablishedEmpty: requireCompleteHistory) + options: piOptions, + allowEstablishedEmpty: true) // Missing or incompatible mirror history is not zero usage. - guard !requireCompleteHistory || piResult != nil else { return nil } - if let piResult { + piHistoryIsComplete = piResult?.isComplete == true && piResult?.scopeFingerprint != nil + guard !requireCompleteHistory || piHistoryIsComplete else { return nil } + if let piResult, let scope = piResult.scopeFingerprint { + if let nativeSnapshot { + accounting = .includesPi( + scope: scope, + native: nativeSnapshot) + } else { + accounting = .piOnly(scope: scope) + } reports.append(piResult.report) - piMerged = true if let piLastScanAt = piResult.lastScanAt { scanTimes.append(piLastScanAt) } @@ -1029,8 +1341,8 @@ public struct CostUsageFetcher: Sendable { // `previous` is an exact report captured before the current bounded refresh became // pending. Its rows remain established even though native catch-up is still active; // `staleSnapshotUpdatedAt` keeps refresh scheduling and stale presentation explicit. - let displayedHistoryCoverageIsEstablished = nativeHistoryCoverageIsEstablished - || staleSnapshotUpdatedAt != nil + let displayedHistoryCoverageIsEstablished = (nativeHistoryCoverageIsEstablished + || staleSnapshotUpdatedAt != nil) && (!piHistoryRequested || piHistoryIsComplete) // updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time // would let stale token rows inherit app-start freshness (#1964). lastRefreshAt // drives TTL suppression and stays native-only: a merged load must never delay a @@ -1046,7 +1358,8 @@ public struct CostUsageFetcher: Sendable { projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, updatedAt: scanTimes.min()), - lastRefreshAt: piMerged || staleSnapshotUpdatedAt != nil ? nil : nativeScanAt, + accounting: accounting, + lastRefreshAt: piHistoryRequested || staleSnapshotUpdatedAt != nil ? nil : nativeScanAt, staleSnapshotUpdatedAt: staleSnapshotUpdatedAt) } } @@ -1535,47 +1848,12 @@ public struct CostUsageFetcher: Sendable { } } - private struct ProjectBreakdownAccumulator { - var totalTokens = 0 - var sawTotalTokens = false - var costUSD: Double = 0 - var sawCost = false - - mutating func add(_ breakdown: CostUsageDailyReport.ModelBreakdown) { - if let totalTokens = breakdown.totalTokens { - self.totalTokens += totalTokens - self.sawTotalTokens = true - } - if let costUSD = breakdown.costUSD { - self.costUSD += costUSD - self.sawCost = true - } - } - - func build(modelName: String) -> CostUsageDailyReport.ModelBreakdown { - CostUsageDailyReport.ModelBreakdown( - modelName: modelName, - costUSD: self.sawCost ? self.costUSD : nil, - totalTokens: self.sawTotalTokens ? self.totalTokens : nil) - } - } - private static func projectModelBreakdowns( from entries: [CostUsageDailyReport.Entry]) -> [CostUsageDailyReport.ModelBreakdown]? { - var accumulators: [String: ProjectBreakdownAccumulator] = [:] - for entry in entries { - for breakdown in entry.modelBreakdowns ?? [] { - var accumulator = accumulators[breakdown.modelName] ?? ProjectBreakdownAccumulator() - accumulator.add(breakdown) - accumulators[breakdown.modelName] = accumulator - } - } - guard !accumulators.isEmpty else { return nil } - return accumulators.map { modelName, accumulator in - accumulator.build(modelName: modelName) - } - .sorted { lhs, rhs in + let summaries = CostUsageDailyReport.modelCostSummaries(from: entries) + guard !summaries.isEmpty else { return nil } + return summaries.sorted { lhs, rhs in let lhsCost = lhs.costUSD ?? -1 let rhsCost = rhs.costUSD ?? -1 if lhsCost != rhsCost { diff --git a/Sources/CodexBarCore/DarwinProcessEnumerator.swift b/Sources/CodexBarCore/DarwinProcessEnumerator.swift index ee3989d78d..2130895533 100644 --- a/Sources/CodexBarCore/DarwinProcessEnumerator.swift +++ b/Sources/CodexBarCore/DarwinProcessEnumerator.swift @@ -22,12 +22,40 @@ enum DarwinProcessEnumerator { /// Parses the `KERN_PROCARGS2` payload without consuming the environment /// strings that follow argv. static func parseProcArgs2(_ data: Data) -> String? { + self.parseProcArgs2Arguments(data)?.joined(separator: " ") + } + + /// Returns the original argv from a `KERN_PROCARGS2` payload. Keeping the + /// boundaries matters for flags whose values contain whitespace. + static func parseProcArgs2Arguments(_ data: Data) -> [String]? { + self.parseProcArgs2Layout(data)?.arguments + } + + static func parseProcArgs2PiSelectorEnvironment(_ data: Data) -> [String: String]? { + guard let layout = self.parseProcArgs2Layout(data) else { return nil } + let suffix = Data(data[layout.environmentOffset...]) + // Darwin can omit environment records from a successful procargs response. + // An argv-only response or padding is not evidence of an empty environment. + guard let start = suffix.firstIndex(where: { $0 != 0 }) else { return nil } + var offset = start + while offset < suffix.endIndex { + guard let terminator = suffix[offset...].firstIndex(of: 0) else { return nil } + if terminator == offset { + // The empty environment terminator may be followed by unrelated Apple vectors. + return PiProcessEnvironment.parseNULSeparated(Data(suffix[start.. (arguments: [String], environmentOffset: Int)? { let argumentCountSize = MemoryLayout.size guard data.count >= argumentCountSize else { return nil } let argumentCount = data.withUnsafeBytes { rawBuffer in Int(Int32(littleEndian: rawBuffer.loadUnaligned(as: Int32.self))) } - guard argumentCount >= 0 else { return nil } + guard argumentCount >= 0, argumentCount <= data.count else { return nil } let bytes = [UInt8](data) var offset = argumentCountSize @@ -47,7 +75,7 @@ enum DarwinProcessEnumerator { arguments.append(argument) offset = terminator + 1 } - return arguments.joined(separator: " ") + return (arguments, offset) } } @@ -95,11 +123,34 @@ extension DarwinProcessEnumerator { return (Int32(bitPattern: info.pbi_ppid), Date(timeIntervalSince1970: startInterval)) } - static func commandLine(pid: Int32) -> String? { + static func arguments(pid: Int32) -> [String]? { + self.procArgs2Data(pid: pid).flatMap(self.parseProcArgs2Arguments) + } + + static func argumentsWithPiSelectorEnvironment(pid: Int32) -> ( + arguments: [String], piSelectorEnvironment: [String: String]?)? + { + guard let data = self.procArgs2Data(pid: pid), + let layout = self.parseProcArgs2Layout(data) + else { return nil } + let process = AgentProcessRecord( + pid: pid, + ppid: 0, + startedAt: nil, + command: layout.arguments.joined(separator: " "), + arguments: layout.arguments) + let environment = AgentPSOutputParser.piDialect(for: process) == nil + ? nil + : self.parseProcArgs2PiSelectorEnvironment(data) + return (layout.arguments, environment) + } + + private static func procArgs2Data(pid: Int32) -> Data? { var mib = [CTL_KERN, KERN_PROCARGS2, pid] var byteCount = 0 guard sysctl(&mib, u_int(mib.count), nil, &byteCount, nil, 0) == 0, - byteCount >= MemoryLayout.size + byteCount >= MemoryLayout.size, + byteCount <= PiProcessEnvironment.maxEnvironmentBytes else { return nil } var data = Data(count: byteCount) @@ -110,7 +161,11 @@ extension DarwinProcessEnumerator { if byteCount < data.count { data.removeSubrange(byteCount.. String? { + self.arguments(pid: pid)?.joined(separator: " ") } static func currentWorkingDirectory(pid: Int32) -> String? { diff --git a/Sources/CodexBarCore/LocalAgentSessionScanner.swift b/Sources/CodexBarCore/LocalAgentSessionScanner.swift index 7726de817d..df377b4cd0 100644 --- a/Sources/CodexBarCore/LocalAgentSessionScanner.swift +++ b/Sources/CodexBarCore/LocalAgentSessionScanner.swift @@ -39,6 +39,7 @@ private final class TrustedCodexAppServerCache: @unchecked Sendable { public struct LocalAgentSessionScanner: Sendable { typealias ProcessOutputProvider = @Sendable ([String: String]) async -> String typealias CWDProvider = @Sendable ([Int32], [String: String]) async -> [Int32: String] + typealias ProcessEnvironmentProvider = @Sendable ([Int32]) async -> [Int32: [String: String]] typealias AppServerTrustValidator = @Sendable (String) -> Bool private struct Rollout: Sendable { @@ -63,6 +64,7 @@ public struct LocalAgentSessionScanner: Sendable { private let trustedCodexAppServerCache = TrustedCodexAppServerCache() private let processOutputProvider: ProcessOutputProvider? private let cwdProvider: CWDProvider? + private let processEnvironmentProvider: ProcessEnvironmentProvider? private let appServerTrustValidator: AppServerTrustValidator private let didVisitDirectoryEntry: (@Sendable () -> Void)? @@ -70,6 +72,7 @@ public struct LocalAgentSessionScanner: Sendable { self.config = config self.processOutputProvider = nil self.cwdProvider = nil + self.processEnvironmentProvider = nil self.appServerTrustValidator = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) } self.didVisitDirectoryEntry = nil } @@ -78,6 +81,7 @@ public struct LocalAgentSessionScanner: Sendable { config: SessionScanConfig = SessionScanConfig(), processOutputProvider: @escaping ProcessOutputProvider, cwdProvider: @escaping CWDProvider, + processEnvironmentProvider: ProcessEnvironmentProvider? = nil, appServerTrustValidator: @escaping AppServerTrustValidator = { CodexLaunchPreflight.isLaunchCandidateAllowed(path: $0) }, @@ -86,6 +90,7 @@ public struct LocalAgentSessionScanner: Sendable { self.config = config self.processOutputProvider = processOutputProvider self.cwdProvider = cwdProvider + self.processEnvironmentProvider = processEnvironmentProvider self.appServerTrustValidator = appServerTrustValidator self.didVisitDirectoryEntry = didVisitDirectoryEntry } @@ -96,11 +101,7 @@ public struct LocalAgentSessionScanner: Sendable { environment: [String: String] = ProcessInfo.processInfo.environment, includeFileOnlySessions: Bool = true) async -> [AgentSession] { - let allProcesses = if let processOutputProvider = self.processOutputProvider { - await AgentPSOutputParser.parse(processOutputProvider(environment)) - } else { - await self.processRecords(environment: environment) - } + let allProcesses = await self.processRecords(environment: environment) let processes = Array(AgentSessionCorrelation.newestProcessesFirst( AgentPSOutputParser.agentProcesses(from: allProcesses)) .prefix(max(0, self.config.maxProcessCount))) @@ -188,6 +189,66 @@ public struct LocalAgentSessionScanner: Sendable { directoryBudget: &directoryBudget) } + /// Returns the project directories of live Pi processes so historical cost scans can resolve + /// project-level `.pi/settings.json` without assuming the app's own current directory. + @concurrent + public func piWorkingDirectories( + environment: [String: String] = ProcessInfo.processInfo.environment) async -> [URL] + { + let contexts = await self.piSessionProcessContexts(environment: environment) + var seen = Set() + return contexts.compactMap { context in + guard let workingDirectory = context.workingDirectory, + seen.insert(workingDirectory.path).inserted + else { return nil } + return workingDirectory + } + } + + /// Returns the command selectors and project directories of live Pi-family processes so cost scans can + /// resolve process-owned `--session-dir` and `--profile` choices alongside project settings. + @concurrent + public func piSessionProcessContexts( + environment: [String: String] = ProcessInfo.processInfo.environment) async -> [PiSessionProcessContext] + { + let allProcesses = await self.processRecords(environment: environment) + // Provider-specific by design: only Pi processes provide project roots for Pi history resolution. + // Keep every process through context resolution first so duplicate processes do not consume the + // process budget before an older process with a distinct session root is considered. + let processes = AgentSessionCorrelation.newestProcessesFirst( + AgentPSOutputParser.agentProcesses(from: allProcesses) + .filter { AgentPSOutputParser.provider(for: $0) == .pi }) + guard !processes.isEmpty, self.config.maxProcessCount > 0 else { return [] } + + let cwdByPID = if let cwdProvider = self.cwdProvider { + await cwdProvider(processes.map(\.pid), environment) + } else { + await self.cwdByPID(processes.map(\.pid), environment: environment) + } + var seen = Set() + let distinctContexts: [PiSessionProcessContext] = processes.compactMap { process in + let workingDirectory = cwdByPID[process.pid] + .flatMap { $0.isEmpty ? nil : URL(fileURLWithPath: $0, isDirectory: true) } + // Keep unresolved selectors so missing CWD evidence cannot turn unknown history into zero. + let context = PiSessionProcessContext( + command: process.command, + arguments: process.arguments, + workingDirectory: workingDirectory, + selectorEnvironment: process.piSelectorEnvironment) + let key = PiFamilySessionScanner.processRootSelectorKey(context) + guard seen.insert(key).inserted else { return nil } + return context + } + return distinctContexts + .prefix(max(0, self.config.maxProcessCount)) + .sorted { + $0.workingDirectory?.path == $1.workingDirectory?.path + ? $0.command < $1.command + : ($0.workingDirectory?.path ?? "") < + ($1.workingDirectory?.path ?? "") + } + } + public static func shouldScanSessionMetadata( hasAgentProcesses: Bool, includeFileOnlySessions: Bool, @@ -312,6 +373,7 @@ public struct LocalAgentSessionScanner: Sendable { lastActivityAt: rollout?.modifiedAt, transcriptPath: rollout?.url.path, host: context.host)) + // Provider-specific by design: Pi-family processes are correlated by PiFamilySessionScanner. case .pi: continue } @@ -351,23 +413,61 @@ public struct LocalAgentSessionScanner: Sendable { } private func processRecords(environment: [String: String]) async -> [AgentProcessRecord] { + if let processOutputProvider = self.processOutputProvider { + let records = await AgentPSOutputParser.parse(processOutputProvider(environment)) + guard let processEnvironmentProvider = self.processEnvironmentProvider else { return records } + let piPIDs = records.filter { AgentPSOutputParser.piDialect(for: $0) != nil }.map(\.pid) + guard !piPIDs.isEmpty else { return records } + let environments = await processEnvironmentProvider(piPIDs) + return records.map { record in + guard AgentPSOutputParser.piDialect(for: record) != nil else { return record } + return Self.withPiSelectorEnvironment(environments[record.pid], record: record) + } + } #if canImport(Darwin) return DarwinProcessEnumerator.allPIDs().compactMap { pid in guard let bsdInfo = DarwinProcessEnumerator.bsdInfo(pid: pid), let executablePath = DarwinProcessEnumerator.executablePath(pid: pid) else { return nil } - let command = DarwinProcessEnumerator.commandLine(pid: pid) ?? executablePath + let processArguments = DarwinProcessEnumerator.argumentsWithPiSelectorEnvironment(pid: pid) + let arguments = processArguments?.arguments + let command = arguments?.joined(separator: " ") ?? executablePath return AgentProcessRecord( pid: pid, ppid: bsdInfo.ppid, startedAt: bsdInfo.startTime, - command: command) + command: command, + arguments: arguments, + piSelectorEnvironment: processArguments?.piSelectorEnvironment) + } + #else + let records = await AgentPSOutputParser.parse(self.processOutput(environment: environment)) + #if os(Linux) + return records.map { record in + guard AgentPSOutputParser.piDialect(for: record) != nil else { return record } + return Self.withPiSelectorEnvironment( + PiProcessEnvironment.readLinuxEnvironment(pid: record.pid), + record: record) } #else - return await AgentPSOutputParser.parse(self.processOutput(environment: environment)) + return records + #endif #endif } + private static func withPiSelectorEnvironment( + _ environment: [String: String]?, + record: AgentProcessRecord) -> AgentProcessRecord + { + AgentProcessRecord( + pid: record.pid, + ppid: record.ppid, + startedAt: record.startedAt, + command: record.command, + arguments: record.arguments, + piSelectorEnvironment: environment) + } + #if !canImport(Darwin) private func processOutput(environment: [String: String]) async -> String { let binary = ["/bin/ps", "/usr/bin/ps"].first { FileManager.default.isExecutableFile(atPath: $0) } diff --git a/Sources/CodexBarCore/PiFamilySessionRootResolver.swift b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift new file mode 100644 index 0000000000..8b83992657 --- /dev/null +++ b/Sources/CodexBarCore/PiFamilySessionRootResolver.swift @@ -0,0 +1,467 @@ +import Foundation + +struct OMPSessionRootResolver: Sendable { + static func sessionRoots( + environment: [String: String], + fileManager: FileManager = .default) -> [URL] + { + self.sessionRoots( + environment: environment, + baseDirectory: self.currentDirectory(fileManager: fileManager), + fileManager: fileManager) + } + + static func sessionRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager = .default) -> [URL] + { + self.resolvedSessionRoots( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager).map(\.url) + } + + static func resolvedSessionRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager = .default) -> [OMPSessionResolvedRoot] + { + guard let profile = activeProfile(in: environment) else { + // `nil` is the valid default profile. An invalid profile is + // represented separately so a malformed environment fails closed. + guard self.profileValueIsValid(in: environment) else { return [] } + return self.defaultProfileRoots( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + .map { OMPSessionResolvedRoot(url: $0, layout: .projectDirectories) } + } + + return Self.namedProfileRoots( + profile: profile, + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + static func defaultProfileSessionRoots( + environment: [String: String], + fileManager: FileManager = .default) -> [URL] + { + self.defaultProfileSessionRoots( + environment: environment, + baseDirectory: self.currentDirectory(fileManager: fileManager), + fileManager: fileManager) + } + + static func defaultProfileSessionRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager = .default) -> [URL] + { + self.defaultProfileRoots( + environment: self.sanitizedDefaultEnvironment(environment), + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + /// A named OMP profile is rooted at HOME (and optionally an absolute XDG directory), so it + /// can be resolved even when process CWD lookup is unavailable. + static func canResolveNamedProfileWithoutWorkingDirectory( + _ profile: String, + environment: [String: String]) -> Bool + { + guard case .named = self.normalizedProfile(profile), + let home = homeURL( + environment: environment, + baseDirectory: nil, + fileManager: .default), + configRoot(home: home, environment: environment) != nil + else { return false } + + for key in ["XDG_DATA_HOME", "PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR"] { + guard let value = environment[key] else { continue } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.isEmpty || trimmed.hasPrefix("/") else { return false } + } + return true + } + + private static func defaultProfileRoots( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> [URL] + { + guard let home = homeURL( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + else { return [] } + guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } + let customAgentRoot = Self.customAgentRoot( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + let agentRoot: URL + if let customAgentRoot { + agentRoot = customAgentRoot + } else { + guard let canonicalAgentRoot = Self.canonicalAgentRoot( + configRoot.appendingPathComponent("agent", isDirectory: true), + home: home) + else { return [] } + agentRoot = canonicalAgentRoot + } + + guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } + + var roots = [root] + #if os(macOS) || os(Linux) + if customAgentRoot == nil, + let xdgDataHome = Self.xdgDataHome( + environment: environment, + home: home, + baseDirectory: baseDirectory, + fileManager: fileManager) + { + let xdgSessions = xdgDataHome + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + if Self.isDirectory(xdgSessions, fileManager: fileManager), + let xdgRoot = Self.sessionRoot( + agentRoot: xdgDataHome.appendingPathComponent("omp", isDirectory: true), + fileManager: fileManager) + { + roots.append(xdgRoot) + } + } + #endif + + var seen = Set() + return roots.filter { seen.insert(Self.canonicalURL($0).path).inserted } + } + + private static func namedProfileRoots( + profile: String, + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> [OMPSessionResolvedRoot] + { + guard let home = homeURL( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) + else { return [] } + guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } + let profileRoot = configRoot + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent(profile, isDirectory: true) + guard let agentRoot = Self.canonicalAgentRoot( + profileRoot.appendingPathComponent("agent", isDirectory: true), + home: home) + else { return [] } + + guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } + var roots: [OMPSessionResolvedRoot] = [] + + func appendExistingLayouts(in profileRoot: URL) { + let canonicalProfileRoot = Self.canonicalURL(profileRoot) + let directRoot = canonicalProfileRoot.appendingPathComponent("sessions", isDirectory: true) + if Self.isDirectory(directRoot, fileManager: fileManager) { + roots.append(OMPSessionResolvedRoot(url: directRoot, layout: .direct)) + } + let agentRoot = canonicalProfileRoot + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + if Self.isDirectory(agentRoot, fileManager: fileManager) { + roots.append(OMPSessionResolvedRoot(url: agentRoot, layout: .projectDirectories)) + } + } + + // A selected profile may use either the direct `profiles//sessions` layout or + // the older `profiles//agent/sessions` layout. Keep both when present so a + // profile migration cannot silently hide part of its history. + appendExistingLayouts(in: profileRoot) + #if os(macOS) || os(Linux) + if let xdgDataHome = Self.xdgDataHome( + environment: environment, + home: home, + baseDirectory: baseDirectory, + fileManager: fileManager) + { + let xdgProfileRoot = xdgDataHome + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent(profile, isDirectory: true) + appendExistingLayouts(in: xdgProfileRoot) + } + #endif + + if roots.isEmpty { + // Preserve the historical missing-root signal for an explicitly selected profile. + roots.append(OMPSessionResolvedRoot(url: root, layout: .projectDirectories)) + } + var seen = Set() + return roots.filter { seen.insert(Self.canonicalURL($0.url).path).inserted } + } + + /// Returns the profile directories that belong to the validated OMP configuration. + /// This keeps profile discovery aligned with `sessionRoots` when `PI_CONFIG_DIR` is customized. + static func profileDiscoveryDirectories( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager = .default) -> [URL] + { + guard let home = homeURL( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager), + let configRoot = Self.configRoot(home: home, environment: environment) + else { return [] } + + var directories = [configRoot.appendingPathComponent("profiles", isDirectory: true)] + #if os(macOS) || os(Linux) + if Self.customAgentRoot( + environment: environment, + baseDirectory: baseDirectory, + fileManager: fileManager) == nil, + let xdgDataHome = Self.xdgDataHome( + environment: environment, + home: home, + baseDirectory: baseDirectory, + fileManager: fileManager) + { + directories.append( + xdgDataHome + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true)) + } + #endif + + var seen = Set() + return directories.compactMap { directory in + let canonical = Self.canonicalURL(directory) + return seen.insert(canonical.path).inserted ? canonical : nil + } + } + + private static func profileValueIsValid(in environment: [String: String]) -> Bool { + let value = if let omp = environment["OMP_PROFILE"] { + omp + } else { + environment["PI_PROFILE"] + } + if case .invalid = Self.normalizedProfile(value) { + return false + } + return true + } + + private static func activeProfile(in environment: [String: String]) -> String? { + let value = if let omp = environment["OMP_PROFILE"] { + omp + } else { + environment["PI_PROFILE"] + } + guard case let .named(profile) = Self.normalizedProfile(value) else { return nil } + return profile + } + + private enum ProfileValue { + case `default` + case named(String) + case invalid + } + + private static func normalizedProfile(_ value: String?) -> ProfileValue { + let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if normalized.isEmpty || normalized == "default" { + return .default + } + + let scalars = Array(normalized.unicodeScalars) + guard let first = scalars.first, + scalars.count <= 64, + Self.isASCIIAlphaNumeric(first), + scalars.dropFirst().allSatisfy(Self.isProfileTailScalar), + normalized != ".", + normalized != "..", + !normalized.hasSuffix("."), + !Self.isWindowsReservedProfileName(normalized) + else { return .invalid } + + return .named(normalized) + } + + private static func isASCIIAlphaNumeric(_ scalar: Unicode.Scalar) -> Bool { + (scalar.value >= 48 && scalar.value <= 57) || + (scalar.value >= 97 && scalar.value <= 122) + } + + private static func isProfileTailScalar(_ scalar: Unicode.Scalar) -> Bool { + self.isASCIIAlphaNumeric(scalar) || + scalar.value == 46 || + scalar.value == 95 || + scalar.value == 45 + } + + private static func isWindowsReservedProfileName(_ value: String) -> Bool { + let uppercased = value.uppercased() + let base = uppercased.split(separator: ".", omittingEmptySubsequences: false).first.map(String.init) ?? "" + switch base { + case "CON", "PRN", "AUX", "NUL": + return true + default: + return (base.hasPrefix("COM") || base.hasPrefix("LPT")) && + base.count == 4 && + base.last.map(\.isNumber) == true + } + } + + private static func homeURL( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + guard let home = environmentURL( + environment["HOME"], + baseDirectory: baseDirectory, + fileManager: fileManager) + else { return nil } + return home + } + + private static func configRoot(home: URL, environment: [String: String]) -> URL? { + let name: String = if let configuredPath = environment["PI_CONFIG_DIR"]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !configuredPath.isEmpty + { + configuredPath + } else { + ".omp" + } + guard !name.hasPrefix("/") else { return nil } + + let canonicalHome = Self.canonicalURL(home) + let configRoot = Self.canonicalURL( + canonicalHome.appendingPathComponent(name, isDirectory: true)) + guard Self.isWithin(root: canonicalHome, candidate: configRoot) else { return nil } + return configRoot + } + + private static func customAgentRoot( + environment: [String: String], + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + self.environmentURL( + environment["PI_CODING_AGENT_DIR"], + baseDirectory: baseDirectory, + fileManager: fileManager) + } + + private static func environmentURL( + _ value: String?, + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + guard let value else { return nil } + let path = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !path.isEmpty else { return nil } + + let url: URL + if path.hasPrefix("/") { + url = URL(fileURLWithPath: path, isDirectory: true) + } else { + guard let baseDirectory else { return nil } + url = baseDirectory.appendingPathComponent(path, isDirectory: true) + } + return Self.canonicalURL(url) + } + + private static func xdgDataHome( + environment: [String: String], + home: URL, + baseDirectory: URL?, + fileManager: FileManager) -> URL? + { + if let configured = environment["XDG_DATA_HOME"], + !configured.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return self.environmentURL( + configured, + baseDirectory: baseDirectory, + fileManager: fileManager) + } + return home + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + + private static func sanitizedDefaultEnvironment(_ environment: [String: String]) -> [String: String] { + // A process with an inaccessible environment must not inherit + // process-specific config, custom roots, XDG roots, or profile + // selectors from the scanner's ambient environment. HOME is the only + // input needed to identify the standard default profile root. + guard let home = environment["HOME"] else { return [:] } + return ["HOME": home] + } + + private static func currentDirectory(fileManager: FileManager) -> URL { + URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) + } + + private static func sessionRoot(agentRoot: URL, fileManager: FileManager) -> URL? { + let canonicalAgentRoot = Self.canonicalURL(agentRoot) + let candidate = Self.canonicalURL( + agentRoot.appendingPathComponent("sessions", isDirectory: true)) + guard Self.isWithin(root: canonicalAgentRoot, candidate: candidate) else { return nil } + return candidate + } + + private static func canonicalAgentRoot(_ agentRoot: URL, home: URL) -> URL? { + let canonicalHome = Self.canonicalURL(home) + let canonicalAgentRoot = Self.canonicalURL(agentRoot) + guard Self.isWithin(root: canonicalHome, candidate: canonicalAgentRoot) else { return nil } + return canonicalAgentRoot + } + + private static func canonicalURL(_ url: URL) -> URL { + url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + } + + private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { + var isDirectory: ObjCBool = false + return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && + isDirectory.boolValue + } + + static func isWithin(root: URL, candidate: URL) -> Bool { + let rootPath = root.standardizedFileURL.path + let candidatePath = candidate.standardizedFileURL.path + if rootPath == "/" { + return candidatePath.hasPrefix("/") + } + return candidatePath == rootPath || candidatePath.hasPrefix(rootPath + "/") + } +} + +/// Resolves the historical Pi-family roots used by cost and usage discovery. +public enum PiFamilySessionRootResolver { + /// Returns resolved roots for Pi and OMP historical session stores. + /// + /// Unresolved placeholders are omitted so callers can use the result for read-only source detection. + public static func costSessionRootURLs( + environment: [String: String], + baseDirectory: URL? = nil, + processContexts: [PiSessionProcessContext] = []) -> [URL] + { + PiFamilySessionScanner.costSessionRoots( + environment: environment, + baseDirectories: baseDirectory.map { [$0] }, + processContexts: processContexts) + .filter(\.resolutionIsComplete) + .map(\.url) + } +} diff --git a/Sources/CodexBarCore/PiFamilySessionScanner.swift b/Sources/CodexBarCore/PiFamilySessionScanner.swift index 27e46c0a07..47dfc85c12 100644 --- a/Sources/CodexBarCore/PiFamilySessionScanner.swift +++ b/Sources/CodexBarCore/PiFamilySessionScanner.swift @@ -9,6 +9,16 @@ struct PiFamilySessionRecord: Equatable, Sendable { let url: URL } +enum PiFamilySessionRootLayout: Hashable, Sendable { + case projectDirectories + case direct +} + +struct OMPSessionResolvedRoot: Hashable, Sendable { + let url: URL + let layout: PiFamilySessionRootLayout +} + enum PiFamilySessionFileParser { private static let maximumReadSize = 16 * 1024 @@ -41,6 +51,7 @@ enum PiFamilySessionFileParser { header["type"] as? String == "session", let id = header["id"] as? String else { return nil } + // Provider-specific by design: Pi session files require the upstream v3 header contract. if dialect == .pi, header["version"] as? Int != 3 { return nil } @@ -141,356 +152,82 @@ enum PiFamilySessionFileParser { } } -struct OMPSessionRootResolver: Sendable { - static func sessionRoots( - environment: [String: String], - fileManager: FileManager = .default) -> [URL] - { - self.sessionRoots( - environment: environment, - baseDirectory: self.currentDirectory(fileManager: fileManager), - fileManager: fileManager) - } - - static func sessionRoots( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager = .default) -> [URL] - { - guard let profile = activeProfile(in: environment) else { - // `nil` is the valid default profile. An invalid profile is - // represented separately so a malformed environment fails closed. - guard self.profileValueIsValid(in: environment) else { return [] } - return self.defaultProfileRoots( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) - } - - return Self.namedProfileRoots( - profile: profile, - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) - } - - static func defaultProfileSessionRoots( - environment: [String: String], - fileManager: FileManager = .default) -> [URL] - { - self.defaultProfileSessionRoots( - environment: environment, - baseDirectory: self.currentDirectory(fileManager: fileManager), - fileManager: fileManager) - } - - static func defaultProfileSessionRoots( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager = .default) -> [URL] - { - self.defaultProfileRoots( - environment: self.sanitizedDefaultEnvironment(environment), - baseDirectory: baseDirectory, - fileManager: fileManager) - } - - private static func defaultProfileRoots( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager) -> [URL] - { - guard let home = homeURL( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) - else { return [] } - guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } - let customAgentRoot = Self.customAgentRoot( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) - let agentRoot: URL - if let customAgentRoot { - agentRoot = customAgentRoot - } else { - guard let canonicalAgentRoot = Self.canonicalAgentRoot( - configRoot.appendingPathComponent("agent", isDirectory: true), - home: home) - else { return [] } - agentRoot = canonicalAgentRoot - } - - guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } - - #if os(macOS) || os(Linux) - if customAgentRoot == nil, - let xdgDataHome = Self.environmentURL( - environment["XDG_DATA_HOME"], - baseDirectory: baseDirectory, - fileManager: fileManager) - { - let xdgSessions = xdgDataHome - .appendingPathComponent("omp", isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) - if Self.isDirectory(xdgSessions, fileManager: fileManager), - let root = Self.sessionRoot( - agentRoot: xdgDataHome.appendingPathComponent("omp", isDirectory: true), - fileManager: fileManager) - { - return [root] - } - } - #endif - - return [root] +// swiftlint:disable:next type_body_length +struct PiFamilySessionScanner: Sendable { + struct ScanInput: Sendable { + let processes: [AgentProcessRecord] + let cwdByPID: [Int32: String] + let environment: [String: String] + let now: Date + let host: String + let config: SessionScanConfig } - private static func namedProfileRoots( - profile: String, - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager) -> [URL] - { - guard let home = homeURL( - environment: environment, - baseDirectory: baseDirectory, - fileManager: fileManager) - else { return [] } - guard let configRoot = Self.configRoot(home: home, environment: environment) else { return [] } - let profileRoot = configRoot - .appendingPathComponent("profiles", isDirectory: true) - .appendingPathComponent(profile, isDirectory: true) - guard let agentRoot = Self.canonicalAgentRoot( - profileRoot.appendingPathComponent("agent", isDirectory: true), - home: home) - else { return [] } - - guard let root = Self.sessionRoot(agentRoot: agentRoot, fileManager: fileManager) else { return [] } - #if os(macOS) || os(Linux) - if let xdgDataHome = Self.environmentURL( - environment["XDG_DATA_HOME"], - baseDirectory: baseDirectory, - fileManager: fileManager) + private struct SessionRoot: Hashable, Sendable { + let url: URL + let layout: PiFamilySessionRootLayout + let missingIsKnownEmpty: Bool + let preserveAfterProcessExit: Bool + /// Identifies a durable selector that can replace an older retained root. + let retentionKey: String? + + init( + url: URL, + layout: PiFamilySessionRootLayout, + missingIsKnownEmpty: Bool = false, + preserveAfterProcessExit: Bool = false, + retentionKey: String? = nil) { - let xdgProfileRoot = xdgDataHome - .appendingPathComponent("omp", isDirectory: true) - .appendingPathComponent("profiles", isDirectory: true) - .appendingPathComponent(profile, isDirectory: true) - let xdgSessions = xdgProfileRoot.appendingPathComponent("sessions", isDirectory: true) - if Self.isDirectory(xdgSessions, fileManager: fileManager), - let root = Self.sessionRoot( - agentRoot: xdgProfileRoot, - fileManager: fileManager) - { - return [root] - } - } - #endif - - return [root] - } - - private static func profileValueIsValid(in environment: [String: String]) -> Bool { - let value = if let omp = environment["OMP_PROFILE"] { - omp - } else { - environment["PI_PROFILE"] - } - if case .invalid = Self.normalizedProfile(value) { - return false - } - return true - } - - private static func activeProfile(in environment: [String: String]) -> String? { - let value = if let omp = environment["OMP_PROFILE"] { - omp - } else { - environment["PI_PROFILE"] + self.url = url + self.layout = layout + self.missingIsKnownEmpty = missingIsKnownEmpty + self.preserveAfterProcessExit = preserveAfterProcessExit + self.retentionKey = retentionKey } - guard case let .named(profile) = Self.normalizedProfile(value) else { return nil } - return profile } - private enum ProfileValue { - case `default` - case named(String) - case invalid + private struct ProfileSessionRootResolution { + let roots: [SessionRoot] + let isComplete: Bool } - private static func normalizedProfile(_ value: String?) -> ProfileValue { - let normalized = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if normalized.isEmpty || normalized == "default" { - return .default - } - - let scalars = Array(normalized.unicodeScalars) - guard let first = scalars.first, - scalars.count <= 64, - Self.isASCIIAlphaNumeric(first), - scalars.dropFirst().allSatisfy(Self.isProfileTailScalar), - normalized != ".", - normalized != "..", - !normalized.hasSuffix("."), - !Self.isWindowsReservedProfileName(normalized) - else { return .invalid } - - return .named(normalized) - } - - private static func isASCIIAlphaNumeric(_ scalar: Unicode.Scalar) -> Bool { - (scalar.value >= 48 && scalar.value <= 57) || - (scalar.value >= 97 && scalar.value <= 122) - } - - private static func isProfileTailScalar(_ scalar: Unicode.Scalar) -> Bool { - self.isASCIIAlphaNumeric(scalar) || - scalar.value == 46 || - scalar.value == 95 || - scalar.value == 45 - } - - private static func isWindowsReservedProfileName(_ value: String) -> Bool { - let uppercased = value.uppercased() - let base = uppercased.split(separator: ".", omittingEmptySubsequences: false).first.map(String.init) ?? "" - switch base { - case "CON", "PRN", "AUX", "NUL": - return true - default: - return (base.hasPrefix("COM") || base.hasPrefix("LPT")) && - base.count == 4 && - base.last.map(\.isNumber) == true - } - } - - private static func homeURL( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager) -> URL? - { - guard let home = environmentURL( - environment["HOME"], - baseDirectory: baseDirectory, - fileManager: fileManager) - else { return nil } - return home - } - - private static func configRoot(home: URL, environment: [String: String]) -> URL? { - let name: String = if let configuredPath = environment["PI_CONFIG_DIR"]? - .trimmingCharacters(in: .whitespacesAndNewlines), - !configuredPath.isEmpty - { - configuredPath - } else { - ".omp" - } - guard !name.hasPrefix("/") else { return nil } - - let canonicalHome = Self.canonicalURL(home) - let configRoot = Self.canonicalURL( - canonicalHome.appendingPathComponent(name, isDirectory: true)) - guard Self.isWithin(root: canonicalHome, candidate: configRoot) else { return nil } - return configRoot + private struct PiSessionRootResolution { + let roots: [SessionRoot] + let isComplete: Bool } - private static func customAgentRoot( - environment: [String: String], - baseDirectory: URL?, - fileManager: FileManager) -> URL? - { - self.environmentURL( - environment["PI_CODING_AGENT_DIR"], - baseDirectory: baseDirectory, - fileManager: fileManager) + private struct OMPSessionRootResolution { + let roots: [SessionRoot] + let profileDiscoveryIsComplete: Bool } - private static func environmentURL( - _ value: String?, - baseDirectory: URL?, - fileManager: FileManager) -> URL? - { - guard let value else { return nil } - let path = value.trimmingCharacters(in: .whitespacesAndNewlines) - guard !path.isEmpty else { return nil } - + struct CostSessionRoot: Hashable, Sendable { let url: URL - if path.hasPrefix("/") { - url = URL(fileURLWithPath: path, isDirectory: true) - } else { - guard let baseDirectory else { return nil } - url = baseDirectory.appendingPathComponent(path, isDirectory: true) - } - return Self.canonicalURL(url) - } - - private static func sanitizedDefaultEnvironment(_ environment: [String: String]) -> [String: String] { - // A process with an inaccessible environment must not inherit - // process-specific config, custom roots, XDG roots, or profile - // selectors from the scanner's ambient environment. HOME is the only - // input needed to identify the standard default profile root. - guard let home = environment["HOME"] else { return [:] } - return ["HOME": home] - } - - private static func currentDirectory(fileManager: FileManager) -> URL { - URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true) - } - - private static func sessionRoot(agentRoot: URL, fileManager: FileManager) -> URL? { - let canonicalAgentRoot = Self.canonicalURL(agentRoot) - let candidate = Self.canonicalURL( - agentRoot.appendingPathComponent("sessions", isDirectory: true)) - guard Self.isWithin(root: canonicalAgentRoot, candidate: candidate) else { return nil } - return candidate - } - - private static func canonicalAgentRoot(_ agentRoot: URL, home: URL) -> URL? { - let canonicalHome = Self.canonicalURL(home) - let canonicalAgentRoot = Self.canonicalURL(agentRoot) - guard Self.isWithin(root: canonicalHome, candidate: canonicalAgentRoot) else { return nil } - return canonicalAgentRoot - } - - private static func canonicalURL(_ url: URL) -> URL { - url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL - } - - private static func isDirectory(_ url: URL, fileManager: FileManager) -> Bool { - var isDirectory: ObjCBool = false - return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) && - isDirectory.boolValue - } - - fileprivate static func isWithin(root: URL, candidate: URL) -> Bool { - let rootPath = root.standardizedFileURL.path - let candidatePath = candidate.standardizedFileURL.path - if rootPath == "/" { - return candidatePath.hasPrefix("/") + let missingIsKnownEmpty: Bool + let resolutionIsComplete: Bool + let preserveAfterProcessExit: Bool + let retentionKeys: Set + + init( + url: URL, + missingIsKnownEmpty: Bool, + resolutionIsComplete: Bool = true, + preserveAfterProcessExit: Bool = false, + retentionKey: String? = nil, + retentionKeys: Set = []) + { + self.url = url + self.missingIsKnownEmpty = missingIsKnownEmpty + self.resolutionIsComplete = resolutionIsComplete + self.preserveAfterProcessExit = preserveAfterProcessExit + self.retentionKeys = retentionKeys.union(retentionKey.map { [$0] } ?? []) } - return candidatePath == rootPath || candidatePath.hasPrefix(rootPath + "/") } -} -struct PiFamilySessionScanner: Sendable { - struct ScanInput: Sendable { - let processes: [AgentProcessRecord] - let cwdByPID: [Int32: String] - let environment: [String: String] - let now: Date - let host: String - let config: SessionScanConfig - } - - private enum RootLayout: Hashable, Sendable { - case projectDirectories - case direct - } - - private struct SessionRoot: Hashable, Sendable { - let url: URL - let layout: RootLayout + enum RetainedSettingsRootResolution: Sendable { + case resolved(url: URL, retentionKey: String) + case removed + case unavailable } static func scan( @@ -502,6 +239,7 @@ struct PiFamilySessionScanner: Sendable { let now = input.now let host = input.host let config = input.config + // Provider-specific by design: Pi-family sessions are correlated only with Pi provider processes. let liveProcesses = Array(AgentSessionCorrelation.newestProcessesFirst( processes.filter { AgentPSOutputParser.provider(for: $0) == .pi }) .prefix(max(0, config.maxProcessCount))) @@ -529,8 +267,7 @@ struct PiFamilySessionScanner: Sendable { let roots = Self.sessionRoots( for: process, dialect: dialect, - cwd: processCWD, - environment: input.environment) + cwd: processCWD) for root in roots { guard directoryBudget.hasTimeRemaining() else { break } let canonicalRoot = Self.canonicalURL(root.url) @@ -568,6 +305,7 @@ struct PiFamilySessionScanner: Sendable { let id = record?.id ?? "pid:\(process.pid)" let startedAt = record?.startedAt ?? process.startedAt + // Provider-specific by design: this branch emits a Pi-family AgentSession with its fixed provider identity. sessions.append(AgentSession( id: id, provider: .pi, @@ -606,50 +344,564 @@ struct PiFamilySessionScanner: Sendable { private static func sessionRoots( for process: AgentProcessRecord, dialect: AgentSession.Dialect, + cwd: String) -> [SessionRoot] + { + guard let environment = processSelectorEnvironment(for: process) else { return [] } + // Provider-specific by design: Pi and OMP use different on-disk session-root contracts. + return switch dialect { + case .pi: + self.piSessionRootResolution( + process: process, + cwd: cwd, + environment: environment).roots + case .omp: + self.ompSessionRoots(process: process, cwd: cwd, environment: environment) + } + } + + private static func processWorkingDirectory( + _ context: PiSessionProcessContext, + process: AgentProcessRecord, + environment: [String: String]) -> String? + { + if let workingDirectory = context.workingDirectory { return workingDirectory.path } + // Only selectors independent of CWD may resolve after working-directory discovery fails. + return Self.hasCWDIndependentRootSelection(in: process, environment: environment) ? "/" : nil + } + + private static func processSelectorEnvironment(for process: AgentProcessRecord) -> [String: String]? { + if var environment = process.piSelectorEnvironment { + // Relative HOME cannot be resolved against the scanner's own working directory. + if let home = environment["HOME"], !home.hasPrefix("/") { + environment.removeValue(forKey: "HOME") + } + return environment + } + guard let selector = Self.commandLineValue( + "--session-dir", in: process.command, arguments: process.arguments), + selector.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("/") + else { return nil } + return [:] + } + + /// Resolves the same Pi-family roots used by live-session discovery for historical cost scans. + /// Keeping this in one resolver prevents the menu and cost surfaces from silently reading different stores. + static func costSessionRoots( + environment: [String: String], + baseDirectory: URL? = nil) -> [CostSessionRoot] + { + self.costSessionRoots( + environment: environment, + baseDirectories: baseDirectory.map { [$0] }) + } + + /// Resolves historical roots for every known Pi project directory. Project-level Pi settings are + /// relative to the process working directory, so a single app-wide current directory is not enough + /// when several Pi processes are active in different projects. + static func costSessionRoots( + environment: [String: String], + baseDirectories: [URL]? = nil, + processContexts: [PiSessionProcessContext] = []) -> [CostSessionRoot] + { + var configuredCWDs = baseDirectories ?? [] + if !processContexts.isEmpty { + // Live process roots augment the scanner's normal working directory so default and project history + // remain visible after a process starts or exits. + configuredCWDs.insert( + URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true), + at: 0) + } + let cwdURLs = (configuredCWDs.isEmpty ? [URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true)] : configuredCWDs) + .map(Self.canonicalURL) + let uniqueCWDs = cwdURLs.reduce(into: [URL]()) { result, url in + guard !result.contains(where: { $0.path == url.path }) else { return } + result.append(url) + } + let defaultProcess = AgentProcessRecord(pid: 0, ppid: 0, startedAt: nil, command: "") + let hasExplicitProcessProfile = Self.hasExplicitProcessProfile(in: processContexts) + // Provider-specific by design: historical cost scans must resolve both Pi dialects through the shared root + // resolver. + let dialects: [AgentSession.Dialect] = [.pi, .omp] + var output: [CostSessionRoot] = [] + var outputIndexByPath: [String: Int] = [:] + + func appendCostRoot(_ root: CostSessionRoot) { + let canonical = Self.canonicalURL(root.url) + let candidate = CostSessionRoot( + url: canonical, + missingIsKnownEmpty: root.missingIsKnownEmpty, + resolutionIsComplete: root.resolutionIsComplete, + preserveAfterProcessExit: root.preserveAfterProcessExit, + retentionKeys: root.retentionKeys) + guard let index = outputIndexByPath[canonical.path] else { + outputIndexByPath[canonical.path] = output.count + output.append(candidate) + return + } + + // A shared root can be discovered through both dialects. Preserve the strictest + // availability contract and all durable provenance when those discoveries converge. + let existing = output[index] + output[index] = CostSessionRoot( + url: canonical, + missingIsKnownEmpty: existing.missingIsKnownEmpty && candidate.missingIsKnownEmpty, + resolutionIsComplete: existing.resolutionIsComplete && candidate.resolutionIsComplete, + preserveAfterProcessExit: existing.preserveAfterProcessExit || candidate.preserveAfterProcessExit, + retentionKeys: existing.retentionKeys.union(candidate.retentionKeys)) + } + + for dialect in dialects { + var roots: [SessionRoot] = [] + var rootResolutionIsComplete = true + for context in processContexts { + let process = AgentProcessRecord( + pid: 0, + ppid: 0, + startedAt: nil, + command: context.command, + arguments: context.arguments, + piSelectorEnvironment: context.selectorEnvironment) + guard AgentPSOutputParser.piDialect(for: process) == dialect else { continue } + guard let processEnvironment = Self.processSelectorEnvironment(for: process) else { + rootResolutionIsComplete = false + continue + } + guard let contextCWD = Self.processWorkingDirectory( + context, process: process, environment: processEnvironment) + else { + rootResolutionIsComplete = false + continue + } + let resolution: (roots: [SessionRoot], isComplete: Bool) + switch dialect { + // Provider-specific by design: this branch resolves the Pi dialect's session roots. + case .pi: + let result = Self.piSessionRootResolution( + process: process, + cwd: contextCWD, + environment: processEnvironment, + preserveSettingsRoot: context.workingDirectory != nil) + resolution = (result.roots, result.isComplete) + case .omp: + let result = Self.ompSessionRootResolution( + process: process, + cwd: contextCWD, + environment: processEnvironment, + suppressProfileDiscovery: hasExplicitProcessProfile) + resolution = (result.roots, result.profileDiscoveryIsComplete) + } + // A process-selected root is safe to retain after exit because the + // selector is explicit. Roots reached only through the process's + // working directory or inherited environment must be re-resolved on + // the next scan, otherwise a stale project can remain attributed. + let processRootIsRetained = Self.hasExplicitProcessRootSelection( + dialect: dialect, + processContexts: [context]) + roots.append(contentsOf: resolution.roots.map { root in + SessionRoot( + url: root.url, + layout: root.layout, + missingIsKnownEmpty: root.missingIsKnownEmpty, + preserveAfterProcessExit: root.preserveAfterProcessExit || processRootIsRetained, + retentionKey: root.retentionKey ?? Self.processRetentionKey( + dialect: dialect, + process: process, + cwd: contextCWD, + environment: processEnvironment)) + }) + rootResolutionIsComplete = rootResolutionIsComplete && resolution.isComplete + } + for cwdURL in uniqueCWDs { + switch dialect { + // Provider-specific by design: this branch resolves the Pi dialect's session roots. + case .pi: + let resolution = Self.piSessionRootResolution( + process: defaultProcess, + cwd: cwdURL.path, + environment: environment, + preserveSettingsRoot: false) + roots.append(contentsOf: resolution.roots) + rootResolutionIsComplete = rootResolutionIsComplete && resolution.isComplete + case .omp: + let resolution = Self.ompSessionRootResolution( + process: defaultProcess, + cwd: cwdURL.path, + environment: environment, + suppressProfileDiscovery: hasExplicitProcessProfile) + roots.append(contentsOf: resolution.roots) + rootResolutionIsComplete = rootResolutionIsComplete && resolution.profileDiscoveryIsComplete + } + } + let hasExplicitSelection = Self.hasExplicitCostRootSelection( + dialect: dialect, + environment: environment) || Self.hasExplicitProcessRootSelection( + dialect: dialect, + processContexts: processContexts) + if roots.isEmpty, hasExplicitSelection { + appendCostRoot(CostSessionRoot( + url: Self.unresolvedCostSessionRoot(for: dialect), + missingIsKnownEmpty: false, + resolutionIsComplete: false)) + continue + } + for root in roots { + let canonical = Self.canonicalURL(root.url) + appendCostRoot(CostSessionRoot( + url: canonical, + missingIsKnownEmpty: root.missingIsKnownEmpty, + resolutionIsComplete: true, + preserveAfterProcessExit: root.preserveAfterProcessExit, + retentionKey: root.retentionKey)) + } + if !rootResolutionIsComplete { + let unresolved = Self.unresolvedCostSessionRoot(for: dialect) + appendCostRoot(CostSessionRoot( + url: unresolved, + missingIsKnownEmpty: false, + resolutionIsComplete: false)) + } + } + return output + } + + private static func hasExplicitProcessProfile(in contexts: [PiSessionProcessContext]) -> Bool { + contexts.contains { context in + let process = AgentProcessRecord( + pid: 0, + ppid: 0, + startedAt: nil, + command: context.command, + arguments: context.arguments) + return AgentPSOutputParser.piDialect(for: process) == .omp && + (Self.commandLineValue( + "--profile", + in: process.command, + arguments: process.arguments) != nil || + context.selectorEnvironment?["OMP_PROFILE"] != nil || + context.selectorEnvironment?["PI_PROFILE"] != nil) + } + } + + private static func unresolvedCostSessionRoot(for dialect: AgentSession.Dialect) -> URL { + // Keep an unresolved selection visible to the cost scanner without ever enumerating a + // real directory. The completion flag is the source of truth; this path is only a stable + // cache-key component and a defensive placeholder. + URL(fileURLWithPath: "/.codexbar-unresolved-\(dialect.rawValue)", isDirectory: true) + } + + private static func defaultCostSessionRoot( + for dialect: AgentSession.Dialect, + environment: [String: String]) -> URL? + { + guard let home = homeURL(environment) else { return nil } + // Provider-specific by design: Pi and OMP keep their default histories under distinct home directories. + let directory = dialect == .pi ? ".pi" : ".omp" + return Self.canonicalURL( + home + .appendingPathComponent(directory, isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true)) + } + + private static func hasExplicitCostRootSelection( + dialect: AgentSession.Dialect, + environment: [String: String]) -> Bool + { + // Provider-specific by design: these environment keys select Pi-family history roots rather than generic + // policy. + let keys: [String] = switch dialect { + case .pi: + ["PI_CODING_AGENT_SESSION_DIR", "PI_CODING_AGENT_DIR"] + case .omp: + [ + "PI_CODING_AGENT_SESSION_DIR", + "PI_CONFIG_DIR", + "PI_CODING_AGENT_DIR", + "OMP_PROFILE", + "PI_PROFILE", + ] + } + return keys.contains { key in + guard let value = environment[key] else { return false } + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private static func hasExplicitProcessRootSelection( + dialect: AgentSession.Dialect, + processContexts: [PiSessionProcessContext]) -> Bool + { + processContexts.contains { context in + let process = AgentProcessRecord( + pid: 0, + ppid: 0, + startedAt: nil, + command: context.command, + arguments: context.arguments) + guard AgentPSOutputParser.piDialect(for: process) == dialect else { return false } + return switch dialect { + // Provider-specific by design: this branch checks selectors for the Pi dialect. + case .pi: + Self.commandLineValue( + "--session-dir", + in: context.command, + arguments: context.arguments) != nil + case .omp: + Self.commandLineValue( + "--session-dir", + in: context.command, + arguments: context.arguments) != nil || + Self.commandLineValue( + "--profile", + in: context.command, + arguments: context.arguments) != nil + } + } + } + + /// Returns whether a process can resolve its session store without a working directory. + /// Absolute (or home-relative) session directories and validated named OMP profiles have + /// enough information to resolve from HOME alone. + static func hasCWDIndependentRootSelection( + in process: AgentProcessRecord, + environment: [String: String]) -> Bool + { + if let selector = commandLineValue( + "--session-dir", in: process.command, arguments: process.arguments) + { + return self.isCWDIndependentPath(selector, environment: environment) + } + if let selector = environment["PI_CODING_AGENT_SESSION_DIR"], + !selector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return self.isCWDIndependentPath(selector, environment: environment) + } + if AgentPSOutputParser.piDialect(for: process) == .pi, + let selector = environment["PI_CODING_AGENT_DIR"], + !selector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return self.isCWDIndependentPath(selector, environment: environment) + } + guard AgentPSOutputParser.piDialect(for: process) == .omp, + let profile = commandLineValue( + "--profile", + in: process.command, + arguments: process.arguments) ?? environment["OMP_PROFILE"] ?? environment["PI_PROFILE"] + else { return false } + return OMPSessionRootResolver.canResolveNamedProfileWithoutWorkingDirectory( + profile, + environment: environment) + } + + private static func isCWDIndependentPath(_ value: String, environment: [String: String]) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") || trimmed == "~" || trimmed.hasPrefix("~/") else { return false } + return Self.pathURL(value, cwd: "/", home: environment["HOME"]) != nil + } + + static func processRootSelectorKey(_ context: PiSessionProcessContext) -> String { + let process = AgentProcessRecord( + pid: 0, ppid: 0, startedAt: nil, command: context.command, arguments: context.arguments) + let dialect = AgentPSOutputParser.piDialect(for: process)?.rawValue ?? "unknown" + // Model, prompt and presentation arguments do not select another history store. + let sessionDirectory = Self.commandLineValue( + "--session-dir", in: context.command, arguments: context.arguments) + let profile = Self.commandLineValue("--profile", in: context.command, arguments: context.arguments) + let components = [ + dialect, + context.workingDirectory?.standardizedFileURL.path ?? "", + sessionDirectory ?? "", + profile ?? "", + PiProcessEnvironment.scopeKey(context.selectorEnvironment), + ] + return components.map { "\($0.utf8.count):\($0)" }.joined() + } + + private static func processRetentionKey( + dialect: AgentSession.Dialect, + process: AgentProcessRecord, cwd: String, - environment: [String: String]) -> [SessionRoot] + environment: [String: String]) -> String? { - if let explicit = commandLineValue("--session-dir", in: process.command), - let url = pathURL(explicit, cwd: cwd, home: environment["HOME"]) + if let selector = commandLineValue( + "--session-dir", + in: process.command, + arguments: process.arguments), + let url = pathURL(selector, cwd: cwd, home: environment["HOME"]) { - return [SessionRoot(url: url, layout: .direct)] + return "process:" + dialect.rawValue + ":session-dir:" + url.path } - if let configured = environment["PI_CODING_AGENT_SESSION_DIR"], - let url = pathURL(configured, cwd: cwd, home: environment["HOME"]) + if dialect == .omp, + let profile = Self.commandLineValue( + "--profile", + in: process.command, + arguments: process.arguments) { - return [SessionRoot(url: url, layout: .direct)] + return "process:omp:profile:" + profile } + return nil + } - switch dialect { - case .pi: - if let agentDirectory = environment["PI_CODING_AGENT_DIR"], - let agentRoot = pathURL(agentDirectory, cwd: cwd, home: environment["HOME"]) - { - return [SessionRoot( - url: agentRoot.appendingPathComponent("sessions", isDirectory: true), - layout: .projectDirectories)] + private static func settingsRetentionKey( + _ settingsURL: URL, + sessionDirectory: String, + resolvingDirectory: String, + home: String?) -> String + { + let trimmed = sessionDirectory.trimmingCharacters(in: .whitespacesAndNewlines) + let selectorIsCWDIndependent = trimmed.hasPrefix("/") || + trimmed == "~" || + trimmed.hasPrefix("~/") + let base = if selectorIsCWDIndependent { + "" + } else { + ":base=" + self.canonicalURL(URL(fileURLWithPath: resolvingDirectory, isDirectory: true)).path + } + let homeEvidence = home.map { ":home=" + Data($0.utf8).base64EncodedString() } ?? "" + return "settings:" + self.canonicalURL(settingsURL).path + base + homeEvidence + } + + static func retainedSettingsRootResolution(retentionKey: String) -> RetainedSettingsRootResolution { + let prefix = "settings:" + guard retentionKey.hasPrefix(prefix) else { return .unavailable } + var payload = String(retentionKey.dropFirst(prefix.count)) + guard !payload.isEmpty else { return .unavailable } + + let capturedHome: String? + if let homeRange = payload.range(of: ":home=", options: .backwards) { + guard let data = Data(base64Encoded: String(payload[homeRange.upperBound...])), + let home = String(data: data, encoding: .utf8), home.hasPrefix("/") + else { return .unavailable } + capturedHome = home + payload = String(payload[.. String? { + let parent = settingsURL.deletingLastPathComponent() + // Provider-specific by design: only Pi project settings use the `.pi/settings.json` path. + guard parent.lastPathComponent == ".pi" else { return nil } + let grandparent = parent.deletingLastPathComponent() + // Project settings live at /.pi/settings.json. Global settings use the + // separate /.pi/agent/settings.json layout and never reach this helper. + return self.canonicalURL(grandparent).path + } + private static func ompSessionRoots( process: AgentProcessRecord, cwd: String, environment: [String: String]) -> [SessionRoot] { - guard let home = homeURL(environment) else { return [] } + self.ompSessionRootResolution(process: process, cwd: cwd, environment: environment).roots + } + + private static func ompSessionRootResolution( + process: AgentProcessRecord, + cwd: String, + environment: [String: String], + suppressProfileDiscovery: Bool = false) -> OMPSessionRootResolution + { + let processHasExplicitSelection = Self.commandLineValue( + "--session-dir", + in: process.command, + arguments: process.arguments) != nil || Self.commandLineValue( + "--profile", + in: process.command, + arguments: process.arguments) != nil + if let explicit = commandLineValue( + "--session-dir", + in: process.command, + arguments: process.arguments), + let url = pathURL(explicit, cwd: cwd, home: environment["HOME"]) + { + return OMPSessionRootResolution( + roots: [SessionRoot(url: url, layout: .direct)], + profileDiscoveryIsComplete: true) + } + if let configured = environment["PI_CODING_AGENT_SESSION_DIR"], + let url = pathURL(configured, cwd: cwd, home: environment["HOME"]) + { + return OMPSessionRootResolution( + roots: [SessionRoot(url: url, layout: .direct)], + profileDiscoveryIsComplete: true) + } + + guard let home = homeURL(environment) else { + return OMPSessionRootResolution(roots: [], profileDiscoveryIsComplete: false) + } var safeEnvironment = ["HOME": home.path] for key in [ "PI_CONFIG_DIR", @@ -660,67 +912,111 @@ struct PiFamilySessionScanner: Sendable { ] { safeEnvironment[key] = environment[key] } - if let profile = Self.commandLineValue("--profile", in: process.command) { + if suppressProfileDiscovery, + safeEnvironment["OMP_PROFILE"] == nil, + safeEnvironment["PI_PROFILE"] == nil + { + // A live named profile is authoritative for this scan. Keep the ambient default root, + // but do not broaden it to unrelated profiles discovered on disk. + safeEnvironment["OMP_PROFILE"] = "default" + } + if let profile = Self.commandLineValue( + "--profile", + in: process.command, + arguments: process.arguments) + { safeEnvironment["OMP_PROFILE"] = profile } let baseDirectory = URL(fileURLWithPath: cwd, isDirectory: true) - var urls = OMPSessionRootResolver.sessionRoots( + var resolvedRoots = OMPSessionRootResolver.resolvedSessionRoots( environment: safeEnvironment, - baseDirectory: baseDirectory) - - if safeEnvironment["OMP_PROFILE"] == nil { - let profileParents = [ - home - .appendingPathComponent(".omp", isDirectory: true) - .appendingPathComponent("profiles", isDirectory: true), - Self.xdgDataHome(environment, home: home) - .appendingPathComponent("omp", isDirectory: true) - .appendingPathComponent("profiles", isDirectory: true), - ] + baseDirectory: baseDirectory).map { root in + let canonical = Self.canonicalURL(root.url) + let defaultRootIsKnownEmpty = !processHasExplicitSelection && + !Self.hasExplicitCostRootSelection(dialect: .omp, environment: environment) && + Self.defaultCostSessionRoot(for: .omp, environment: environment) == canonical + return SessionRoot( + url: canonical, + layout: root.layout, + missingIsKnownEmpty: defaultRootIsKnownEmpty) + } + var profileDiscoveryIsComplete = true + + if safeEnvironment["OMP_PROFILE"] == nil, + safeEnvironment["PI_PROFILE"] == nil + { + let profileParents = OMPSessionRootResolver.profileDiscoveryDirectories( + environment: safeEnvironment, + baseDirectory: baseDirectory) for parent in profileParents { - urls.append(contentsOf: Self.profileSessionRoots(in: parent)) + let resolution = Self.profileSessionRoots(in: parent) + resolvedRoots.append(contentsOf: resolution.roots) + profileDiscoveryIsComplete = profileDiscoveryIsComplete && resolution.isComplete } } var seen = Set() - return urls.compactMap { url in - let canonical = Self.canonicalURL(url) + let roots: [SessionRoot] = resolvedRoots.compactMap { root in + let canonical = Self.canonicalURL(root.url) guard seen.insert(canonical.path).inserted else { return nil } - return SessionRoot(url: canonical, layout: .projectDirectories) + return SessionRoot( + url: canonical, + layout: root.layout, + missingIsKnownEmpty: root.missingIsKnownEmpty, + preserveAfterProcessExit: root.preserveAfterProcessExit, + retentionKey: root.retentionKey) } + return OMPSessionRootResolution( + roots: roots, + profileDiscoveryIsComplete: profileDiscoveryIsComplete) } - private static func profileSessionRoots(in profilesDirectory: URL) -> [URL] { - guard let enumerator = FileManager.default.enumerator( - at: profilesDirectory, - includingPropertiesForKeys: [.isDirectoryKey], - options: [.skipsHiddenFiles, .skipsSubdirectoryDescendants]) - else { return [] } - - var roots: [URL] = [] - let canonicalProfilesDirectory = Self.canonicalURL(profilesDirectory) - while roots.count < 64, let profile = enumerator.nextObject() as? URL { - let canonicalProfile = Self.canonicalURL(profile) - guard (try? canonicalProfile.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true, - OMPSessionRootResolver.isWithin( - root: canonicalProfilesDirectory, - candidate: canonicalProfile) - else { continue } - let xdgLayout = canonicalProfile.appendingPathComponent("sessions", isDirectory: true) - if Self.isDirectory(xdgLayout) { - roots.append(xdgLayout) - continue + private static func piSessionRootResolution( + process: AgentProcessRecord, + cwd: String, + environment: [String: String], + preserveSettingsRoot: Bool = false) -> PiSessionRootResolution + { + if let explicit = commandLineValue( + "--session-dir", + in: process.command, + arguments: process.arguments) + { + guard let url = pathURL(explicit, cwd: cwd, home: environment["HOME"]) else { + return PiSessionRootResolution(roots: [], isComplete: false) } - roots.append(canonicalProfile - .appendingPathComponent("agent", isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true)) + return PiSessionRootResolution( + roots: [SessionRoot(url: url, layout: .direct)], + isComplete: true) + } + if let configured = environment["PI_CODING_AGENT_SESSION_DIR"], + !configured.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + guard let url = pathURL(configured, cwd: cwd, home: environment["HOME"]) else { + return PiSessionRootResolution(roots: [], isComplete: false) + } + return PiSessionRootResolution( + roots: [SessionRoot(url: url, layout: .direct)], + isComplete: true) + } + if let agentDirectory = environment["PI_CODING_AGENT_DIR"], + !agentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + guard let agentRoot = pathURL(agentDirectory, cwd: cwd, home: environment["HOME"]) else { + return PiSessionRootResolution(roots: [], isComplete: false) + } + return PiSessionRootResolution( + roots: [SessionRoot( + url: agentRoot.appendingPathComponent("sessions", isDirectory: true), + layout: .projectDirectories)], + isComplete: true) } - return roots.sorted { $0.path < $1.path } - } - private static func piSettingsSessionDirectory(cwd: String, environment: [String: String]) -> URL? { - guard let home = homeURL(environment) else { return nil } + guard let home = Self.homeURL(environment) else { + return PiSessionRootResolution(roots: [], isComplete: false) + } + // Provider-specific by design: Pi's settings paths are distinct from OMP's profile roots. let globalSettings = home .appendingPathComponent(".pi", isDirectory: true) .appendingPathComponent("agent", isDirectory: true) @@ -729,25 +1025,185 @@ struct PiFamilySessionScanner: Sendable { .appendingPathComponent(".pi", isDirectory: true) .appendingPathComponent("settings.json") - let configured = Self.sessionDirectory(in: projectSettings) ?? Self.sessionDirectory(in: globalSettings) - return configured.flatMap { Self.pathURL($0, cwd: cwd, home: home.path) } + let configured: (value: String, retentionKey: String)? + switch Self.sessionDirectoryResolution(in: projectSettings) { + case let .configured(value): + configured = ( + value, + Self.settingsRetentionKey( + projectSettings, + sessionDirectory: value, + resolvingDirectory: cwd, + home: home.path)) + case .unavailable: + return PiSessionRootResolution(roots: [], isComplete: false) + case .missing, .noSessionDirectory: + switch Self.sessionDirectoryResolution(in: globalSettings) { + case let .configured(value): + configured = ( + value, + Self.settingsRetentionKey( + globalSettings, + sessionDirectory: value, + resolvingDirectory: cwd, + home: home.path)) + case .unavailable: + return PiSessionRootResolution(roots: [], isComplete: false) + case .missing, .noSessionDirectory: + configured = nil + } + } + + if let configured { + guard let url = Self.pathURL(configured.value, cwd: cwd, home: home.path) else { + return PiSessionRootResolution(roots: [], isComplete: false) + } + return PiSessionRootResolution( + roots: [SessionRoot( + url: url, + layout: .direct, + preserveAfterProcessExit: preserveSettingsRoot, + retentionKey: configured.retentionKey)], + isComplete: true) + } + + // Provider-specific by design: this path is Pi's default project session directory. + return PiSessionRootResolution( + roots: [SessionRoot( + url: home + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true), + layout: .projectDirectories, + missingIsKnownEmpty: true)], + isComplete: true) } - private static func sessionDirectory(in settingsURL: URL) -> String? { - guard let values = try? settingsURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), + private enum ProfileDirectoryInspection { + case missing + case notDirectory + case readableDirectory + case unavailable + } + + private static func profileDirectoryInspection(_ url: URL) -> ProfileDirectoryInspection { + let fileManager = FileManager.default + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return .missing + } + guard isDirectory.boolValue else { return .notDirectory } + guard fileManager.isReadableFile(atPath: url.path) else { + return .unavailable + } + return .readableDirectory + } + + private static func profileSessionRoots(in profilesDirectory: URL) -> ProfileSessionRootResolution { + switch self.profileDirectoryInspection(profilesDirectory) { + case .missing: + return ProfileSessionRootResolution(roots: [], isComplete: true) + case .notDirectory, .unavailable: + return ProfileSessionRootResolution(roots: [], isComplete: false) + case .readableDirectory: + break + } + + let profiles: [URL] + do { + profiles = try FileManager.default.contentsOfDirectory( + at: profilesDirectory, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles]) + } catch { + return ProfileSessionRootResolution(roots: [], isComplete: false) + } + + var roots: [SessionRoot] = [] + var isComplete = true + let canonicalProfilesDirectory = Self.canonicalURL(profilesDirectory) + for profile in profiles { + guard roots.count < 64 else { + isComplete = false + break + } + let canonicalProfile = Self.canonicalURL(profile) + guard OMPSessionRootResolver.isWithin( + root: canonicalProfilesDirectory, + candidate: canonicalProfile) + else { continue } + switch Self.profileDirectoryInspection(canonicalProfile) { + case .missing, .notDirectory: + continue + case .unavailable: + isComplete = false + continue + case .readableDirectory: + break + } + let xdgLayout = canonicalProfile.appendingPathComponent("sessions", isDirectory: true) + switch Self.profileDirectoryInspection(xdgLayout) { + case .readableDirectory: + roots.append(SessionRoot(url: xdgLayout, layout: .direct)) + case .unavailable: + isComplete = false + case .missing, .notDirectory: + break + } + let agentLayout = canonicalProfile + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + switch Self.profileDirectoryInspection(agentLayout) { + case .readableDirectory: + roots.append(SessionRoot(url: agentLayout, layout: .projectDirectories)) + case .unavailable: + isComplete = false + case .missing, .notDirectory: + break + } + } + return ProfileSessionRootResolution( + roots: roots.sorted { $0.url.path < $1.url.path }, + isComplete: isComplete) + } + + private enum PiSettingsSessionDirectoryResolution { + case missing + case noSessionDirectory + case configured(String) + case unavailable + } + + private static func sessionDirectoryResolution(in settingsURL: URL) -> PiSettingsSessionDirectoryResolution { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: settingsURL.path, isDirectory: &isDirectory) else { + return .missing + } + guard !isDirectory.boolValue, + let values = try? settingsURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]), values.isRegularFile == true, let fileSize = values.fileSize, fileSize <= 1024 * 1024, let data = try? Data(contentsOf: settingsURL, options: [.mappedIfSafe]), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let sessionDir = object["sessionDir"] as? String, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return .unavailable + } + guard let rawValue = object["sessionDir"] else { return .noSessionDirectory } + guard let sessionDir = rawValue as? String, !sessionDir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { return nil } - return sessionDir + else { + return .unavailable + } + return .configured(sessionDir) } - private static func commandLineValue(_ flag: String, in command: String) -> String? { - let tokens = command.split(whereSeparator: \ .isWhitespace).map(String.init) + private static func commandLineValue( + _ flag: String, + in command: String, + arguments: [String]? = nil) -> String? + { + let tokens = arguments ?? command.split(whereSeparator: \ .isWhitespace).map(String.init) for index in tokens.indices { if tokens[index] == flag, index + 1 < tokens.count { let value = tokens[index + 1] @@ -765,6 +1221,9 @@ struct PiFamilySessionScanner: Sendable { private static func pathURL(_ path: String, cwd: String, home: String?) -> URL? { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + if trimmed == "~" || trimmed.hasPrefix("~/") { + guard let home, home.hasPrefix("/") else { return nil } + } let expanded: String = if trimmed == "~", let home { home } else if trimmed.hasPrefix("~/"), let home { @@ -785,15 +1244,6 @@ struct PiFamilySessionScanner: Sendable { return URL(fileURLWithPath: home, isDirectory: true).standardizedFileURL } - private static func xdgDataHome(_ environment: [String: String], home: URL) -> URL { - if let configured = environment["XDG_DATA_HOME"], !configured.isEmpty { - return URL(fileURLWithPath: configured, isDirectory: true).standardizedFileURL - } - return home - .appendingPathComponent(".local", isDirectory: true) - .appendingPathComponent("share", isDirectory: true) - } - private static func isDirectory(_ url: URL) -> Bool { var isDirectory: ObjCBool = false return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue @@ -803,7 +1253,7 @@ struct PiFamilySessionScanner: Sendable { in root: URL, now: Date, dialect: AgentSession.Dialect, - layout: RootLayout, + layout: PiFamilySessionRootLayout, directoryBudget: inout DirectoryMetadataScanBudget) -> [PiFamilySessionRecord] { let fileManager = FileManager.default diff --git a/Sources/CodexBarCore/PiProcessEnvironment.swift b/Sources/CodexBarCore/PiProcessEnvironment.swift new file mode 100644 index 0000000000..cac7e3fa9b --- /dev/null +++ b/Sources/CodexBarCore/PiProcessEnvironment.swift @@ -0,0 +1,76 @@ +import Foundation + +enum PiProcessEnvironment { + static let maxEnvironmentBytes = 1_048_576 + static let selectorNames: Set = [ + "HOME", + "PI_CODING_AGENT_SESSION_DIR", + "PI_CODING_AGENT_DIR", + "PI_CONFIG_DIR", + "OMP_PROFILE", + "PI_PROFILE", + "XDG_DATA_HOME", + ] + + static func filtered(_ environment: [String: String]?) -> [String: String]? { + environment.map { values in + values.filter { self.selectorNames.contains($0.key) } + } + } + + static func parseNULSeparated(_ data: Data) -> [String: String]? { + guard data.count <= self.maxEnvironmentBytes, + data.isEmpty || data.last == 0 + else { return nil } + + var selected: [String: String] = [:] + for record in data.split(separator: 0) { + guard let separator = record.firstIndex(of: 61) else { return nil } + guard let name = String(bytes: record[.. [String: String]? + { + guard pid > 0 else { return nil } + let url = procRoot + .appendingPathComponent(String(pid), isDirectory: true) + .appendingPathComponent("environ") + guard let file = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? file.close() } + + do { + var data = Data() + while data.count <= self.maxEnvironmentBytes { + let remaining = self.maxEnvironmentBytes + 1 - data.count + let chunk = try file.read(upToCount: min(16384, remaining)) ?? Data() + if chunk.isEmpty { return self.parseNULSeparated(data) } + data.append(chunk) + } + } catch { + return nil + } + return nil + } + + static func scopeKey(_ environment: [String: String]?) -> String { + guard let selected = self.filtered(environment) else { return "unavailable" } + var key = "available:\(selected.count):" + for name in selected.keys.sorted() { + guard let value = selected[name] else { continue } + // Byte lengths prevent selectors containing separators from sharing a key. + key += "\(name.utf8.count):\(name)\(value.utf8.count):\(value)" + } + return key + } +} diff --git a/Sources/CodexBarCore/PiSessionCostCache.swift b/Sources/CodexBarCore/PiSessionCostCache.swift index cd3923d335..ab9ad88661 100644 --- a/Sources/CodexBarCore/PiSessionCostCache.swift +++ b/Sources/CodexBarCore/PiSessionCostCache.swift @@ -2,7 +2,9 @@ import Foundation enum PiSessionCostCacheIO { /// Artifact schema version. Pricing changes are tracked separately by `pricingKey`. - private static let artifactVersion = 8 + /// v9 invalidates artifacts produced before stricter root and parser + /// provenance checks were introduced. + private static let artifactVersion = 9 private static func defaultCacheRoot() -> URL { let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! @@ -39,6 +41,7 @@ enum PiSessionCostCacheIO { var cache = cache cache.timeZoneIdentifier = calendar.timeZone.identifier guard let data = try? JSONEncoder().encode(cache) else { return } + // Write at the final path: FileManager replacement can lose the destination on Linux. try? data.write(to: url, options: [.atomic]) } } @@ -50,10 +53,11 @@ struct PiSessionCostCache: Codable { var scanUntilKey: String? var timeZoneIdentifier: String? var pricingKey: String? + var sessionRootsFingerprint: String? var daysByProvider: [String: [String: [String: PiPackedUsage]]] = [:] var files: [String: PiSessionFileUsage] = [:] - init(version: Int = 8) { + init(version: Int = 9) { self.version = version } } @@ -62,30 +66,48 @@ struct PiSessionFileUsage: Codable { var mtimeUnixMs: Int64 var size: Int64 var parsedBytes: Int64 + var fileIdentity: String? var sessionID: String? var lastModelContext: PiModelContext? var contributions: [String: [String: [String: PiPackedUsage]]] var unkeyedContributions: [String: [String: [String: PiPackedUsage]]] var entryUsages: [String: PiSessionEntryUsage] + var unsupportedAssistantDayKeys: Set + var hasUndatedUnsupportedAssistant: Bool init( mtimeUnixMs: Int64, size: Int64, parsedBytes: Int64, + fileIdentity: String? = nil, sessionID: String? = nil, lastModelContext: PiModelContext?, contributions: [String: [String: [String: PiPackedUsage]]], unkeyedContributions: [String: [String: [String: PiPackedUsage]]] = [:], - entryUsages: [String: PiSessionEntryUsage] = [:]) + entryUsages: [String: PiSessionEntryUsage] = [:], + unsupportedAssistantDayKeys: Set = [], + hasUndatedUnsupportedAssistant: Bool = false) { self.mtimeUnixMs = mtimeUnixMs self.size = size self.parsedBytes = parsedBytes + self.fileIdentity = fileIdentity self.sessionID = sessionID self.lastModelContext = lastModelContext self.contributions = contributions self.unkeyedContributions = unkeyedContributions self.entryUsages = entryUsages + self.unsupportedAssistantDayKeys = unsupportedAssistantDayKeys + self.hasUndatedUnsupportedAssistant = hasUndatedUnsupportedAssistant + } + + var requiresFullReparseOnChange: Bool { + self.hasUndatedUnsupportedAssistant || !self.unsupportedAssistantDayKeys.isEmpty || + self.contributions.values.contains { days in + days.values.contains { models in + models.values.contains { ($0.usageSampleCount ?? 0) > $0.costSampleCount } + } + } } } @@ -99,6 +121,7 @@ struct PiSessionEntryUsage: Codable, Equatable { struct PiModelContext: Codable, Equatable { var providerRawValue: String var modelName: String + var isUnsupportedBackend: Bool = false } struct PiPackedUsage: Codable, Equatable { diff --git a/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift b/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift new file mode 100644 index 0000000000..5eaa4cbeb8 --- /dev/null +++ b/Sources/CodexBarCore/PiSessionCostScanner+Aggregation.swift @@ -0,0 +1,153 @@ +import Foundation + +extension PiSessionCostScanner { + static func checkedReports( + cache: PiSessionCostCache, + range: CostUsageScanner.CostUsageDayRange) + -> (codex: CostUsageDailyReport, claude: CostUsageDailyReport)? + { + // Provider-specific by design: Pi's shared cache has Codex and Anthropic pricing partitions. + guard let codex = self.buildReport(provider: .codex, cache: cache, range: range), + let claude = self.buildReport(provider: .claude, cache: cache, range: range) + else { return nil } + return (codex, claude) + } + + static func buildReport( + provider: UsageProvider, + cache: PiSessionCostCache, + range: CostUsageScanner.CostUsageDayRange) -> CostUsageDailyReport? + { + let providerDays = cache.daysByProvider[provider.rawValue] ?? [:] + let dayKeys = providerDays.keys.sorted().filter { + CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + var entries: [CostUsageDailyReport.Entry] = [] + var total = PiPackedUsage() + for dayKey in dayKeys { + guard let models = providerDays[dayKey] else { continue } + var day = PiPackedUsage() + var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] + for modelName in models.keys.sorted() { + guard var packed = models[modelName], + let derivedTokens = CheckedSum.integers([ + packed.inputTokens, packed.cacheReadTokens, packed.cacheWriteTokens, packed.outputTokens, + ]) + else { return nil } + packed.totalTokens = max(packed.totalTokens, derivedTokens) + guard let next = self.addPacked(a: day, b: packed) else { return nil } + day = next + // Keep per-message prices and unknown-price evidence; never reprice an aggregate request. + breakdown.append(CostUsageDailyReport.ModelBreakdown( + modelName: modelName, + costUSD: packed.costSampleCount > 0 ? Double(packed.costNanos) / self.costScale : nil, + totalTokens: packed.totalTokens, + requestCount: packed.usageSampleCount)) + } + guard let next = self.addPacked(a: total, b: day) else { return nil } + total = next + let requestCount = day.usageSampleCount ?? 0 + entries.append(CostUsageDailyReport.Entry( + date: dayKey, + inputTokens: day.inputTokens > 0 ? day.inputTokens : nil, + outputTokens: day.outputTokens > 0 ? day.outputTokens : nil, + cacheReadTokens: day.cacheReadTokens > 0 ? day.cacheReadTokens : nil, + cacheCreationTokens: day.cacheWriteTokens > 0 ? day.cacheWriteTokens : nil, + totalTokens: day.totalTokens, + requestCount: requestCount, + costUSD: day.costSampleCount > 0 ? Double(day.costNanos) / self.costScale : nil, + modelsUsed: models.keys.sorted(), + modelBreakdowns: self.sortedModelBreakdowns(breakdown), + unpricedRequestCount: requestCount - day.costSampleCount, + estimatedRequestCount: day.costSampleCount, + pricedRequestCount: 0)) + } + guard !entries.isEmpty else { return CostUsageDailyReport(data: [], summary: nil) } + return CostUsageDailyReport( + data: entries, + summary: CostUsageDailyReport.Summary( + totalInputTokens: total.inputTokens > 0 ? total.inputTokens : nil, + totalOutputTokens: total.outputTokens > 0 ? total.outputTokens : nil, + cacheReadTokens: total.cacheReadTokens > 0 ? total.cacheReadTokens : nil, + cacheCreationTokens: total.cacheWriteTokens > 0 ? total.cacheWriteTokens : nil, + totalTokens: total.totalTokens, + totalCostUSD: total.costSampleCount > 0 ? Double(total.costNanos) / self.costScale : nil)) + } + + static func mergedContributions( + existing: [String: [String: [String: PiPackedUsage]]], + delta: [String: [String: [String: PiPackedUsage]]]) -> [String: [String: [String: PiPackedUsage]]]? + { + var merged = existing + guard self.applyContributions(daysByProvider: &merged, contributions: delta) else { return nil } + return merged + } + + static func applyContributions( + daysByProvider: inout [String: [String: [String: PiPackedUsage]]], + contributions: [String: [String: [String: PiPackedUsage]]]) -> Bool + { + for (provider, days) in contributions { + for (day, models) in days { + for (model, packed) in models { + guard let sum = self.addPacked( + a: daysByProvider[provider]?[day]?[model] ?? PiPackedUsage(), b: packed) + else { return false } + daysByProvider[provider, default: [:]][day, default: [:]][model] = sum + } + } + } + return true + } + + static func addPacked(a: PiPackedUsage, b: PiPackedUsage) -> PiPackedUsage? { + guard let aSamples = self.validSampleCount(a), let bSamples = self.validSampleCount(b), + let input = CheckedSum.integers([a.inputTokens, b.inputTokens]), + let read = CheckedSum.integers([a.cacheReadTokens, b.cacheReadTokens]), + let write = CheckedSum.integers([a.cacheWriteTokens, b.cacheWriteTokens]), + let output = CheckedSum.integers([a.outputTokens, b.outputTokens]), + let tokens = CheckedSum.integers([a.totalTokens, b.totalTokens]), + let costs = CheckedSum.integers([a.costSampleCount, b.costSampleCount]), + let samples = CheckedSum.integers([aSamples, bSamples]) + else { return nil } + let nanos = a.costNanos.addingReportingOverflow(b.costNanos) + guard !nanos.overflow else { return nil } + return PiPackedUsage( + inputTokens: input, + cacheReadTokens: read, + cacheWriteTokens: write, + outputTokens: output, + totalTokens: tokens, + costNanos: nanos.partialValue, + costSampleCount: costs, + usageSampleCount: samples) + } + + private static func validSampleCount(_ usage: PiPackedUsage) -> Int? { + guard [ + usage.inputTokens, + usage.cacheReadTokens, + usage.cacheWriteTokens, + usage.outputTokens, + usage.totalTokens, + usage.costSampleCount, + ].allSatisfy({ $0 >= 0 }), + usage.costNanos >= 0, + let count = usage.usageSampleCount ?? (usage.isZero ? 0 : nil), + count >= usage.costSampleCount, + count > 0 || usage.isZero, + usage.costSampleCount > 0 || usage.costNanos == 0 + else { return nil } + return count + } + + private static func sortedModelBreakdowns(_ values: [CostUsageDailyReport.ModelBreakdown]) + -> [CostUsageDailyReport.ModelBreakdown] + { + values.sorted { + if $0.costUSD != $1.costUSD { return ($0.costUSD ?? -1) > ($1.costUSD ?? -1) } + if $0.totalTokens != $1.totalTokens { return ($0.totalTokens ?? -1) > ($1.totalTokens ?? -1) } + return $0.modelName > $1.modelName + } + } +} diff --git a/Sources/CodexBarCore/PiSessionCostScanner.swift b/Sources/CodexBarCore/PiSessionCostScanner.swift index a0d5c30daa..e69c2290d8 100644 --- a/Sources/CodexBarCore/PiSessionCostScanner.swift +++ b/Sources/CodexBarCore/PiSessionCostScanner.swift @@ -1,3 +1,4 @@ +import CoreFoundation import Foundation private final class PiSessionISO8601FormatterBox: @unchecked Sendable { @@ -15,6 +16,7 @@ private final class PiSessionISO8601FormatterBox: @unchecked Sendable { }() } +// swiftlint:disable:next type_body_length enum PiSessionCostScanner { @TaskLocal static var sessionParseObserverForTesting: (@Sendable () -> Void)? @@ -25,6 +27,10 @@ enum PiSessionCostScanner { var calendar: Calendar var refreshMinIntervalSeconds: TimeInterval = 60 var forceRescan: Bool = false + var environment: [String: String] + var workingDirectory: URL? + var workingDirectories: [URL] + var processContexts: [PiSessionProcessContext] init( piSessionsRoot: URL? = nil, @@ -32,7 +38,11 @@ enum PiSessionCostScanner { cacheRoot: URL? = nil, calendar: Calendar = .current, refreshMinIntervalSeconds: TimeInterval = 60, - forceRescan: Bool = false) + forceRescan: Bool = false, + environment: [String: String] = ProcessInfo.processInfo.environment, + workingDirectory: URL? = nil, + workingDirectories: [URL] = [], + processContexts: [PiSessionProcessContext] = []) { self.piSessionsRoot = piSessionsRoot self.ompSessionsRoot = ompSessionsRoot @@ -40,6 +50,10 @@ enum PiSessionCostScanner { self.calendar = calendar self.refreshMinIntervalSeconds = refreshMinIntervalSeconds self.forceRescan = forceRescan + self.environment = environment + self.workingDirectory = workingDirectory + self.workingDirectories = workingDirectories + self.processContexts = processContexts } } @@ -50,6 +64,9 @@ enum PiSessionCostScanner { let parsedBytes: Int64 let sessionID: String? let lastModelContext: PiModelContext? + let isComplete: Bool + let unsupportedAssistantDayKeys: Set + let hasUndatedUnsupportedAssistant: Bool } private struct SessionFileCandidate { @@ -57,6 +74,14 @@ enum PiSessionCostScanner { let rootIndex: Int } + private struct SessionRoot { + let url: URL + let missingIsKnownEmpty: Bool + let resolutionIsComplete: Bool + let preserveAfterProcessExit: Bool + let retentionKeys: Set + } + private struct AssistantIdentity { let provider: UsageProvider let modelName: String @@ -75,7 +100,7 @@ enum PiSessionCostScanner { let checkCancellation: CostUsageScanner.CancellationCheck? } - private static let costScale = 1_000_000_000.0 + static let costScale = 1_000_000_000.0 /// Bump for Pi-only cost formula changes not represented by the parser or pricing fingerprints. private static let costFormulaVersion = 2 private static let maxLineBytes = 16 * 1024 * 1024 @@ -100,6 +125,15 @@ enum PiSessionCostScanner { checkCancellation: nil)) ?? CostUsageDailyReport(data: [], summary: nil) } + struct DailyReportResult { + let report: CostUsageDailyReport + let isComplete: Bool + let lastScanAt: Date? + /// The scope represented by `report`, which may be the prior cache scope when a + /// newly requested root set could not be inspected completely. + let scopeFingerprint: String? + } + static func loadDailyReportCancellable( provider: UsageProvider, since: Date, @@ -107,10 +141,31 @@ enum PiSessionCostScanner { now: Date = Date(), options: Options = Options(), checkCancellation: CostUsageScanner.CancellationCheck?) throws -> CostUsageDailyReport + { + try self.loadDailyReportResultCancellable( + provider: provider, + since: since, + until: until, + now: now, + options: options, + checkCancellation: checkCancellation).report + } + + static func loadDailyReportResultCancellable( + provider: UsageProvider, + since: Date, + until: Date, + now: Date = Date(), + options: Options = Options(), + checkCancellation: CostUsageScanner.CancellationCheck?) throws -> DailyReportResult { // Provider-specific by design: Pi records only OpenAI Codex and Anthropic sessions with distinct pricing. - guard provider == .codex || provider == .claude else { - return CostUsageDailyReport(data: [], summary: nil) + guard provider == .codex || provider == .claude || provider == .pi else { + return DailyReportResult( + report: CostUsageDailyReport(data: [], summary: nil), + isComplete: true, + lastScanAt: nil, + scopeFingerprint: nil) } let range = CostUsageScanner.CostUsageDayRange( @@ -124,27 +179,43 @@ enum PiSessionCostScanner { let nowMs = Int64(now.timeIntervalSince1970 * 1000) let refreshMs = Int64(max(0, options.refreshMinIntervalSeconds) * 1000) let pricingContext = self.pricingContext(now: now, cacheRoot: options.cacheRoot) + let roots = self.defaultSessionRoots( + options: options, + previousSessionRootsFingerprint: cache.sessionRootsFingerprint) + let sessionRootsFingerprint = self.sessionRootsFingerprint(roots) let windowExpanded = self.requestedWindowExpandsCache(range: range, cache: cache) let pricingChanged = cache.pricingKey != pricingContext.pricingKey + let sessionRootsChanged = cache.sessionRootsFingerprint != sessionRootsFingerprint + let invalidCache = cache.files.values.contains { $0.fileIdentity == nil } + || self.checkedReports(cache: cache, range: range) == nil + let cacheBeforeScan = cache let shouldRefresh = options.forceRescan || windowExpanded || pricingChanged + || sessionRootsChanged + || invalidCache || refreshMs == 0 || cache.lastScanUnixMs == 0 || nowMs - cache.lastScanUnixMs > refreshMs + var scanIsComplete = roots.allSatisfy(\.resolutionIsComplete) if shouldRefresh { try checkCancellation?() - let roots = self.defaultSessionRoots(options: options) let startCutoff = self.dateFromDayKey(range.scanSinceKey, calendar: range.calendar) ?? since var files: [SessionFileCandidate] = [] + var seenFilePaths = Set() for (rootIndex, root) in roots.enumerated() { - for url in self.listPiSessionFiles( - root: root, + guard root.resolutionIsComplete else { continue } + let result = self.listPiSessionFiles( + root: root.url, startCutoffLocal: startCutoff, - calendar: range.calendar) - { - files.append(SessionFileCandidate(url: url, rootIndex: rootIndex)) + calendar: range.calendar, + missingIsKnownEmpty: root.missingIsKnownEmpty) + scanIsComplete = scanIsComplete && result.isComplete + for url in result.files { + let canonicalURL = self.canonicalSessionFileURL(url) + guard seenFilePaths.insert(canonicalURL.path).inserted else { continue } + files.append(SessionFileCandidate(url: canonicalURL, rootIndex: rootIndex)) } } files.sort { lhs, rhs in @@ -155,50 +226,100 @@ enum PiSessionCostScanner { let filePathsInScan = Set(files.map(\.url.path)) for file in files { - try self.scanPiSessionFile( + let fileIsComplete = try self.scanPiSessionFile( fileURL: file.url, cache: &cache, context: ScanContext( range: range, - forceRescan: options.forceRescan || windowExpanded || pricingChanged, + forceRescan: options.forceRescan || windowExpanded || pricingChanged || invalidCache, pricingContext: pricingContext, checkCancellation: checkCancellation)) + scanIsComplete = scanIsComplete && fileIsComplete } try checkCancellation?() - for key in cache.files.keys where !filePathsInScan.contains(key) { - if let old = cache.files[key] { - self.applyContributions( - daysByProvider: &cache.daysByProvider, - contributions: old.contributions, - sign: -1) + if scanIsComplete { + for key in cache.files.keys where !filePathsInScan.contains(key) { + cache.files.removeValue(forKey: key) } - cache.files.removeValue(forKey: key) } - try self.rebuildDailyUsage(cache: &cache, files: files, checkCancellation: checkCancellation) + if scanIsComplete { + if try !self.rebuildDailyUsage(cache: &cache, files: files, checkCancellation: checkCancellation) { + cache = cacheBeforeScan + scanIsComplete = false + } + } else if sessionRootsChanged || pricingChanged { + // Scope and pricing changes require one complete reparse. Keep the previous + // report intact rather than mixing roots or catalog versions after a failure. + cache = cacheBeforeScan + } else { + // Keep cached files from roots that could not be inspected. Rebuilding from only the + // visible roots would silently discard their usage and turn an I/O failure into zero. + let visiblePaths = Set(files.map(\.url.path)) + let preservedFiles = cache.files.keys + .filter { !visiblePaths.contains($0) } + .map { SessionFileCandidate(url: URL(fileURLWithPath: $0), rootIndex: Int.max) } + if try !self.rebuildDailyUsage( + cache: &cache, + files: files + preservedFiles, + checkCancellation: checkCancellation) + { + cache = cacheBeforeScan + } + } + } + + if !shouldRefresh { + scanIsComplete = self.cachedSourceFilesAreCurrent(cache: cache, roots: roots, calendar: options.calendar) + } + var reports = self.checkedReports(cache: cache, range: range) + if reports == nil { + cache = cacheBeforeScan + scanIsComplete = false + reports = self.checkedReports(cache: cache, range: range) + } + guard let reports else { + return DailyReportResult( + report: CostUsageDailyReport(data: [], summary: nil), + isComplete: false, + lastScanAt: nil, + scopeFingerprint: nil) + } + // Validate all model/day/range sums before committing the candidate or advancing its age. + if shouldRefresh, scanIsComplete { cache.scanSinceKey = range.scanSinceKey cache.scanUntilKey = range.scanUntilKey cache.pricingKey = pricingContext.pricingKey + cache.sessionRootsFingerprint = sessionRootsFingerprint cache.lastScanUnixMs = nowMs try checkCancellation?() - PiSessionCostCacheIO.save( - cache: cache, - cacheRoot: options.cacheRoot, - calendar: range.calendar) + PiSessionCostCacheIO.save(cache: cache, cacheRoot: options.cacheRoot, calendar: range.calendar) } - - return self.buildReport( - provider: provider, - cache: cache, - range: range, - pricingContext: pricingContext) + // Provider-specific by design: the Pi provider aggregates its Codex and Claude-priced local sessions. + let lastScanAt = cache.lastScanUnixMs > 0 + ? Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) + : nil + if provider == .pi { + return DailyReportResult( + report: CostUsageDailyReport.merged([reports.codex, reports.claude], calendar: range.calendar), + isComplete: scanIsComplete && !self.hasUnsupportedHistory(cache: cache, range: range), + lastScanAt: lastScanAt, + scopeFingerprint: cache.sessionRootsFingerprint) + } + return DailyReportResult( + report: provider == .codex ? reports.codex : reports.claude, + isComplete: scanIsComplete, + lastScanAt: lastScanAt, + scopeFingerprint: cache.sessionRootsFingerprint) } struct CachedDailyReportResult { let report: CostUsageDailyReport let lastScanAt: Date? + let scopeFingerprint: String? + let isComplete: Bool } static func loadCachedDailyReport( @@ -225,29 +346,61 @@ enum PiSessionCostScanner { now: Date = Date(), cacheRoot: URL? = nil, calendar: Calendar = .current, + options: Options? = nil, allowEstablishedEmpty: Bool = false) -> CachedDailyReportResult? { - guard provider == .codex || provider == .claude else { return nil } + // Provider-specific by design: cached Pi history is the merged Codex/Claude report above. + guard provider == .codex || provider == .claude || provider == .pi else { return nil } let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar) let cache = PiSessionCostCacheIO.load(cacheRoot: cacheRoot) guard cache.timeZoneIdentifier == range.calendar.timeZone.identifier else { return nil } + guard cache.files.values.allSatisfy({ $0.fileIdentity != nil }) else { return nil } + var sourcesAreComplete = false + if let options { + let expectedRoots = self.defaultSessionRoots( + options: options, + previousSessionRootsFingerprint: cache.sessionRootsFingerprint) + guard self.sessionRootsFingerprint(expectedRoots) == cache.sessionRootsFingerprint else { return nil } + sourcesAreComplete = self.cachedSourceFilesAreCurrent( + cache: cache, + roots: expectedRoots, + calendar: range.calendar) + } guard !allowEstablishedEmpty || cache.lastScanUnixMs > 0 else { return nil } guard allowEstablishedEmpty || !cache.daysByProvider.isEmpty else { return nil } guard !self.requestedWindowExpandsCache(range: range, cache: cache) else { return nil } let pricingContext = self.pricingContext(now: now, cacheRoot: cacheRoot) guard cache.pricingKey == pricingContext.pricingKey else { return nil } - let report = self.buildReport( - provider: provider, - cache: cache, - range: range, - pricingContext: pricingContext) + guard let reports = self.checkedReports(cache: cache, range: range) else { return nil } + guard provider != .pi || !self.hasUnsupportedHistory(cache: cache, range: range) else { return nil } + // Provider-specific by design: the Pi cache's aggregate view merges its fixed Codex and Claude tariffs. + let report = if provider == .pi { + CostUsageDailyReport.merged([reports.codex, reports.claude], calendar: range.calendar) + } else { + provider == .codex ? reports.codex : reports.claude + } guard allowEstablishedEmpty || !report.data.isEmpty else { return nil } let lastScanAt = cache.lastScanUnixMs > 0 ? Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) : nil - return CachedDailyReportResult(report: report, lastScanAt: lastScanAt) + return CachedDailyReportResult( + report: report, + lastScanAt: lastScanAt, + scopeFingerprint: cache.sessionRootsFingerprint, + isComplete: sourcesAreComplete) + } + + private static func hasUnsupportedHistory( + cache: PiSessionCostCache, + range: CostUsageScanner.CostUsageDayRange) -> Bool + { + cache.files.values.contains { file in + file.hasUndatedUnsupportedAssistant || file.unsupportedAssistantDayKeys.contains { + CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) + } + } } private static func pricingContext(now: Date, cacheRoot: URL?) -> ModelsDevPricingContext { @@ -284,44 +437,277 @@ enum PiSessionCostScanner { return false } - private static func defaultSessionRoots(options: Options) -> [URL] { + private static func defaultSessionRoots( + options: Options, + previousSessionRootsFingerprint: String?) -> [SessionRoot] + { if options.piSessionsRoot != nil || options.ompSessionsRoot != nil { - return [options.piSessionsRoot, options.ompSessionsRoot].compactMap(\.self) + return [options.piSessionsRoot, options.ompSessionsRoot] + .compactMap(\.self) + .map { + SessionRoot( + url: $0, + missingIsKnownEmpty: false, + resolutionIsComplete: true, + preserveAfterProcessExit: false, + retentionKeys: []) + } + } + + let resolved = PiFamilySessionScanner.costSessionRoots( + environment: options.environment, + baseDirectories: options.workingDirectories.isEmpty + ? options.workingDirectory.map { [$0] } + : options.workingDirectories, + processContexts: options.processContexts) + if !resolved.isEmpty { + let resolvedRoots = resolved.map { root in + SessionRoot( + url: root.url, + missingIsKnownEmpty: root.missingIsKnownEmpty, + resolutionIsComplete: root.resolutionIsComplete, + preserveAfterProcessExit: root.preserveAfterProcessExit, + retentionKeys: root.retentionKeys) + } + return self.appendingPreviousSessionRoots( + resolvedRoots, + fingerprint: previousSessionRootsFingerprint) } let home = FileManager.default.homeDirectoryForCurrentUser - return [".pi", ".omp"].map { directory in - home - .appendingPathComponent(directory, isDirectory: true) - .appendingPathComponent("agent", isDirectory: true) - .appendingPathComponent("sessions", isDirectory: true) + // Provider-specific by design: Pi-family stores use the fixed .pi and .omp home directories. + let fallbackRoots = [".pi", ".omp"].map { directory in + SessionRoot( + url: home + .appendingPathComponent(directory, isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true), + missingIsKnownEmpty: true, + resolutionIsComplete: true, + preserveAfterProcessExit: false, + retentionKeys: []) + } + return self.appendingPreviousSessionRoots( + fallbackRoots, + fingerprint: previousSessionRootsFingerprint) + } + + private static func appendingPreviousSessionRoots( + _ roots: [SessionRoot], + fingerprint: String?) -> [SessionRoot] + { + var output: [SessionRoot] = [] + var indices: [String: Int] = [:] + func appendRoot(_ root: SessionRoot) { + guard let index = indices[root.url.path] else { + indices[root.url.path] = output.count + output.append(root) + return + } + let current = output[index] + output[index] = SessionRoot( + url: root.url, + missingIsKnownEmpty: current.missingIsKnownEmpty && root.missingIsKnownEmpty, + resolutionIsComplete: current.resolutionIsComplete && root.resolutionIsComplete, + preserveAfterProcessExit: current.preserveAfterProcessExit || root.preserveAfterProcessExit, + retentionKeys: current.retentionKeys.union(root.retentionKeys)) + } + roots.forEach(appendRoot) + guard let fingerprint, !fingerprint.isEmpty else { return output } + let currentRetentionKeys = Set(roots.flatMap(\.retentionKeys)) + let currentSettingsRetentionKeys = Set(currentRetentionKeys.filter { $0.hasPrefix("settings:") }) + for component in fingerprint.split(separator: "\u{1E}", omittingEmptySubsequences: true) { + let fields = component.split(separator: "\u{1F}", omittingEmptySubsequences: false) + guard fields.count >= 4, fields[3] == "live" else { continue } + let path = String(fields[0]) + guard !path.isEmpty, !path.hasPrefix("/.codexbar-unresolved-") else { continue } + let retentionKey = fields.count >= 5 && !fields[4].isEmpty ? String(fields[4]) : nil + // A settings file is a replacement point: if it now resolves to another root, the + // prior root belonged to the superseded selector and must not be carried forward. + if let retentionKey, currentRetentionKeys.contains(retentionKey) { continue } + if let retentionKey, retentionKey.hasPrefix("settings:") { + switch PiFamilySessionScanner.retainedSettingsRootResolution(retentionKey: retentionKey) { + case .removed: + // The settings selector was removed, so its retained root is obsolete. + continue + case let .resolved(url, resolvedRetentionKey): + let resolvedURL = url.standardizedFileURL + appendRoot(SessionRoot( + url: resolvedURL, + missingIsKnownEmpty: fields[1] == "known-empty", + resolutionIsComplete: true, + preserveAfterProcessExit: true, + retentionKeys: [resolvedRetentionKey])) + continue + case .unavailable: + // Preserve the cached root while marking the scope incomplete. This avoids + // silently dropping history when the settings file cannot be revalidated. + let url = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + appendRoot(SessionRoot( + url: url, + missingIsKnownEmpty: fields[1] == "known-empty", + resolutionIsComplete: false, + preserveAfterProcessExit: true, + retentionKeys: [retentionKey])) + continue + } + } + // Legacy fingerprints did not record provenance. If the current scope has a settings + // selector, prefer its current value over an ambiguous retained settings root. + if retentionKey == nil, !currentSettingsRetentionKeys.isEmpty { continue } + let url = URL(fileURLWithPath: path, isDirectory: true).standardizedFileURL + appendRoot(SessionRoot( + url: url, + missingIsKnownEmpty: fields[1] == "known-empty", + resolutionIsComplete: fields[2] == "resolved", + preserveAfterProcessExit: true, + retentionKeys: retentionKey.map { [$0] } ?? [])) + } + return output + } + + private static func sessionRootsFingerprint(_ roots: [SessionRoot]) -> String { + roots + .flatMap { root in + (root.retentionKeys.isEmpty ? [""] : root.retentionKeys.sorted()).map { retentionKey in + [ + root.url.path, + root.missingIsKnownEmpty ? "known-empty" : "required", + root.resolutionIsComplete ? "resolved" : "unresolved", + root.preserveAfterProcessExit ? "live" : "configured", + retentionKey, + ].joined(separator: "\u{1F}") + } + } + .joined(separator: "\u{1E}") + } + + /// Returns the root scope represented by a Pi scanner configuration. Cached + /// reads use this to reject a report produced for a different live or + /// configured project root before publishing it. + package static func scopeFingerprint(options: Options) -> String { + let cache = PiSessionCostCacheIO.load(cacheRoot: options.cacheRoot) + return self.scopeFingerprint( + options: options, + cache: cache) + } + + private struct SessionFileListResult { + let files: [URL] + let isComplete: Bool + } + + private struct SessionFileMetadata: Equatable { + let fileIdentity: String + let mtimeUnixMs: Int64 + let size: Int64 + + func matches(_ cached: PiSessionFileUsage) -> Bool { + cached.fileIdentity == self.fileIdentity && + cached.mtimeUnixMs == self.mtimeUnixMs && + cached.size == self.size && + cached.parsedBytes == cached.size } } + private static func sessionFileMetadata(at url: URL) -> SessionFileMetadata? { + let path = url.path + guard FileManager.default.isReadableFile(atPath: path), + let attrs = try? FileManager.default.attributesOfItem(atPath: path), + attrs[.type] as? FileAttributeType == .typeRegular, + let device = attrs[.systemNumber] as? NSNumber, + let inode = attrs[.systemFileNumber] as? NSNumber, + let modifiedAt = attrs[.modificationDate] as? Date, + let size = (attrs[.size] as? NSNumber)?.int64Value, + size >= 0 + else { return nil } + return SessionFileMetadata( + fileIdentity: "\(device):\(inode)", + mtimeUnixMs: Int64(modifiedAt.timeIntervalSince1970 * 1000), + size: size) + } + + private static func cachedSourceFilesAreCurrent( + cache: PiSessionCostCache, + roots: [SessionRoot], + calendar: Calendar) -> Bool + { + guard !roots.isEmpty, + roots.allSatisfy(\.resolutionIsComplete), + cache.lastScanUnixMs > 0, + let sinceKey = cache.scanSinceKey, + let cutoff = self.dateFromDayKey(sinceKey, calendar: calendar) + else { return false } + + // Compare the persisted scan window, even when the caller displays a narrower period. + var paths = Set() + for root in roots { + let inventory = self.listPiSessionFiles( + root: root.url, + startCutoffLocal: cutoff, + calendar: calendar, + missingIsKnownEmpty: root.missingIsKnownEmpty) + guard inventory.isComplete else { return false } + for candidate in inventory.files { + let url = self.canonicalSessionFileURL(candidate) + guard paths.insert(url.path).inserted else { continue } + guard let cached = cache.files[url.path], + let metadata = self.sessionFileMetadata(at: url), + metadata.matches(cached) + else { return false } + } + } + return paths == Set(cache.files.keys) + } + private static func listPiSessionFiles( root: URL, startCutoffLocal: Date, - calendar: Calendar) -> [URL] + calendar: Calendar, + missingIsKnownEmpty: Bool) -> SessionFileListResult { - guard FileManager.default.fileExists(atPath: root.path) else { return [] } + guard FileManager.default.fileExists(atPath: root.path) else { + // A missing default store is a known empty store, but a missing configured root may + // indicate an unmounted or unavailable location and must keep the scan incomplete. + return SessionFileListResult(files: [], isComplete: missingIsKnownEmpty) + } + + let rootValues = try? root.resourceValues(forKeys: [.isDirectoryKey]) + guard rootValues?.isDirectory == true else { + return SessionFileListResult(files: [], isComplete: false) + } + guard FileManager.default.isReadableFile(atPath: root.path) else { + return SessionFileListResult(files: [], isComplete: false) + } let keys: Set = [.isRegularFileKey, .contentModificationDateKey] + var isComplete = true guard let enumerator = FileManager.default.enumerator( at: root, includingPropertiesForKeys: Array(keys), - options: [.skipsHiddenFiles]) + options: [.skipsHiddenFiles], + errorHandler: { _, _ in + isComplete = false + return true + }) else { - return [] + return SessionFileListResult(files: [], isComplete: false) } var output: [URL] = [] while let item = enumerator.nextObject() as? URL { guard item.pathExtension.lowercased() == "jsonl" else { continue } - let values = try? item.resourceValues(forKeys: keys) - guard values?.isRegularFile == true else { continue } + let values: URLResourceValues + do { + values = try item.resourceValues(forKeys: keys) + } catch { + isComplete = false + continue + } + guard values.isRegularFile == true else { continue } let startedAt = self.parseSessionStartFromFilename(item.lastPathComponent) - let modifiedAt = values?.contentModificationDate + let modifiedAt = values.contentModificationDate if self .shouldIncludeFile( startedAt: startedAt, @@ -333,7 +719,9 @@ enum PiSessionCostScanner { } } - return output.sorted(by: { $0.path < $1.path }) + return SessionFileListResult( + files: output.sorted(by: { $0.path < $1.path }), + isComplete: isComplete) } private static func shouldIncludeFile( @@ -355,14 +743,14 @@ enum PiSessionCostScanner { fileURL: URL, cache: inout PiSessionCostCache, context: ScanContext) - throws + throws -> Bool { try context.checkCancellation?() let path = fileURL.path - let attrs = (try? FileManager.default.attributesOfItem(atPath: path)) ?? [:] - let mtime = (attrs[.modificationDate] as? Date)?.timeIntervalSince1970 ?? 0 - let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 - let mtimeMs = Int64(mtime * 1000) + guard let metadata = self.sessionFileMetadata(at: fileURL) else { return false } + let fileIdentity = metadata.fileIdentity + let mtimeMs = metadata.mtimeUnixMs + let size = metadata.size func storeFileUsage(_ usage: PiSessionFileUsage) { cache.files[path] = usage @@ -371,14 +759,15 @@ enum PiSessionCostScanner { let cached = cache.files[path] if !context.forceRescan, let cached, - cached.mtimeUnixMs == mtimeMs, - cached.size == size + metadata.matches(cached) { - return + return true } if !context.forceRescan, let cached, + cached.fileIdentity == fileIdentity, + !cached.requiresFullReparseOnChange, size > cached.size, cached.parsedBytes > 0, cached.parsedBytes <= size @@ -391,34 +780,28 @@ enum PiSessionCostScanner { initialModelContext: cached.lastModelContext, pricingContext: context.pricingContext, checkCancellation: context.checkCancellation) - if !delta.contributions.isEmpty { - self.applyContributions( - daysByProvider: &cache.daysByProvider, - contributions: delta.contributions, - sign: 1) - } - let merged = self.mergedContributions(existing: cached.contributions, delta: delta.contributions) - let mergedUnkeyed = self.mergedContributions( - existing: cached.unkeyedContributions, - delta: delta.unkeyedContributions) + guard delta.isComplete, self.sessionFileMetadata(at: fileURL) == metadata else { return false } + guard let merged = self.mergedContributions(existing: cached.contributions, delta: delta.contributions), + let mergedUnkeyed = self.mergedContributions( + existing: cached.unkeyedContributions, + delta: delta.unkeyedContributions) + else { return false } let mergedEntryUsages = cached.entryUsages.merging(delta.entryUsages) { _, appended in appended } storeFileUsage(PiSessionFileUsage( mtimeUnixMs: mtimeMs, size: size, parsedBytes: delta.parsedBytes, + fileIdentity: fileIdentity, sessionID: delta.sessionID ?? cached.sessionID, lastModelContext: delta.lastModelContext, contributions: merged, unkeyedContributions: mergedUnkeyed, - entryUsages: mergedEntryUsages)) - return - } - - if let cached { - self.applyContributions( - daysByProvider: &cache.daysByProvider, - contributions: cached.contributions, - sign: -1) + entryUsages: mergedEntryUsages, + unsupportedAssistantDayKeys: cached.unsupportedAssistantDayKeys + .union(delta.unsupportedAssistantDayKeys), + hasUndatedUnsupportedAssistant: cached.hasUndatedUnsupportedAssistant || + delta.hasUndatedUnsupportedAssistant)) + return true } let parsed = try self.parsePiSessionFile( @@ -426,19 +809,21 @@ enum PiSessionCostScanner { range: context.range, pricingContext: context.pricingContext, checkCancellation: context.checkCancellation) - if !parsed.contributions.isEmpty { - self.applyContributions(daysByProvider: &cache.daysByProvider, contributions: parsed.contributions, sign: 1) - } + guard parsed.isComplete, self.sessionFileMetadata(at: fileURL) == metadata else { return false } storeFileUsage(PiSessionFileUsage( mtimeUnixMs: mtimeMs, size: size, parsedBytes: parsed.parsedBytes, + fileIdentity: fileIdentity, sessionID: parsed.sessionID, lastModelContext: parsed.lastModelContext, contributions: parsed.contributions, unkeyedContributions: parsed.unkeyedContributions, - entryUsages: parsed.entryUsages)) + entryUsages: parsed.entryUsages, + unsupportedAssistantDayKeys: parsed.unsupportedAssistantDayKeys, + hasUndatedUnsupportedAssistant: parsed.hasUndatedUnsupportedAssistant)) + return true } private static func parsePiSessionFile( @@ -456,6 +841,9 @@ enum PiSessionCostScanner { var contributions: [String: [String: [String: PiPackedUsage]]] = [:] var unkeyedContributions: [String: [String: [String: PiPackedUsage]]] = [:] var entryUsages: [String: PiSessionEntryUsage] = [:] + var unsupportedAssistantDayKeys: Set = [] + var hasUndatedUnsupportedAssistant = false + var isComplete = true func add( provider: UsageProvider, @@ -465,32 +853,11 @@ enum PiSessionCostScanner { entryID: String?) { guard !usage.isZero else { return } - guard CostUsageScanner.CostUsageDayRange.isInRange( - dayKey: dayKey, - since: range.scanSinceKey, - until: range.scanUntilKey) - else { - return - } - let providerKey = provider.rawValue - var providerDays = contributions[providerKey] ?? [:] - var dayModels = providerDays[dayKey] ?? [:] - let merged = self.addPacked(a: dayModels[modelName] ?? PiPackedUsage(), b: usage, sign: 1) - if merged.isZero { - dayModels.removeValue(forKey: modelName) - } else { - dayModels[modelName] = merged - } - if dayModels.isEmpty { - providerDays.removeValue(forKey: dayKey) - } else { - providerDays[dayKey] = dayModels - } - if providerDays.isEmpty { - contributions.removeValue(forKey: providerKey) - } else { - contributions[providerKey] = providerDays + let delta = [providerKey: [dayKey: [modelName: usage]]] + guard self.applyContributions(daysByProvider: &contributions, contributions: delta) else { + isComplete = false + return } if let entryID { @@ -500,14 +867,11 @@ enum PiSessionCostScanner { modelName: modelName, usage: usage) } else { - var providerDays = unkeyedContributions[providerKey] ?? [:] - var dayModels = providerDays[dayKey] ?? [:] - dayModels[modelName] = self.addPacked( - a: dayModels[modelName] ?? PiPackedUsage(), - b: usage, - sign: 1) - providerDays[dayKey] = dayModels - unkeyedContributions[providerKey] = providerDays + guard self.applyContributions(daysByProvider: &unkeyedContributions, contributions: delta) + else { + isComplete = false + return + } } } @@ -520,10 +884,22 @@ enum PiSessionCostScanner { prefixBytes: Self.maxLineBytes, checkCancellation: checkCancellation, onLine: { line in - guard !line.bytes.isEmpty, !line.wasTruncated else { return } + guard !line.bytes.isEmpty else { return } + if line.wasTruncated { + // Dropping an oversized record must not advance the cache past usage we + // could not parse; retain the previous file snapshot and retry later. + isComplete = false + return + } autoreleasepool { - guard let object = (try? JSONSerialization.jsonObject(with: line.bytes)) as? [String: Any] - else { return } + guard let objectValue = try? JSONSerialization.jsonObject(with: line.bytes), + let object = objectValue as? [String: Any] + else { + // A terminated but malformed/non-object record means the file was not + // fully interpreted; keep the prior cache snapshot and retry later. + isComplete = false + return + } guard let type = object["type"] as? String else { return } if type == "session" { @@ -543,17 +919,56 @@ enum PiSessionCostScanner { entry: object, message: message, fallback: currentModelContext) - guard let identity else { return } - guard let date = self.timestampDate(entry: object, message: message) else { return } + guard let identity else { + let unsupported = if let explicit = self.extractProviderText( + entry: object, + message: message) + { + self.mappedProvider(fromPiProvider: explicit) == nil + } else { + currentModelContext?.isUnsupportedBackend == true + } + guard unsupported else { + isComplete = false + return + } + if let date = self.timestampDate(entry: object, message: message) { + let day = CostUsageScanner.CostUsageDayRange.dayKey( + from: date, + calendar: range.calendar) + if CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: day, since: range.scanSinceKey, until: range.scanUntilKey) + { + unsupportedAssistantDayKeys.insert(day) + } + } else { + hasUndatedUnsupportedAssistant = true + } + return + } + guard let date = self.timestampDate(entry: object, message: message) else { + // A recognized assistant row without a usable timestamp cannot be + // assigned to a day. Keep the scan incomplete so cache advancement + // never permanently hides its usage. + isComplete = false + return + } let dayKey = CostUsageScanner.CostUsageDayRange.dayKey( from: date, calendar: range.calendar) - let usage = self.extractUsage( + guard CostUsageScanner.CostUsageDayRange.isInRange( + dayKey: dayKey, since: range.scanSinceKey, until: range.scanUntilKey) + else { return } + guard let usage = self.extractUsage( provider: identity.provider, modelName: identity.modelName, message: message, pricingDate: date, pricingContext: pricingContext) + else { + isComplete = false + return + } add( provider: identity.provider, dayKey: dayKey, @@ -562,10 +977,20 @@ enum PiSessionCostScanner { entryID: self.entryIdentifier(from: object)) } }) + // A scan can stop at the last complete newline while an active writer leaves a + // partial JSON object at EOF. The committed offset then trails the file size, so + // keep the cache incomplete and retry the tail on a later refresh. + let observedFileSize = (try? FileManager.default + .attributesOfItem(atPath: fileURL.path)[.size] as? NSNumber)? + .int64Value + if observedFileSize != parsedBytes { + isComplete = false + } } catch is CancellationError { throw CancellationError() } catch { parsedBytes = startOffset + isComplete = false } return ParseResult( @@ -574,7 +999,10 @@ enum PiSessionCostScanner { entryUsages: entryUsages, parsedBytes: parsedBytes, sessionID: sessionID, - lastModelContext: currentModelContext) + lastModelContext: currentModelContext, + isComplete: isComplete, + unsupportedAssistantDayKeys: unsupportedAssistantDayKeys, + hasUndatedUnsupportedAssistant: hasUndatedUnsupportedAssistant) } private static func sessionIdentifier(from object: [String: Any]) -> String? { @@ -595,53 +1023,52 @@ enum PiSessionCostScanner { private static func rebuildDailyUsage( cache: inout PiSessionCostCache, files: [SessionFileCandidate], - checkCancellation: CostUsageScanner.CancellationCheck?) throws + checkCancellation: CostUsageScanner.CancellationCheck?) throws -> Bool { var seenEntriesBySessionID: [String: Set] = [:] - cache.daysByProvider = [:] + var days: [String: [String: [String: PiPackedUsage]]] = [:] for file in files { try checkCancellation?() guard let usage = cache.files[file.url.path] else { continue } guard let sessionID = usage.sessionID else { - self.applyContributions( - daysByProvider: &cache.daysByProvider, - contributions: usage.contributions, - sign: 1) + guard self.applyContributions(daysByProvider: &days, contributions: usage.contributions) else { + return false + } continue } - self.applyContributions( - daysByProvider: &cache.daysByProvider, - contributions: usage.unkeyedContributions, - sign: 1) + guard self.applyContributions(daysByProvider: &days, contributions: usage.unkeyedContributions) else { + return false + } var seenEntries = seenEntriesBySessionID[sessionID] ?? [] for entryID in usage.entryUsages.keys.sorted() where seenEntries.insert(entryID).inserted { guard let entryUsage = usage.entryUsages[entryID] else { continue } - self.applyEntryUsage(daysByProvider: &cache.daysByProvider, entryUsage: entryUsage) + guard self.applyEntryUsage(daysByProvider: &days, entryUsage: entryUsage) else { return false } } seenEntriesBySessionID[sessionID] = seenEntries } + cache.daysByProvider = days + return true } private static func applyEntryUsage( daysByProvider: inout [String: [String: [String: PiPackedUsage]]], - entryUsage: PiSessionEntryUsage) + entryUsage: PiSessionEntryUsage) -> Bool { let contributions = [ entryUsage.providerRawValue: [ entryUsage.dayKey: [entryUsage.modelName: entryUsage.usage], ], ] - self.applyContributions(daysByProvider: &daysByProvider, contributions: contributions, sign: 1) + return self.applyContributions(daysByProvider: &daysByProvider, contributions: contributions) } private static func modelContext(from object: [String: Any]) -> PiModelContext? { - guard let providerText = object["provider"] as? String, - let provider = self.mappedProvider(fromPiProvider: providerText) - else { - return nil + guard let providerText = object["provider"] as? String else { return nil } + guard let provider = self.mappedProvider(fromPiProvider: providerText) else { + return PiModelContext(providerRawValue: "", modelName: "", isUnsupportedBackend: true) } let rawModelName = (object["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let modelName = self.normalizeModelName(rawModelName, provider: provider) else { return nil } @@ -737,6 +1164,8 @@ enum PiSessionCostScanner { private static func parseTimestampValue(_ value: Any?) -> Date? { if let number = value as? NSNumber { + // JSON booleans bridge to NSNumber on Darwin; they are not timestamps. + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } let raw = number.doubleValue guard raw.isFinite else { return nil } if raw > 1_000_000_000_000 { @@ -763,23 +1192,28 @@ enum PiSessionCostScanner { modelName: String, message: [String: Any], pricingDate: Date? = nil, - pricingContext: ModelsDevPricingContext? = nil) -> PiPackedUsage + pricingContext: ModelsDevPricingContext? = nil) -> PiPackedUsage? { - let usage = (message["usage"] as? [String: Any]) ?? [:] - let input = self.readNonNegativeInt( + guard let usage = message["usage"] as? [String: Any] else { return nil } + var hasCounter = false + func read(_ value: Any?) -> Int? { + if value != nil { hasCounter = true } + return Self.readNonNegativeInt(value) + } + let input = read( usage["input"] ?? usage["inputTokens"] ?? usage["input_tokens"] ?? usage["promptTokens"] ?? usage["prompt_tokens"]) - let cacheRead = self.readNonNegativeInt( + let cacheRead = read( usage["cacheRead"] ?? usage["cacheReadTokens"] ?? usage["cache_read"] ?? usage["cache_read_tokens"] ?? usage["cacheReadInputTokens"] ?? usage["cache_read_input_tokens"]) - let cacheWrite = self.readNonNegativeInt( + let cacheWrite = read( usage["cacheWrite"] ?? usage["cacheWriteTokens"] ?? usage["cache_write"] @@ -788,20 +1222,22 @@ enum PiSessionCostScanner { ?? usage["cache_creation_tokens"] ?? usage["cacheCreationInputTokens"] ?? usage["cache_creation_input_tokens"]) - let output = self.readNonNegativeInt( + let output = read( usage["output"] ?? usage["outputTokens"] ?? usage["output_tokens"] ?? usage["completionTokens"] ?? usage["completion_tokens"]) - let directTotal = self.readNonNegativeInt( + let directTotal = read( usage["totalTokens"] ?? usage["total_tokens"] ?? usage["tokenCount"] ?? usage["token_count"] ?? usage["tokens"]) - let derivedTotal = input + cacheRead + cacheWrite + output + guard hasCounter, let input, let cacheRead, let cacheWrite, let output, let directTotal, + let derivedTotal = CheckedSum.integers([input, cacheRead, cacheWrite, output]) + else { return nil } let totalTokens = max(directTotal, derivedTotal) let rawUsage = PiPackedUsage( @@ -811,13 +1247,16 @@ enum PiSessionCostScanner { outputTokens: output, totalTokens: totalTokens) // Pi-compatible JSONL does not record Anthropic cache retention, so use Pi's persisted default tariff. - let costUSD = self.computedCostUSD( + let costUSD = totalTokens == derivedTotal ? self.computedCostUSD( provider: provider, modelName: modelName, usage: rawUsage, pricingDate: pricingDate, - pricingContext: pricingContext) - let costNanos = costUSD.map { Int64(($0 * self.costScale).rounded()) } ?? 0 + pricingContext: pricingContext) : nil + let costNanos = costUSD.flatMap { value -> Int64? in + guard value.isFinite, value >= 0 else { return nil } + return Int64(exactly: (value * self.costScale).rounded()) + } return PiPackedUsage( inputTokens: rawUsage.inputTokens, @@ -825,8 +1264,8 @@ enum PiSessionCostScanner { cacheWriteTokens: rawUsage.cacheWriteTokens, outputTokens: rawUsage.outputTokens, totalTokens: rawUsage.totalTokens, - costNanos: costNanos, - costSampleCount: costUSD == nil ? 0 : 1, + costNanos: costNanos ?? 0, + costSampleCount: costNanos == nil ? 0 : 1, usageSampleCount: 1) } @@ -837,6 +1276,7 @@ enum PiSessionCostScanner { pricingDate: Date? = nil, pricingContext: ModelsDevPricingContext? = nil) -> Double? { + // Provider-specific by design: Pi pricing delegates to the Codex and Claude tariff calculators. switch provider { case .codex: // Pi records input, cache reads, and cache writes as disjoint counts. Codex pricing @@ -851,6 +1291,7 @@ enum PiSessionCostScanner { pricingDate: pricingDate, modelsDevCatalog: pricingContext?.catalog, modelsDevCacheRoot: pricingContext?.cacheRoot) + // Provider-specific by design: Claude uses its own first-party input/cache/output tariff. case .claude: CostUsagePricing.claudeCostUSD( model: modelName, @@ -866,15 +1307,26 @@ enum PiSessionCostScanner { } } - private static func readNonNegativeInt(_ value: Any?) -> Int { - let numeric = (value as? NSNumber)?.doubleValue ?? (value as? String).flatMap { Double($0) } - guard let numeric, numeric >= 0 else { return 0 } - return Int(exactly: numeric.rounded()) ?? 0 + private static func readNonNegativeInt(_ value: Any?) -> Int? { + guard let value else { return 0 } + let text: String + if let number = value as? NSNumber { + guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } + text = number.stringValue + } else if let string = value as? String { + text = string + } else { + return nil + } + if let integer = Int(text) { return integer >= 0 ? integer : nil } + guard let numeric = Double(text), numeric.isFinite, numeric >= 0 else { return nil } + return Int(exactly: numeric.rounded()) } } extension PiSessionCostScanner { private static func mappedProvider(fromPiProvider provider: String) -> UsageProvider? { + // Provider-specific by design: Pi currently records the Codex and Anthropic integrations it can price. switch provider.lowercased() { case "openai-codex": .codex @@ -885,170 +1337,6 @@ extension PiSessionCostScanner { } } - private static func buildReport( - provider: UsageProvider, - cache: PiSessionCostCache, - range: CostUsageScanner.CostUsageDayRange, - pricingContext: ModelsDevPricingContext? = nil) -> CostUsageDailyReport - { - guard let providerDays = cache.daysByProvider[provider.rawValue] else { - return CostUsageDailyReport(data: [], summary: nil) - } - - let dayKeys = providerDays.keys.sorted().filter { - CostUsageScanner.CostUsageDayRange.isInRange(dayKey: $0, since: range.sinceKey, until: range.untilKey) - } - - var entries: [CostUsageDailyReport.Entry] = [] - var totalInput = 0 - var totalOutput = 0 - var totalCacheRead = 0 - var totalCacheWrite = 0 - var totalTokens = 0 - var totalCostNanos: Int64 = 0 - var totalCostSamples = 0 - - for dayKey in dayKeys { - guard let models = providerDays[dayKey] else { continue } - let modelNames = models.keys.sorted() - - var dayInput = 0 - var dayOutput = 0 - var dayCacheRead = 0 - var dayCacheWrite = 0 - var dayTotalTokens = 0 - var dayCostNanos: Int64 = 0 - var dayCostSamples = 0 - var breakdown: [CostUsageDailyReport.ModelBreakdown] = [] - - for modelName in modelNames { - let packed = models[modelName] ?? PiPackedUsage() - let modelTotalTokens = max( - packed.totalTokens, - packed.inputTokens + packed.cacheReadTokens + packed.cacheWriteTokens + packed.outputTokens) - let currentPricingCost = self.computedCostUSD( - provider: provider, - modelName: modelName, - usage: packed, - pricingContext: pricingContext) - let usageSampleCount = packed.usageSampleCount - let hasCompleteCachedCost = (usageSampleCount ?? 0) > 0 - && packed.costSampleCount == usageSampleCount - // Cached costs are accumulated per message, which preserves Claude long-context threshold boundaries. - let costNanos = hasCompleteCachedCost - ? packed.costNanos - : currentPricingCost.map { Int64(($0 * self.costScale).rounded()) } - breakdown.append(CostUsageDailyReport.ModelBreakdown( - modelName: modelName, - costUSD: costNanos.map { Double($0) / Self.costScale }, - totalTokens: modelTotalTokens > 0 ? modelTotalTokens : nil)) - dayInput += packed.inputTokens - dayOutput += packed.outputTokens - dayCacheRead += packed.cacheReadTokens - dayCacheWrite += packed.cacheWriteTokens - dayTotalTokens += modelTotalTokens - if let costNanos { - dayCostNanos += costNanos - dayCostSamples += 1 - } - } - - let sortedBreakdown = self.sortedModelBreakdowns(breakdown) - entries.append(CostUsageDailyReport.Entry( - date: dayKey, - inputTokens: dayInput > 0 ? dayInput : nil, - outputTokens: dayOutput > 0 ? dayOutput : nil, - cacheReadTokens: dayCacheRead > 0 ? dayCacheRead : nil, - cacheCreationTokens: dayCacheWrite > 0 ? dayCacheWrite : nil, - totalTokens: dayTotalTokens > 0 ? dayTotalTokens : nil, - costUSD: dayCostSamples > 0 ? Double(dayCostNanos) / Self.costScale : nil, - modelsUsed: modelNames, - modelBreakdowns: sortedBreakdown)) - - totalInput += dayInput - totalOutput += dayOutput - totalCacheRead += dayCacheRead - totalCacheWrite += dayCacheWrite - totalTokens += dayTotalTokens - totalCostNanos += dayCostNanos - totalCostSamples += dayCostSamples - } - - guard !entries.isEmpty else { return CostUsageDailyReport(data: [], summary: nil) } - return CostUsageDailyReport( - data: entries, - summary: CostUsageDailyReport.Summary( - totalInputTokens: totalInput > 0 ? totalInput : nil, - totalOutputTokens: totalOutput > 0 ? totalOutput : nil, - cacheReadTokens: totalCacheRead > 0 ? totalCacheRead : nil, - cacheCreationTokens: totalCacheWrite > 0 ? totalCacheWrite : nil, - totalTokens: totalTokens > 0 ? totalTokens : nil, - totalCostUSD: totalCostSamples > 0 ? Double(totalCostNanos) / Self.costScale : nil)) - } - - private static func mergedContributions( - existing: [String: [String: [String: PiPackedUsage]]], - delta: [String: [String: [String: PiPackedUsage]]]) -> [String: [String: [String: PiPackedUsage]]] - { - var merged = existing - self.applyContributions(daysByProvider: &merged, contributions: delta, sign: 1) - return merged - } - - private static func applyContributions( - daysByProvider: inout [String: [String: [String: PiPackedUsage]]], - contributions: [String: [String: [String: PiPackedUsage]]], - sign: Int) - { - for (providerKey, providerDays) in contributions { - var mergedProviderDays = daysByProvider[providerKey] ?? [:] - for (dayKey, dayModels) in providerDays { - var mergedDayModels = mergedProviderDays[dayKey] ?? [:] - for (modelName, packed) in dayModels { - let updated = self.addPacked( - a: mergedDayModels[modelName] ?? PiPackedUsage(), - b: packed, - sign: sign) - if updated.isZero { - mergedDayModels.removeValue(forKey: modelName) - } else { - mergedDayModels[modelName] = updated - } - } - if mergedDayModels.isEmpty { - mergedProviderDays.removeValue(forKey: dayKey) - } else { - mergedProviderDays[dayKey] = mergedDayModels - } - } - if mergedProviderDays.isEmpty { - daysByProvider.removeValue(forKey: providerKey) - } else { - daysByProvider[providerKey] = mergedProviderDays - } - } - } - - private static func addPacked(a: PiPackedUsage, b: PiPackedUsage, sign: Int) -> PiPackedUsage { - let aUsageSampleCount = a.usageSampleCount ?? (a.isZero ? 0 : nil) - let bUsageSampleCount = b.usageSampleCount ?? (b.isZero ? 0 : nil) - let usageSampleCount: Int? = if let aCount = aUsageSampleCount, let bCount = bUsageSampleCount { - max(0, aCount + sign * bCount) - } else { - nil - } - - return PiPackedUsage( - inputTokens: max(0, a.inputTokens + sign * b.inputTokens), - cacheReadTokens: max(0, a.cacheReadTokens + sign * b.cacheReadTokens), - cacheWriteTokens: max(0, a.cacheWriteTokens + sign * b.cacheWriteTokens), - outputTokens: max(0, a.outputTokens + sign * b.outputTokens), - totalTokens: max(0, a.totalTokens + sign * b.totalTokens), - costNanos: max(0, a.costNanos + Int64(sign) * b.costNanos), - costSampleCount: max(0, a.costSampleCount + sign * b.costSampleCount), - usageSampleCount: usageSampleCount) - } - private static func parseSessionStartFromFilename(_ filename: String) -> Date? { guard let regex = self.sessionStartFilenameRegex else { return nil } let range = NSRange(filename.startIndex.. URL { + url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL + } + private static func dateFromDayKey(_ key: String, calendar: Calendar) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, @@ -1093,23 +1385,19 @@ extension PiSessionCostScanner { return components.date } - private static func sortedModelBreakdowns(_ breakdowns: [CostUsageDailyReport.ModelBreakdown]) - -> [CostUsageDailyReport.ModelBreakdown] - { - breakdowns.sorted { lhs, rhs in - let lhsCost = lhs.costUSD ?? -1 - let rhsCost = rhs.costUSD ?? -1 - if lhsCost != rhsCost { - return lhsCost > rhsCost - } - - let lhsTokens = lhs.totalTokens ?? -1 - let rhsTokens = rhs.totalTokens ?? -1 - if lhsTokens != rhsTokens { - return lhsTokens > rhsTokens - } - - return lhs.modelName > rhs.modelName + private static func scopeFingerprint(options: Options, cache originalCache: PiSessionCostCache) -> String { + let cache = originalCache + let roots = self.defaultSessionRoots( + options: options, + previousSessionRootsFingerprint: cache.sessionRootsFingerprint) + // An incomplete root resolution restores the cached report wholesale. Advertise that + // retained scope so callers do not reject the report as belonging to a different dataset. + if roots.contains(where: { !$0.resolutionIsComplete }), + let cachedScope = cache.sessionRootsFingerprint, + !cachedScope.isEmpty + { + return cachedScope } + return self.sessionRootsFingerprint(roots) } } diff --git a/Sources/CodexBarCore/PiSessionProcessContext.swift b/Sources/CodexBarCore/PiSessionProcessContext.swift new file mode 100644 index 0000000000..77a9b1e565 --- /dev/null +++ b/Sources/CodexBarCore/PiSessionProcessContext.swift @@ -0,0 +1,24 @@ +import Foundation + +/// The process command and working directory needed to resolve a live Pi-family session store. +public struct PiSessionProcessContext: Equatable, Sendable { + public let command: String + /// Original argv when available; this preserves whitespace inside flag values. + public let arguments: [String]? + /// The process CWD, when it could be read. An absolute `--session-dir` remains resolvable when this is nil. + public let workingDirectory: URL? + /// Captured Pi root selectors only. Missing evidence must never inherit the scanner's environment. + public let selectorEnvironment: [String: String]? + + public init( + command: String, + arguments: [String]? = nil, + workingDirectory: URL?, + selectorEnvironment: [String: String]? = nil) + { + self.command = command + self.arguments = arguments + self.workingDirectory = workingDirectory?.standardizedFileURL + self.selectorEnvironment = PiProcessEnvironment.filtered(selectorEnvironment) + } +} diff --git a/Sources/CodexBarCore/PiSnapshotAccounting.swift b/Sources/CodexBarCore/PiSnapshotAccounting.swift new file mode 100644 index 0000000000..1b8537db4d --- /dev/null +++ b/Sources/CodexBarCore/PiSnapshotAccounting.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Describes which local sources are represented by a token-cost snapshot. +/// +/// Claude and Codex can read Pi/OMP session logs as an inclusive convenience. The +/// spend dashboard uses this metadata to project the native portion when Pi is +/// shown as its own source, so the same session is never counted twice. +package enum PiSnapshotAccounting: Sendable, Equatable { + case nativeOnly + case includesPi(scope: String, native: CostUsageTokenSnapshot) + case piOnly(scope: String) + + package var scope: String? { + switch self { + case .nativeOnly: + nil + case let .includesPi(scope, _), let .piOnly(scope): + scope + } + } +} + +package struct CostUsageTokenResult: Sendable, Equatable { + package let snapshot: CostUsageTokenSnapshot + package let accounting: PiSnapshotAccounting? + + package init(snapshot: CostUsageTokenSnapshot, accounting: PiSnapshotAccounting? = nil) { + self.snapshot = snapshot + self.accounting = accounting + } +} diff --git a/Sources/CodexBarCore/Providers/Pi/PiProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Pi/PiProviderDescriptor.swift new file mode 100644 index 0000000000..44a638eb54 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Pi/PiProviderDescriptor.swift @@ -0,0 +1,78 @@ +import Foundation + +public enum PiProviderDescriptor { + public static let descriptor: ProviderDescriptor = Self.makeDescriptor() + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .pi, + metadata: ProviderMetadata( + id: .pi, + displayName: "Pi", + sessionLabel: "Session", + weeklyLabel: "Weekly", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Pi usage", + cliName: "pi", + defaultEnabled: false, + widgetSelectable: true, + isPrimaryProvider: false, + usesAccountFallback: false, + dashboardURL: "https://github.com/badlogic/pi-mono", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .init(provider: .pi), + iconResourceName: "ProviderIcon-pi", + color: ProviderColor(hex: 0x7C3AED), + confettiPalette: [ + ProviderColor(hex: 0x7C3AED), + ProviderColor(hex: 0xA78BFA), + ProviderColor(hex: 0xEDE9FE), + ]), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: true, + noDataMessage: self.noDataMessage, + menuHintLines: [.estimate], + supportsTokenSnapshot: true, + settingsStatusOrder: 10, + showsHintInProviderDetails: true, + historyTitleStyle: .compact, + hintPlacement: .afterRequestHistory, + chartEstimateDisclaimer: .localized("codex_api_estimate_hint")), + pace: .unsupported, + history: .alwaysTracked, + presentation: ProviderUsagePresentation( + menuCard: ProviderMenuCardPresentation(supportsInlineTokenCostDashboard: true)), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PiLocalFetchStrategy()] })), + cli: ProviderCLIConfig(name: "pi", versionDetector: nil, supportsCostCommand: true)) + } + + private static func noDataMessage() -> String { + "No Pi sessions found." + } +} + +struct PiLocalFetchStrategy: ProviderFetchStrategy { + let id: String = "pi.local" + let kind: ProviderFetchKind = .localProbe + func isAvailable(_: ProviderFetchContext) async -> Bool { + true + } + + func fetch(_ c: ProviderFetchContext) async throws -> ProviderFetchResult { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date(), + dataConfidence: .estimated) + return self.makeResult(usage: snapshot, sourceLabel: "local") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift index cad5b8e6a3..d569059ba8 100644 --- a/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift +++ b/Sources/CodexBarCore/Providers/ProviderInstanceIDAliases.generated.swift @@ -77,6 +77,7 @@ extension ProviderInstanceID { public static let coderabbit = UsageProvider.coderabbit.instanceID public static let replicate = UsageProvider.replicate.instanceID public static let huggingface = UsageProvider.huggingface.instanceID + public static let pi = UsageProvider.pi.instanceID } // swiftformat:enable sortDeclarations diff --git a/Sources/CodexBarCore/Providers/ProviderManifest.swift b/Sources/CodexBarCore/Providers/ProviderManifest.swift index 559e7b79cb..5caa94e552 100644 --- a/Sources/CodexBarCore/Providers/ProviderManifest.swift +++ b/Sources/CodexBarCore/Providers/ProviderManifest.swift @@ -79,5 +79,6 @@ public enum ProviderManifest { CodeRabbitProviderDescriptor.descriptor, ReplicateProviderDescriptor.descriptor, HuggingFaceProviderDescriptor.descriptor, + PiProviderDescriptor.descriptor, ] } diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index 8bf7e76032..4095a3a766 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -93,6 +93,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case coderabbit case replicate case huggingface + case pi } // swiftformat:enable sortDeclarations diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 290a04971f..4b92325f79 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -23,6 +23,7 @@ enum ProviderChoice: String, AppEnum { case kimi case deepseek case openrouter + case pi static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Provider") @@ -49,6 +50,7 @@ enum ProviderChoice: String, AppEnum { .kimi: DisplayRepresentation(title: "Kimi Code"), .deepseek: DisplayRepresentation(title: "DeepSeek"), .openrouter: DisplayRepresentation(title: "OpenRouter"), + .pi: DisplayRepresentation(title: "Pi"), ] var provider: UsageProvider { diff --git a/Tests/CodexBarTests/CLICostTests.swift b/Tests/CodexBarTests/CLICostTests.swift index 9670c94e3e..fa8c1b694d 100644 --- a/Tests/CodexBarTests/CLICostTests.swift +++ b/Tests/CodexBarTests/CLICostTests.swift @@ -84,11 +84,35 @@ struct CLICostTests { groupBy: .project, format: .text, includePiSessions: true)) + #expect(!CodexBarCLI.costIncludePiSessions( + provider: .codex, + selectedProviders: [.codex, .pi], + groupBy: .none, + format: .text, + includePiSessions: true)) + #expect(!CodexBarCLI.costIncludePiSessions( + provider: .codex, + selectedProviders: [.codex, .pi], + groupBy: .none, + format: .json, + includePiSessions: true)) #expect(CodexBarCLI.costIncludePiSessions( provider: .claude, groupBy: .session, format: .text, includePiSessions: true)) + #expect(!CodexBarCLI.costIncludePiSessions( + provider: .claude, + selectedProviders: [.claude, .pi], + groupBy: .none, + format: .json, + includePiSessions: true)) + #expect(CodexBarCLI.costIncludePiSessions( + provider: .claude, + selectedProviders: [.claude], + groupBy: .none, + format: .json, + includePiSessions: true)) } @Test diff --git a/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift b/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift index bf9975830b..256614897e 100644 --- a/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift +++ b/Tests/CodexBarTests/CostSummarySettingsSectionTests.swift @@ -13,6 +13,6 @@ struct CostSummarySettingsSectionTests { @Test func `cost settings status providers come from ordered descriptor capabilities`() { - #expect(CostSummarySettingsSection.costStatusProviders == [.claude, .codex, .cursor]) + #expect(CostSummarySettingsSection.costStatusProviders == [.claude, .codex, .cursor, .pi]) } } diff --git a/Tests/CodexBarTests/CostUsageCompletedSnapshotTests.swift b/Tests/CodexBarTests/CostUsageCompletedSnapshotTests.swift index b607686a6f..41fdeac40f 100644 --- a/Tests/CodexBarTests/CostUsageCompletedSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageCompletedSnapshotTests.swift @@ -4,6 +4,150 @@ import Testing @Suite(.serialized) struct CostUsageCompletedSnapshotTests { + @Test(arguments: ["renamed-root", "malformed-append", "new-malformed-file"]) + func `cached Pi source changes retain old totals without establishing completed history`( + change: String) async throws + { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let nativeOptions = Self.options(env: env) + _ = try await Self.seedNative(env: env, day: day, options: nativeOptions) + let since = try #require(nativeOptions.calendar.date(byAdding: .day, value: -29, to: day)) + let piFile = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_cached-source.jsonl", + contents: env.jsonl([[ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "usage": ["input": 165, "output": 0, "totalTokens": 165], + ], + ]])) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + calendar: nativeOptions.calendar, + refreshMinIntervalSeconds: 0) + let measuredAt = day.addingTimeInterval(-60) + let initial = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: since, + until: day, + now: measuredAt, + options: piOptions, + checkCancellation: nil) + #expect(initial.isComplete) + #expect(initial.report.summary?.totalTokens == 165) + let cacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + let successfulCacheBytes = try Data(contentsOf: cacheURL) + let movedRoot = env.root.appendingPathComponent("pi-temporarily-offline", isDirectory: true) + + if change == "renamed-root" { + try FileManager.default.moveItem(at: env.piSessionsRoot, to: movedRoot) + } else if change == "malformed-append" { + let handle = try FileHandle(forWritingTo: piFile) + try handle.seekToEnd() + try handle.write(contentsOf: Data("{malformed}\n".utf8)) + try handle.close() + } else { + try Data("{malformed}\n".utf8).write( + to: env.piSessionsRoot.appendingPathComponent("2026-04-08T11-00-00-000Z_new.jsonl")) + } + + let beforeScan = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: since, + until: day, + now: day, + cacheRoot: env.cacheRoot, + calendar: nativeOptions.calendar, + options: piOptions, + allowEstablishedEmpty: true) + #expect(beforeScan?.isComplete == false) + #expect(beforeScan?.report.summary?.totalTokens == 165) + #expect(beforeScan?.lastScanAt == measuredAt) + + let failed = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: since, + until: day, + now: day, + options: piOptions, + checkCancellation: nil) + #expect(!failed.isComplete) + #expect(failed.report.summary?.totalTokens == 165) + #expect(try Data(contentsOf: cacheURL) == successfulCacheBytes) + + let forbidParse: @Sendable () -> Void = { + Issue.record("Cached completeness validation must not parse JSONL") + } + let retained = PiSessionCostScanner.$sessionParseObserverForTesting.withValue(forbidParse) { + PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: since, + until: day, + now: day, + cacheRoot: env.cacheRoot, + calendar: nativeOptions.calendar, + options: piOptions, + allowEstablishedEmpty: true) + } + #expect(retained?.report.summary?.totalTokens == 165) + #expect(retained?.isComplete == false) + #expect(retained?.lastScanAt == measuredAt) + #expect(retained?.scopeFingerprint == initial.scopeFingerprint) + + let hydrated = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + includePiSessions: true, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + #expect(hydrated?.snapshot.last30DaysTokens == 265) + #expect(hydrated?.snapshot.historyCoverageIsEstablished == false) + #expect(hydrated?.snapshot.updatedAt == measuredAt) + #expect(hydrated?.lastRefreshAt == nil) + #expect(await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + includePiSessions: true, + requireCompleteHistory: true, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) == nil) + #expect(try Data(contentsOf: cacheURL) == successfulCacheBytes) + + var debouncedOptions = piOptions + debouncedOptions.refreshMinIntervalSeconds = 3600 + let debounced = try PiSessionCostScanner.$sessionParseObserverForTesting.withValue(forbidParse) { + try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: since, + until: day, + now: day, + options: debouncedOptions, + checkCancellation: nil) + } + #expect(!debounced.isComplete) + #expect(debounced.report.summary?.totalTokens == 165) + #expect(debounced.lastScanAt == measuredAt) + #expect(try Data(contentsOf: cacheURL) == successfulCacheBytes) + + if change == "renamed-root" { + try FileManager.default.moveItem(at: movedRoot, to: env.piSessionsRoot) + let restored = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + includePiSessions: true, + requireCompleteHistory: true, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + #expect(restored?.snapshot.last30DaysTokens == 265) + #expect(restored?.snapshot.historyCoverageIsEstablished == true) + #expect(restored?.snapshot.updatedAt == measuredAt) + #expect(try Data(contentsOf: cacheURL) == successfulCacheBytes) + } + } + @Test(arguments: ["missing", "pending", "timezone", "window", "roots"]) func `completed cache reads reject unavailable native history`(invalidity: String) async throws { let env = try CostUsageTestEnvironment() @@ -116,19 +260,27 @@ struct CostUsageCompletedSnapshotTests { ]])) } let since = try #require(options.calendar.date(byAdding: .day, value: -29, to: day)) + let emptyOMPRoot = env.root.appendingPathComponent("empty-omp", isDirectory: true) + try FileManager.default.createDirectory(at: emptyOMPRoot, withIntermediateDirectories: true) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: emptyOMPRoot, + cacheRoot: env.cacheRoot, + calendar: options.calendar, + refreshMinIntervalSeconds: 0) _ = PiSessionCostScanner.loadDailyReport( provider: .codex, since: since, until: day, now: day.addingTimeInterval(-60), - options: PiSessionCostScanner.Options( - piSessionsRoot: env.piSessionsRoot, - ompSessionsRoot: env.root.appendingPathComponent("empty-omp"), - cacheRoot: env.cacheRoot, - calendar: options.calendar, - refreshMinIntervalSeconds: 0)) + options: piOptions) - let completed = await fetcher.loadCompletedCodexTokenSnapshotResult(now: day) + let completed = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + allowScopedCodexHome: true, + requireCompleteHistory: true, + scannerOptions: options, + piScannerOptions: piOptions) #expect(completed?.snapshot.last30DaysTokens == 100 + piTokens) #expect(completed?.snapshot.updatedAt == day.addingTimeInterval(-60)) #expect(completed?.lastRefreshAt == nil) @@ -146,10 +298,20 @@ struct CostUsageCompletedSnapshotTests { default: cache.scanSinceKey = "2026-04-08" } PiSessionCostCacheIO.save(cache: cache, cacheRoot: env.cacheRoot, calendar: storedCalendar) - #expect(await fetcher.loadCompletedCodexTokenSnapshotResult(now: day) == nil) + #expect(await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + allowScopedCodexHome: true, + requireCompleteHistory: true, + scannerOptions: options, + piScannerOptions: piOptions) == nil) } try Data("invalid JSON".utf8).write(to: PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot)) - #expect(await fetcher.loadCompletedCodexTokenSnapshotResult(now: day) == nil) + #expect(await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + allowScopedCodexHome: true, + requireCompleteHistory: true, + scannerOptions: options, + piScannerOptions: piOptions) == nil) } @Test diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests+CurrentWindow.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests+CurrentWindow.swift index 0a396b0de8..e4881bb480 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests+CurrentWindow.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests+CurrentWindow.swift @@ -21,22 +21,25 @@ extension CostUsageFetcherCacheSnapshotTests { defer { fixture.base.remove() } #expect(await fixture.strictSnapshot() != nil) let measuredAt = fixture.base.now.addingTimeInterval(-1800) + let ompRoot = fixture.base.env.root.appendingPathComponent("empty-omp") + try FileManager.default.createDirectory(at: ompRoot, withIntermediateDirectories: true) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: fixture.base.env.piSessionsRoot, + ompSessionsRoot: ompRoot, + cacheRoot: fixture.base.env.cacheRoot, + calendar: fixture.base.calendar, + refreshMinIntervalSeconds: 0, + environment: ["HOME": fixture.base.env.root.path]) if kind != "missing", kind != "scoped-missing" { if kind != "empty" { try Self.writeCurrentWindowPiSession(fixture) } - let options = PiSessionCostScanner.Options( - piSessionsRoot: fixture.base.env.piSessionsRoot, - ompSessionsRoot: fixture.base.env.root.appendingPathComponent("empty-omp"), - cacheRoot: fixture.base.env.cacheRoot, - calendar: fixture.base.calendar, - refreshMinIntervalSeconds: 0) _ = PiSessionCostScanner.loadDailyReport( provider: .codex, since: fixture.base.now, until: fixture.base.now, now: fixture.base.now, - options: options) + options: piOptions) var cache = PiSessionCostCacheIO.load(cacheRoot: fixture.base.env.cacheRoot) #expect(cache.lastScanUnixMs > 0) cache.lastScanUnixMs = Int64(measuredAt.timeIntervalSince1970 * 1000) @@ -58,7 +61,9 @@ extension CostUsageFetcherCacheSnapshotTests { historyDays: 1, allowScopedCodexHome: true, requireCompleteHistory: true, - scannerOptions: fixture.base.options) + scannerOptions: fixture.base.options, + environment: ["HOME": fixture.base.env.root.path], + piScannerOptions: piOptions) if kind == "valid" || kind == "empty" || scoped { let cached = try #require(cached) #expect(cached.snapshot.last30DaysTokens == (kind == "valid" ? 217 : 52)) diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index 03fb3bc5e2..237287db76 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -460,7 +460,8 @@ struct CostUsageFetcherCacheSnapshotTests { let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( now: hydratedAt, historyDays: 1, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(cached?.snapshot.sessionTokens == 207) #expect(cached?.snapshot.updatedAt == oldestScanTime) @@ -500,7 +501,8 @@ struct CostUsageFetcherCacheSnapshotTests { let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( now: day.addingTimeInterval(50 * 60), historyDays: 1, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(cached?.snapshot.sessionTokens == 165) #expect(cached?.snapshot.updatedAt == piScanTime) @@ -508,7 +510,7 @@ struct CostUsageFetcherCacheSnapshotTests { } @Test - func `cached codex token snapshot keeps native scan time when pi cache lacks one`() async throws { + func `cached codex token snapshot excludes unmeasured Pi cache and retains native scan time`() async throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -548,14 +550,18 @@ struct CostUsageFetcherCacheSnapshotTests { timeIntervalSince1970: TimeInterval(nativeCache.lastScanUnixMs) / 1000) let hydratedAt = day.addingTimeInterval(50 * 60) - let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( now: hydratedAt, historyDays: 1, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) - #expect(cached?.sessionTokens == 207) - #expect(cached?.updatedAt == nativeScanTime) - #expect(cached?.updatedAt != hydratedAt) + #expect(cached?.snapshot.sessionTokens == 42) + #expect(cached?.snapshot.historyCoverageIsEstablished == false) + #expect(cached?.accounting == .nativeOnly) + #expect(cached?.lastRefreshAt == nil) + #expect(cached?.snapshot.updatedAt == nativeScanTime) + #expect(cached?.snapshot.updatedAt != hydratedAt) } @Test @@ -716,7 +722,8 @@ struct CostUsageFetcherCacheSnapshotTests { let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( now: day, historyDays: 1, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(cached?.sessionTokens == 207) #expect(cached?.last30DaysTokens == 207) @@ -748,7 +755,8 @@ struct CostUsageFetcherCacheSnapshotTests { scannerOptions: CostUsageScanner.Options( codexSessionsRoot: env.codexSessionsRoot, cacheRoot: env.cacheRoot, - codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"))) + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")), + piScannerOptions: piOptions) #expect(cached?.sessionTokens == 165) #expect(cached?.last30DaysTokens == 165) @@ -792,7 +800,8 @@ struct CostUsageFetcherCacheSnapshotTests { let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshot( now: day, historyDays: 1, - scannerOptions: options) + scannerOptions: options, + piScannerOptions: piOptions) #expect(cached?.sessionTokens == 165) #expect(cached?.last30DaysTokens == 165) diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 8acbe43282..95a7adac7a 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -62,6 +62,58 @@ struct CostUsageFetcherTests { #expect(nativeOnly.sessions.count == 1) } + @Test + func `token result keeps native projection for inclusive Pi accounting`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "native.jsonl", + tokens: 100) + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_mixed.jsonl", + contents: env.jsonl([[ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 50, "output": 5, "totalTokens": 55], + ], + ]])) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let result = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: true, + scannerOptions: scannerOptions, + piScannerOptions: piOptions) + + #expect(result.snapshot.sessionTokens == 155) + guard case let .includesPi(scope, native) = result.accounting else { + Issue.record("expected an inclusive Pi accounting result") + return + } + #expect(!scope.isEmpty) + #expect(native.sessionTokens == 100) + } + @Test func `fetcher scopes codex history to selected codex home`() async throws { let env = try CostUsageTestEnvironment() @@ -881,7 +933,8 @@ extension CostUsageFetcherTests { CostUsageDailyReport.ModelBreakdown( modelName: "claude-sonnet-4-6", costUSD: nativeCost + piCost, - totalTokens: 205), + totalTokens: 205, + requestCount: 1), ]) } diff --git a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift index 039f9b566b..ce3409b851 100644 --- a/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift +++ b/Tests/CodexBarTests/DarwinProcessEnumeratorTests.swift @@ -12,6 +12,7 @@ struct DarwinProcessEnumeratorTests { let data = Self.procArgsData(arguments: ["/usr/bin/tool", "--flag", "value"]) #expect(DarwinProcessEnumerator.parseProcArgs2(data) == "/usr/bin/tool --flag value") + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == ["/usr/bin/tool", "--flag", "value"]) } @Test @@ -38,6 +39,47 @@ struct DarwinProcessEnumeratorTests { #expect(command?.contains("HOME") == false) } + @Test + func `proc args selector environment accepts normal terminators and padding`() { + var data = Self.procArgsData( + arguments: ["/usr/local/bin/omp", "", "--profile", "work"], + environment: ["HOME=/synthetic/home", "OMP_PROFILE=work", "UNRELATED=value"]) + data.append(contentsOf: [0, 0, 0]) + + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(data) == [ + "/usr/local/bin/omp", "", "--profile", "work", + ]) + #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(data) == [ + "HOME": "/synthetic/home", "OMP_PROFILE": "work", + ]) + #expect(DarwinProcessEnumerator.parseProcArgs2(data)?.contains("HOME=") == false) + } + + @Test + func `proc args selector environment distinguishes omitted empty and truncated evidence`() { + let empty = Self.procArgsData(arguments: ["pi"]) + #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(empty) == nil) + var paddedEmpty = empty + paddedEmpty.append(contentsOf: [0, 0]) + #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(paddedEmpty) == nil) + let knownEmpty = Self.procArgsData(arguments: ["pi"], environment: ["UNRELATED=value"]) + #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(knownEmpty) == [:]) + var truncated = Self.procArgsData(arguments: ["pi"], environment: ["HOME=/synthetic/home"]) + truncated.removeLast() + #expect(DarwinProcessEnumerator.parseProcArgs2Arguments(truncated) == ["pi"]) + #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(truncated) == nil) + } + + @Test + func `proc args selector environment stops before Apple vectors`() { + var data = Self.procArgsData(arguments: ["pi"], environment: ["HOME=/synthetic/process"]) + data.append(0) + data.append(contentsOf: "HOME=/synthetic/apple-vector\0ptr_munge=ignored\0".utf8) + #expect(DarwinProcessEnumerator.parseProcArgs2PiSelectorEnvironment(data) == [ + "HOME": "/synthetic/process", + ]) + } + @Test func `proc args parser preserves embedded empty arguments`() { let data = Self.procArgsData(arguments: ["/usr/bin/tool", "", "value"]) diff --git a/Tests/CodexBarTests/PiFamilySessionTests.swift b/Tests/CodexBarTests/PiFamilySessionTests.swift index c2fb954e8b..c8c2ca5c3d 100644 --- a/Tests/CodexBarTests/PiFamilySessionTests.swift +++ b/Tests/CodexBarTests/PiFamilySessionTests.swift @@ -194,6 +194,37 @@ struct PiFamilySessionTests { #expect(sessions.first { $0.id == "pi-settings" }?.dialect == .pi) } + @Test + func `scanner preserves direct omp profile layout`() throws { + let root = try Self.temporaryDirectory(named: "PiDirectOMPProfile") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appendingPathComponent("home", isDirectory: true) + let sessionsRoot = home.appendingPathComponent(".omp/profiles/work/sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessionsRoot, withIntermediateDirectories: true) + + let now = Date(timeIntervalSince1970: 1_900_000_000) + try Self.writeSession( + at: sessionsRoot.appendingPathComponent("direct-omp.jsonl"), + dialect: .omp, + id: "omp-direct-profile", + cwd: "/tmp/direct-omp-profile", + modifiedAt: now.addingTimeInterval(-5)) + + let sessions = Self.scan( + processes: [Self.process( + pid: 61, + startedAt: now.addingTimeInterval(-60), + command: "omp --profile work")], + cwdByPID: [61: "/tmp/direct-omp-profile"], + environment: ["HOME": home.path], + now: now) + + let session = try #require(sessions.first) + #expect(session.id == "omp-direct-profile") + #expect(session.dialect == .omp) + #expect(session.transcriptPath == sessionsRoot.appendingPathComponent("direct-omp.jsonl").path) + } + @Test func `missing jsonl and unresolved custom roots retain pid only rows`() throws { let root = try Self.temporaryDirectory(named: "PiPIDOnly") @@ -250,9 +281,18 @@ struct PiFamilySessionTests { now: Date) -> [AgentSession] { var budget = DirectoryMetadataScanBudget(maxEntryCount: 512, maxDepth: 1, timeLimit: 5) + let fixtureProcesses = processes.map { + AgentProcessRecord( + pid: $0.pid, + ppid: $0.ppid, + startedAt: $0.startedAt, + command: $0.command, + arguments: $0.arguments, + piSelectorEnvironment: environment) + } return PiFamilySessionScanner.scan( input: PiFamilySessionScanner.ScanInput( - processes: processes, + processes: fixtureProcesses, cwdByPID: cwdByPID, environment: environment, now: now, diff --git a/Tests/CodexBarTests/PiInclusiveRefreshTests.swift b/Tests/CodexBarTests/PiInclusiveRefreshTests.swift new file mode 100644 index 0000000000..1900b95cf3 --- /dev/null +++ b/Tests/CodexBarTests/PiInclusiveRefreshTests.swift @@ -0,0 +1,61 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiInclusiveRefreshTests { + @Test(arguments: [UsageProvider.codex, .claude, .pi]) + func `forced refresh reparses Pi history with unchanged file metadata`(provider: UsageProvider) async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 9) + var nativeOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + nativeOptions.refreshMinIntervalSeconds = 0 + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: ["HOME": env.root.path]) + func contents(input: Int) throws -> String { + try env.jsonl([["type": "message", "id": "turn", "timestamp": env.isoString(for: day), "message": [ + "role": "assistant", + "provider": provider == .claude ? "anthropic" : "openai-codex", + "model": provider == .claude ? "claude-sonnet-4-6" : "openai/gpt-5.4", + "usage": ["input": input, "output": 5, "totalTokens": input + 5], + ]]]) + } + func refresh(force: Bool) async throws -> CostUsageTokenSnapshot { + try await CostUsageFetcher.loadTokenSnapshot( + provider: provider, + environment: ["HOME": env.root.path], + now: day, + forceRefresh: force, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: true, + scannerOptions: nativeOptions, + piScannerOptions: piOptions) + } + let original = try contents(input: 10) + let replacement = try contents(input: 20) + #expect(original.utf8.count == replacement.utf8.count) + let file = try env.writePiSessionFile(relativePath: "same-metadata.jsonl", contents: original) + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + let initial = try await refresh(force: false) + #expect(initial.last30DaysTokens == 15) + #expect(initial.historyCoverageIsEstablished) + + // Overwrite the same inode; an ordinary metadata check cannot detect this edit. + let handle = try FileHandle(forWritingTo: file) + try handle.write(contentsOf: Data(replacement.utf8)) + try handle.close() + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + #expect(try await refresh(force: false).last30DaysTokens == 15) + + let forced = try await refresh(force: true) + #expect(forced.last30DaysTokens == 25) + #expect(forced.historyCoverageIsEstablished) + } +} diff --git a/Tests/CodexBarTests/PiNativeProjectionTests.swift b/Tests/CodexBarTests/PiNativeProjectionTests.swift new file mode 100644 index 0000000000..2d036fc56c --- /dev/null +++ b/Tests/CodexBarTests/PiNativeProjectionTests.swift @@ -0,0 +1,309 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PiNativeProjectionTests { + @Test + func `fresh and cached native projections preserve pinned hourly and quota slices`() async throws { + let fixture = try Fixture() + defer { fixture.env.cleanup() } + try fixture.writePiHistory() + + let baseline = try await fixture.load(includePi: false) + #expect(baseline.accounting == .nativeOnly) + #expect(baseline.snapshot.historyCoverageIsEstablished) + #expect(baseline.snapshot.last30DaysTokens == 140) + #expect(baseline.snapshot.hourly.map(\.hour) == fixture.hours) + #expect(baseline.snapshot.hourly.map(\.totalTokens) == [100, 40]) + #expect(baseline.snapshot.quotaSlices.map(\.timestamp) == fixture.events) + #expect(baseline.snapshot.quotaSlices.map(\.totalTokens) == [100, 40]) + + let fresh = try await fixture.load(includePi: true) + #expect(fresh.snapshot.historyCoverageIsEstablished) + #expect(fresh.snapshot.last30DaysTokens == 195) + #expect(fresh.snapshot.hourly == baseline.snapshot.hourly) + #expect(fresh.snapshot.quotaSlices == baseline.snapshot.quotaSlices) + guard case let .includesPi(freshScope, freshNative) = fresh.accounting else { + Issue.record("Expected fresh inclusive Pi accounting with a native projection") + return + } + #expect(!freshScope.isEmpty) + #expect(freshNative.daily == baseline.snapshot.daily) + #expect(freshNative.hourly == baseline.snapshot.hourly) + #expect(freshNative.quotaSlices == baseline.snapshot.quotaSlices) + #expect(freshNative.last30DaysTokens == 140) + #expect(freshNative.historyCoverageIsEstablished) + try fixture.expectNativeQuotaWindow(freshNative) + + let cachedValue = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: fixture.now.addingTimeInterval(60), + historyDays: 1, + includePiSessions: true, + scannerOptions: fixture.options, + environment: fixture.environment, + piScannerOptions: fixture.piOptions) + let cached = try #require(cachedValue) + #expect(cached.snapshot.historyCoverageIsEstablished) + #expect(cached.snapshot.last30DaysTokens == 195) + #expect(cached.snapshot.hourly == baseline.snapshot.hourly) + #expect(cached.snapshot.quotaSlices == baseline.snapshot.quotaSlices) + guard case let .includesPi(cachedScope, cachedNative) = cached.accounting else { + Issue.record("Expected cached inclusive Pi accounting with a native projection") + return + } + #expect(cachedScope == freshScope) + #expect(cachedNative.daily == freshNative.daily) + #expect(cachedNative.hourly == freshNative.hourly) + #expect(cachedNative.quotaSlices == freshNative.quotaSlices) + #expect(cachedNative.last30DaysTokens == 140) + #expect(cachedNative.historyCoverageIsEstablished) + try fixture.expectNativeQuotaWindow(cachedNative) + } + + @Test + func `missing Pi cache leaves native hydration incomplete without claiming a refresh TTL`() async throws { + let fixture = try Fixture() + defer { fixture.env.cleanup() } + // A transcript exists, but a cached-only read cannot claim it was inspected. + try fixture.writePiHistory() + let baseline = try await fixture.load(includePi: false) + let piCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: fixture.env.cacheRoot) + #expect(!FileManager.default.fileExists(atPath: piCacheURL.path)) + + let controlValue = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: fixture.now.addingTimeInterval(60), + historyDays: 1, + includePiSessions: false, + scannerOptions: fixture.options, + environment: fixture.environment, + piScannerOptions: fixture.piOptions) + let control = try #require(controlValue) + #expect(control.accounting == .nativeOnly) + #expect(control.snapshot.historyCoverageIsEstablished) + #expect(control.lastRefreshAt == fixture.now) + + let partialValue = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: fixture.now.addingTimeInterval(60), + historyDays: 1, + includePiSessions: true, + scannerOptions: fixture.options, + environment: fixture.environment, + piScannerOptions: fixture.piOptions) + let partial = try #require(partialValue) + #expect(partial.accounting == .nativeOnly) + #expect(partial.snapshot.last30DaysTokens == 140) + #expect(partial.snapshot.daily == baseline.snapshot.daily) + #expect(partial.snapshot.hourly == baseline.snapshot.hourly) + #expect(partial.snapshot.quotaSlices == baseline.snapshot.quotaSlices) + #expect(!partial.snapshot.historyCoverageIsEstablished) + #expect(partial.lastRefreshAt == nil) + #expect(partial.staleSnapshotUpdatedAt == nil) + #expect(partial.snapshot.updatedAt == control.snapshot.updatedAt) + #expect(!FileManager.default.fileExists(atPath: piCacheURL.path)) + + let strict = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: fixture.now.addingTimeInterval(60), + historyDays: 1, + includePiSessions: true, + requireCompleteHistory: true, + scannerOptions: fixture.options, + environment: fixture.environment, + piScannerOptions: fixture.piOptions) + #expect(strict == nil) + #expect(!FileManager.default.fileExists(atPath: piCacheURL.path)) + + let refreshed = try await fixture.load(includePi: true) + #expect(refreshed.snapshot.historyCoverageIsEstablished) + #expect(refreshed.snapshot.last30DaysTokens == 195) + guard case .includesPi = refreshed.accounting else { + Issue.record("Expected a fresh scan to establish Pi ownership after missing-cache hydration") + return + } + } + + @Test + func `cached Pi history without native data retains Pi-only accounting`() async throws { + let fixture = try Fixture() + defer { fixture.env.cleanup() } + try fixture.writePiHistory() + let scanned = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: fixture.now, + until: fixture.now, + now: fixture.now, + options: fixture.piOptions, + checkCancellation: nil) + #expect(scanned.isComplete) + let cachedValue = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: fixture.now, + historyDays: 1, + includePiSessions: true, + scannerOptions: fixture.options, + environment: fixture.environment, + piScannerOptions: fixture.piOptions) + let cached = try #require(cachedValue) + #expect(cached.snapshot.last30DaysTokens == 55) + #expect(!cached.snapshot.historyCoverageIsEstablished) + #expect(cached.lastRefreshAt == nil) + guard case let .piOnly(scope) = cached.accounting else { + Issue.record("Pi-only hydration must not claim native source ownership") + return + } + #expect(scope == scanned.scopeFingerprint) + } + + @Test + func `invalid dated usage outside the scan window does not poison current Pi history`() throws { + let fixture = try Fixture() + defer { fixture.env.cleanup() } + let old = try #require(fixture.calendar.date(byAdding: .day, value: -90, to: fixture.events[0])) + _ = try fixture.env.writePiSessionFile( + relativePath: "long-lived.jsonl", + contents: fixture.env.jsonl([ + fixture.piRow(at: old, input: true), + fixture.piRow(at: fixture.events[0], input: 10), + ])) + let result = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .pi, + since: fixture.now, + until: fixture.now, + now: fixture.now, + options: fixture.piOptions, + checkCancellation: nil) + #expect(result.isComplete) + #expect(result.report.summary?.totalTokens == 10) + #expect(result.report.data.map(\.date) == ["2026-04-08"]) + #expect(result.lastScanAt == fixture.now) + } + + private struct Fixture { + let env: CostUsageTestEnvironment + let calendar: Calendar + let now: Date + let events: [Date] + let hours: [Date] + let resetStart: Date + let options: CostUsageScanner.Options + let piOptions: PiSessionCostScanner.Options + let environment: [String: String] + + init() throws { + let env = try CostUsageTestEnvironment() + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Kathmandu")) + let first = try #require(calendar.date(from: DateComponents( + year: 2026, month: 4, day: 8, hour: 0, minute: 20))) + let second = first.addingTimeInterval(3600) + let now = try #require(calendar.date(from: DateComponents( + year: 2026, month: 4, day: 8, hour: 12, minute: 30))) + let resetStart = try #require(calendar.date(from: DateComponents( + year: 2026, month: 4, day: 8, hour: 0, minute: 45))) + let hours = try [first, second].map { try #require(calendar.dateInterval(of: .hour, for: $0)?.start) } + let environment = ["HOME": env.root.path] + let omp = env.root.appendingPathComponent("empty-omp", isDirectory: true) + try FileManager.default.createDirectory(at: omp, withIntermediateDirectories: true) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: omp, + cacheRoot: env.cacheRoot, + calendar: calendar, + refreshMinIntervalSeconds: 0, + environment: environment) + self.env = env + self.calendar = calendar + self.now = now + self.events = [first, second] + self.hours = hours + self.resetStart = resetStart + self.options = options + self.piOptions = piOptions + self.environment = environment + + var rows: [[String: Any]] = [[ + "type": "session_meta", + "timestamp": env.isoString(for: first.addingTimeInterval(-2)), + "payload": ["id": "synthetic-native-projection", "cwd": env.root.path], + ]] + for (index, event) in [first, second].enumerated() { + rows.append([ + "type": "turn_context", + "timestamp": env.isoString(for: event.addingTimeInterval(-1)), + "payload": ["model": "gpt-5.4", "turn_id": "synthetic-turn-\(index)"], + ]) + rows.append([ + "type": "event_msg", + "timestamp": env.isoString(for: event), + "payload": [ + "type": "token_count", + "info": [ + "model": "gpt-5.4", + "last_token_usage": [ + "input_tokens": index == 0 ? 100 : 40, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + ], + ], + ]) + } + _ = try env.writeCodexSessionFile( + day: first, filename: "native-projection.jsonl", contents: env.jsonl(rows)) + } + + func load(includePi: Bool) async throws -> CostUsageTokenResult { + try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: self.environment, + now: self.now, + historyDays: 1, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: includePi, + scannerOptions: self.options, + piScannerOptions: self.piOptions) + } + + func writePiHistory() throws { + var row = self.piRow(at: self.events[0], input: 50) + var message = try #require(row["message"] as? [String: Any]) + message["usage"] = ["input": 50, "output": 5, "totalTokens": 55] + row["message"] = message + _ = try self.env.writePiSessionFile( + relativePath: "pi-projection.jsonl", contents: self.env.jsonl([row])) + } + + func piRow(at date: Date, input: Any) -> [String: Any] { + [ + "type": "message", + "timestamp": self.env.isoString(for: date), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "usage": ["input": input, "output": 0], + ], + ] + } + + func expectNativeQuotaWindow(_ snapshot: CostUsageTokenSnapshot) throws { + let resetAt = self.resetStart.addingTimeInterval(7 * 24 * 60 * 60) + let week = try #require(snapshot.quotaWeekSummaries( + resetAt: resetAt, + observedResetInstants: [self.resetStart], + weekCount: 1, + now: self.now, + calendar: self.calendar).first) + #expect(week.start == self.resetStart) + #expect(week.totalTokens == 40) + #expect(week.tokensAreComplete) + #expect(week.costIsComplete) + } + } +} diff --git a/Tests/CodexBarTests/PiNativeProofCorpus.swift b/Tests/CodexBarTests/PiNativeProofCorpus.swift new file mode 100644 index 0000000000..53b25c567b --- /dev/null +++ b/Tests/CodexBarTests/PiNativeProofCorpus.swift @@ -0,0 +1,188 @@ +import CryptoKit +import Foundation +@testable import CodexBarCore + +struct PiNativeProofCorpus: Sendable { + let output: URL + var root: URL { + self.output.appendingPathComponent("corpus", isDirectory: true) + } + + var cache: URL { + self.output.appendingPathComponent("cache", isDirectory: true) + } + + var claude: URL { + self.root.appendingPathComponent("claude-projects", isDirectory: true) + } + + var pi: URL { + self.root.appendingPathComponent("pi-sessions", isDirectory: true) + } + + var parkedPi: URL { + self.root.appendingPathComponent("pi-offline", isDirectory: true) + } + + var omp: URL { + self.root.appendingPathComponent("empty-omp", isDirectory: true) + } + + var widgetURL: URL { + self.output.appendingPathComponent("widget-snapshot.json") + } + + var piFile: URL { + self.pi.appendingPathComponent("native-proof.jsonl") + } + + var isOffline: Bool { + FileManager.default.fileExists(atPath: self.parkedPi.path) + } + + var hasAppended: Bool { + let file = self.isOffline ? self.parkedPi.appendingPathComponent("native-proof.jsonl") : self.piFile + return (try? String(contentsOf: file, encoding: .utf8))?.contains("\"id\":\"pi-appended\"") == true + } + + var environment: [String: String] { + [ + "HOME": self.root.path, + "PI_CODING_AGENT_SESSION_DIR": self.pi.path, + "PI_CONFIG_DIR": "omp-config", + ] + } + + var scannerOptions: CostUsageScanner.Options { + .init( + codexSessionsRoot: self.root.appendingPathComponent("empty-codex"), + claudeProjectsRoots: [self.claude], + cacheRoot: self.cache, + calendar: .current) + } + + var piOptions: PiSessionCostScanner.Options { + .init( + piSessionsRoot: self.pi, + ompSessionsRoot: self.omp, + cacheRoot: self.cache, + calendar: .current, + refreshMinIntervalSeconds: 0, + environment: self.environment, + workingDirectory: self.root, + processContexts: []) + } + + func prepare() throws { + let manager = FileManager.default + for directory in [self.output, self.root, self.cache, self.claude, self.omp] { + try manager.createDirectory(at: directory, withIntermediateDirectories: true) + } + let marker = self.output.appendingPathComponent("corpus-created.json") + guard !manager.fileExists(atPath: marker.path) else { return } + let native = self.claude.appendingPathComponent("native.jsonl") + guard !manager.fileExists(atPath: native.path), !manager.fileExists(atPath: self.piFile.path) else { + throw CocoaError(.fileWriteFileExists) + } + try manager.createDirectory(at: self.pi, withIntermediateDirectories: true) + let stamp = ISO8601DateFormatter().string(from: Date()) + let row: [String: Any] = [ + "type": "assistant", "timestamp": stamp, "sessionId": "native-proof", "requestId": "native-1", + "message": [ + "id": "native-message-1", + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "usage": [ + "input_tokens": 200_000, + "output_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + ], + ], + ] + try Self.jsonLine(row).write(to: native, options: .atomic) + let header: [String: Any] = [ + "type": "session", "version": 3, "id": "pi-proof-session", "timestamp": stamp, "cwd": root.path, + ] + var piData = try Self.jsonLine(header) + try piData.append(Self.jsonLine(Self.piRow(id: "pi-initial", input: 40000, timestamp: stamp))) + try piData.write(to: self.piFile, options: .atomic) + try Self.jsonLine(["createdAt": stamp, "syntheticOnly": true]).write(to: marker, options: .atomic) + } + + func makeOffline() throws { + guard !self.isOffline else { return } + try FileManager.default.moveItem(at: self.pi, to: self.parkedPi) + } + + func restoreAndAppend() throws { + if self.isOffline { + try FileManager.default.moveItem(at: self.parkedPi, to: self.pi) + } + guard !self.hasAppended else { return } + let row = Self.piRow( + id: "pi-appended", input: 10000, timestamp: ISO8601DateFormatter().string(from: Date())) + let handle = try FileHandle(forWritingTo: piFile) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Self.jsonLine(row)) + } + + func load( + provider: UsageProvider, + force: Bool, + now: Date, + historyDays: Int, + includePi: Bool) async throws -> CostUsageTokenResult + { + try await CostUsageFetcher.loadTokenResult( + provider: provider, + environment: self.environment, + now: now, + forceRefresh: force, + historyDays: historyDays, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: includePi, + bypassScannerDebounce: true, + piSessionProcessContexts: [], + scannerOptions: self.scannerOptions, + piScannerOptions: self.piOptions) + } + + func cacheEvidence() -> [String: Any] { + let url = PiSessionCostCacheIO.cacheFileURL(cacheRoot: self.cache) + guard let data = try? Data(contentsOf: url) else { return [:] } + let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + return [ + "sha256": SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), + "lastScanUnixMs": object?["lastScanUnixMs"] ?? NSNull(), + ] + } + + private static func piRow(id: String, input: Int, timestamp: String) -> [String: Any] { + [ + "type": "message", + "id": id, + "timestamp": timestamp, + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "usage": [ + "input": input, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0, + "totalTokens": input, + ], + ], + ] + } + + private static func jsonLine(_ object: [String: Any]) throws -> Data { + var data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + data.append(0x0A) + return data + } +} diff --git a/Tests/CodexBarTests/PiNativeProofReceipts.swift b/Tests/CodexBarTests/PiNativeProofReceipts.swift new file mode 100644 index 0000000000..80b0a00f16 --- /dev/null +++ b/Tests/CodexBarTests/PiNativeProofReceipts.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest +@testable import CodexBar +@testable import CodexBarCore + +extension PiNativeProofSession { + func record(archive: Bool = false) { + let publication = store.spendDashboardPublication + let model = controller.overviewSpendDashboardModel(providers: enabledProviders) + let receipt: [String: Any] = [ + "pid": ProcessInfo.processInfo.processIdentifier, + "window": window?.windowNumber ?? 0, + "action": lastAction, "actionNumber": actionNumber, + "busy": busy, "failure": Self.jsonValue(failure), + "syntheticOnly": true, "realWidgetKitCompositor": false, + "piEnabled": piEnabled, "rootOffline": corpus.isOffline, + "appended": corpus.hasAppended, + "publicationRevision": publication.revision, "publicationGeneration": publication.generation, + "isRefreshing": publication.isRefreshing, + "tokens": model.groups.compactMap(\.totalTokens).reduce(0, +), + "costUSD": model.groups.compactMap(\.totalCost).reduce(0, +), + "sources": publication.sources.map { + ["id": $0.id, "role": String(describing: $0.role), "state": String(describing: $0.state)] + }, + "modelRows": model.groups.flatMap(\.providers).map { + [ + "id": $0.id, + "kind": $0.sourceKind.rawValue, + "tokens": Self.jsonValue($0.totalTokens), + "costUSD": Self.jsonValue($0.totalCost), + "coveredDayCount": $0.coveredDayCount, + ] as [String: Any] + }, + "snapshots": [UsageProvider.claude, .pi].map(self.snapshotReceipt), + "widget": self.widgetReceipt(), "piCache": corpus.cacheEvidence(), + "menuItems": openMenu?.items.compactMap { $0.representedObject as? String } ?? [], + "recordedAt": Date().timeIntervalSince1970, + ] + do { + let data = try JSONSerialization.data(withJSONObject: receipt, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: corpus.output.appendingPathComponent("state.json"), options: .atomic) + if archive { + let name = "\(ProcessInfo.processInfo.processIdentifier)-\(actionNumber)-\(lastAction).json" + let directory = corpus.output.appendingPathComponent("receipts", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent(name), options: .atomic) + } + } catch { + failure = "Receipt failed: \(error.localizedDescription)" + XCTFail(failure ?? "Receipt failed") + } + } + + private func snapshotReceipt(_ provider: UsageProvider) -> [String: Any] { + let regular = store.tokenSnapshotPublicationForCurrentProviderConfig(for: provider) + let independent = store.spendDashboardTokenSnapshotPublicationForCurrentConfig(for: provider) + func fields(_ publication: CurrentProviderConfigTokenPublication?) -> [String: Any] { + guard let snapshot = publication?.snapshot else { return ["available": false] } + return [ + "available": true, "tokens": Self.jsonValue(snapshot.last30DaysTokens), + "costUSD": Self.jsonValue(snapshot.last30DaysCostUSD), + "updatedAt": snapshot.updatedAt.timeIntervalSince1970, + "coverageEstablished": snapshot.historyCoverageIsEstablished, + "historyDays": snapshot.historyDays, + "accounting": publication?.accounting.map { String(describing: $0) } ?? "none", + ] + } + return ["provider": provider.rawValue, "regular": fields(regular), "dashboard": fields(independent)] + } + + private func widgetReceipt() -> [[String: Any]] { + widgetSnapshot?.entries.map { entry in + [ + "provider": entry.provider.rawValue, + "updatedAt": entry.updatedAt.timeIntervalSince1970, + "tokenUpdatedAt": Self.jsonValue(entry.tokenUsage?.updatedAt?.timeIntervalSince1970), + "tokens": Self.jsonValue(entry.tokenUsage?.last30DaysTokens), + "costUSD": Self.jsonValue(entry.tokenUsage?.last30DaysCostUSD), + "hasQuota": entry.primary != nil || entry.secondary != nil || entry.tertiary != nil, + "usageRowCount": entry.usageRows?.count ?? 0, + ] + } ?? [] + } + + private static func jsonValue(_ value: (some Any)?) -> Any { + value.map { $0 as Any } ?? NSNull() + } +} diff --git a/Tests/CodexBarTests/PiNativeProofSession.swift b/Tests/CodexBarTests/PiNativeProofSession.swift new file mode 100644 index 0000000000..739d6654f5 --- /dev/null +++ b/Tests/CodexBarTests/PiNativeProofSession.swift @@ -0,0 +1,227 @@ +import AppKit +import Foundation +import Observation +import XCTest +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +@Observable +final class PiNativeProofSession { + let corpus: PiNativeProofCorpus + let settings: SettingsStore + let store: UsageStore + let controller: StatusItemController + var widgetSnapshot: WidgetSnapshot? + var statusText = "Preparing synthetic local history…" + var busy = false + var finished = false + var failure: String? + var lastAction = "startup" + var actionNumber = 0 + @ObservationIgnored weak var window: NSWindow? + @ObservationIgnored var openMenu: NSMenu? + @ObservationIgnored var actionTask: Task? + + var piEnabled: Bool { + self.store.isEnabled(.pi) + } + + var enabledProviders: [UsageProvider] { + self.piEnabled ? [.claude, .pi] : [.claude] + } + + init(output: URL) throws { + let corpus = PiNativeProofCorpus(output: output) + try corpus.prepare() + self.corpus = corpus + let saved = (try? Data(contentsOf: output.appendingPathComponent("selection.json"))) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Bool] } + let piEnabled = saved?["piEnabled"] ?? true + let defaults = InMemoryUserDefaults() + let settings = testSettingsStore( + suiteName: "PiNativeProof", + userDefaults: defaults, + config: CodexBarConfig(providers: UsageProvider.allCases.map { + ProviderConfig(id: $0.instanceID, enabled: $0 == .claude || ($0 == .pi && piEnabled)) + }), + prepareDefaults: { + $0.set(AppGroupSupport.migrationVersion, forKey: AppGroupSupport.migrationVersionKey) + $0.set(true, forKey: "codexbar.legacySecretsMigrationCompleted") + $0.set(true, forKey: "debugDisableKeychainAccess") + $0.set(true, forKey: "providerDetectionCompleted") + $0.set(false, forKey: "openAIWebAccessEnabled") + }) + settings._test_codexReconciliationEnvironment = corpus.environment + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.openAIWebAccessEnabled = false + settings.openCodexUsageLogsEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + settings.costSummaryDisplayStyle = .both + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + settings.mergedMenuLastSelectedWasOverview = true + settings.mergedOverviewSelectedProviders = [.claude, .pi] + self.settings = settings + let store = UsageStore( + fetcher: UsageFetcher(environment: corpus.environment), + browserDetection: BrowserDetection(homeDirectory: corpus.root.path, fileExists: { _ in false }), + costUsageFetcher: CostUsageFetcher(cacheRoot: corpus.cache), + settings: settings, + startupBehavior: .testing, + environmentBase: corpus.environment, + widgetSnapshotURL: corpus.widgetURL) + store._test_piHistoryScopeResolver = { _ in + PiSessionCostScanner.scopeFingerprint(options: corpus.piOptions) + } + store._test_tokenUsageResultLoaderOverride = { provider, force, now, _, days, includePi in + try await corpus.load(provider: provider, force: force, now: now, historyDays: days, includePi: includePi) + } + store._test_providerRefreshOverride = { [weak store] provider in + guard provider == .claude || provider == .pi else { return XCTFail("Unexpected provider transport") } + store?._setSnapshotForTesting(Self.usageSnapshot(), provider: provider) + } + self.store = store + self.controller = StatusItemController( + store: store, + settings: settings, + account: AccountInfo(email: nil, plan: nil), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: .system) + let dashboard = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + await SpendDashboardSource.makeRequest(settings: settings, store: store, mode: mode) + }, + publicationHandler: { [weak store] publication in store?.spendDashboardPublication = publication }) + dashboard.selectDays(30) + store.sharedSpendDashboardControllerStorage = dashboard + store.startSharedSpendDashboardPublication() + } + + func run(_ action: String) { + guard !self.busy else { return } + self.busy = true + self.lastAction = action + self.actionTask = Task { @MainActor in + do { + let oldEvidence = self.corpus.cacheEvidence() + switch action { + case "toggle": + self.settings.setProviderEnabled( + provider: .pi, metadata: self.store.metadata(for: .pi), enabled: !self.piEnabled) + case "offline": try self.corpus.makeOffline() + case "restore-append": try self.corpus.restoreAndAppend() + default: break + } + try await self.refresh() + if action == "offline" { + let oldCache = try XCTUnwrap(oldEvidence["sha256"] as? String) + XCTAssertFalse(oldCache.isEmpty) + let oldScanMilliseconds = try XCTUnwrap(oldEvidence["lastScanUnixMs"] as? Double) + let retained = try XCTUnwrap(self.store.tokenSnapshotForCurrentProviderConfig(for: .pi)?.snapshot) + XCTAssertEqual( + retained.updatedAt.timeIntervalSince1970, + oldScanMilliseconds / 1000, + accuracy: 0.002) + XCTAssertFalse(retained.historyCoverageIsEstablished) + let widgetPi = self.widgetSnapshot?.entries.first { $0.provider == UsageProvider.pi.instanceID } + let widgetDate = try XCTUnwrap(widgetPi?.updatedAt) + // Shared widget JSON encodes ISO 8601 dates at whole-second precision. + XCTAssertEqual(widgetDate.timeIntervalSince1970, floor(oldScanMilliseconds / 1000)) + XCTAssertEqual(self.corpus.cacheEvidence()["sha256"] as? String, oldCache) + } + if !self.corpus.isOffline { + self.verifyTotals() + } + let selection = try JSONSerialization.data(withJSONObject: ["piEnabled": self.piEnabled]) + try selection.write(to: self.corpus.output.appendingPathComponent("selection.json"), options: .atomic) + self.statusText = "\(action) complete · Pi \(self.piEnabled ? "enabled" : "disabled")" + } catch { + self.failure = error.localizedDescription + self.statusText = "Proof failed: \(error.localizedDescription)" + XCTFail(self.statusText) + } + self.busy = false + self.actionNumber += 1 + self.record(archive: true) + } + } + + private func refresh() async throws { + for provider in self.enabledProviders { + self.store._setSnapshotForTesting(Self.usageSnapshot(), provider: provider) + } + for provider in [UsageProvider.claude, .pi] { + await self.store.refreshTokenUsage(provider, force: true) + } + let dashboard = self.store.sharedSpendDashboardController() + dashboard.update(configuration: SpendDashboardSource.configuration(settings: self.settings, store: self.store)) + dashboard.refresh() + let deadline = Date().addingTimeInterval(30) + while dashboard.isRefreshing || self.store.sharedSpendDashboardTokenPublicationDebounceTask != nil || + self.store.sharedSpendDashboardObservationDebounceTask != nil || + !self.store.spendDashboardTokenRefreshInFlight.isEmpty + { + try Task.checkCancellation() + guard Date() < deadline else { throw CocoaError(.coderReadCorrupt) } + try await Task.sleep(for: .milliseconds(25)) + } + self.store.persistWidgetSnapshot(reason: "pi-native-proof") + await self.store.widgetSnapshotPersistTask?.value + self.widgetSnapshot = WidgetSnapshotStore.load(from: self.corpus.widgetURL) + XCTAssertNotNil(self.widgetSnapshot) + } + + func showMenu(provider: UsageProvider?) { + guard !self.busy, let view = window?.contentView else { return } + self.settings.mergedMenuLastSelectedWasOverview = provider == nil + if let provider { self.settings.selectedMenuProvider = provider.instanceID } + self.openMenu = self.controller.makeMenu(for: provider) + self.lastAction = provider == nil ? "overview-menu" : "pi-menu" + self.actionNumber += 1 + record(archive: true) + let top = view.isFlipped ? 65 : view.bounds.height - 65 + self.openMenu?.popUp(positioning: nil, at: NSPoint(x: 20, y: top), in: view) + record(archive: true) + } + + func finish() { + guard !self.busy else { return } + self.lastAction = "finish" + self.actionNumber += 1 + record(archive: true) + self.finished = true + } + + func stop() { + self.actionTask?.cancel() + self.openMenu?.cancelTracking() + self.store.stopSharedSpendDashboardPublication() + self.store.sharedSpendDashboardControllerStorage = nil + self.controller.releaseStatusItemsForTesting() + } + + private func verifyTotals() { + let model = self.controller.overviewSpendDashboardModel(providers: self.enabledProviders) + XCTAssertEqual(model.groups.compactMap(\.totalTokens).reduce(0, +), self.corpus.hasAppended ? 250_000 : 240_000) + XCTAssertEqual( + model.groups.compactMap(\.totalCost).reduce(0, +), + self.corpus.hasAppended ? 0.75 : 0.72, + accuracy: 0.000_001) + if self.piEnabled { + let pi = self.widgetSnapshot?.entries.first { $0.provider == UsageProvider.pi.instanceID } + XCTAssertNotNil(pi) + XCTAssertNil(pi?.primary) + XCTAssertTrue(pi?.usageRows?.isEmpty ?? true) + XCTAssertEqual(pi?.tokenUsage?.last30DaysTokens, self.corpus.hasAppended ? 50000 : 40000) + } + } + + private static func usageSnapshot() -> UsageSnapshot { + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date(), dataConfidence: .estimated) + } +} diff --git a/Tests/CodexBarTests/PiNativeProofTests.swift b/Tests/CodexBarTests/PiNativeProofTests.swift new file mode 100644 index 0000000000..c1f4ed8fb4 --- /dev/null +++ b/Tests/CodexBarTests/PiNativeProofTests.swift @@ -0,0 +1,139 @@ +import AppKit +import SwiftUI +import WidgetKit +import XCTest +@testable import CodexBar +@testable import CodexBarCore +@testable import CodexBarWidget + +@MainActor +final class PiNativeProofTests: XCTestCase { + func test_interactiveLocalHistory() throws { + let environment = ProcessInfo.processInfo.environment + guard let path = environment["CODEXBAR_PI_NATIVE_PROOF_DIR"] else { + throw XCTSkip("Set CODEXBAR_PI_NATIVE_PROOF_DIR for signed synthetic Pi proof") + } + let output = URL(fileURLWithPath: path, isDirectory: true).resolvingSymlinksInPath() + let parent = output.deletingLastPathComponent() + let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true).resolvingSymlinksInPath() + guard SettingsStore.isRunningTests, + environment["CODEXBAR_SUPPRESS_TEST_KEYCHAIN_ACCESS"] == "1", + environment[CodexCredentialFileAccess.isolationEnvironmentKey] == "1", + environment["CODEXBAR_TEST_SESSION_FILE_ISOLATION"] == "1", + environment["CODEXBAR_ALLOW_TEST_KEYCHAIN_ACCESS"] != "1", + environment["CODEXBAR_TEST_CODEX_FILE_FIXTURES"] == nil, + parent.pathComponents.count >= 3, + home.path.hasPrefix(parent.path + "/") + else { return XCTFail("Use a contained home and credential/session isolation") } + let application = NSApplication.shared + guard application.delegate == nil else { return XCTFail("Use a standalone native test host") } + let oldRendering = StatusItemController.menuCardRenderingEnabled + let oldRefresh = StatusItemController.menuRefreshEnabled + StatusItemController.menuCardRenderingEnabled = true + StatusItemController.setMenuRefreshEnabledForTesting(true) + defer { + StatusItemController.menuCardRenderingEnabled = oldRendering + StatusItemController.setMenuRefreshEnabledForTesting(oldRefresh) + } + let session = try PiNativeProofSession(output: output) + let oldPolicy = application.activationPolicy() + let previousApplication = NSWorkspace.shared.frontmostApplication + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1180, height: 810), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false) + window.title = "CodexBar — Synthetic Pi Live Proof" + window.isReleasedWhenClosed = false + window.minSize = NSSize(width: 1100, height: 650) + window.contentView = NSHostingView(rootView: PiNativeProofView(session: session)) + session.window = window + let timer = Timer(timeInterval: 0.25, repeats: true) { _ in + MainActor.assumeIsolated { session.record() } + } + defer { + timer.invalidate() + session.stop() + window.close() + _ = application.setActivationPolicy(oldPolicy) + if NSWorkspace.shared.frontmostApplication?.processIdentifier == ProcessInfo.processInfo.processIdentifier { + previousApplication?.activate() + } + } + _ = application.setActivationPolicy(.regular) + application.finishLaunching() + window.center() + window.makeKeyAndOrderFront(nil) + application.activate(ignoringOtherApps: true) + RunLoop.main.add(timer, forMode: .common) + session.run("startup") + let deadline = Date().addingTimeInterval(1200) + while !session.finished, Date() < deadline { + if let event = application.nextEvent( + matching: .any, until: Date().addingTimeInterval(0.02), inMode: .default, dequeue: true) + { + application.sendEvent(event) + } + _ = RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.02)) + } + XCTAssertTrue(session.finished, "Click Finish before the native proof deadline") + XCTAssertNil(session.failure) + } +} + +@MainActor +private struct PiNativeProofView: View { + @Bindable var session: PiNativeProofSession + + var body: some View { + VStack(spacing: 0) { + HStack { + Button("Refresh") { self.session.run("refresh") } + Button("Toggle Pi") { self.session.run("toggle") } + Button("Pi menu") { self.session.showMenu(provider: .pi) }.disabled(!self.session.piEnabled) + Button("Overview") { self.session.showMenu(provider: nil) } + Button("Root offline") { self.session.run("offline") } + .disabled(!self.session.piEnabled || self.session.corpus.isOffline) + Button("Restore + append 10,000") { self.session.run("restore-append") } + .disabled(!self.session.piEnabled) + Spacer() + Button("Finish") { self.session.finish() } + } + .disabled(self.session.busy) + .padding(12) + HStack { + if self.session.busy { + ProgressView().controlSize(.small) + } + Text(self.session.statusText).font(.callout) + Spacer() + Text("Synthetic local files · no provider connections").foregroundStyle(.secondary) + }.padding(.horizontal, 16).padding(.bottom, 8) + Divider() + HStack(alignment: .top, spacing: 0) { + SpendDashboardPane(settings: self.session.settings, store: self.session.store) + Divider() + VStack(alignment: .leading, spacing: 12) { + Text("Pi widget view").font(.headline) + Text("AppKit host; persisted production snapshot.") + .font(.caption).foregroundStyle(.secondary) + if let snapshot = session.widgetSnapshot, + let entry = snapshot.entries.first(where: { $0.provider == UsageProvider.pi.instanceID }) + { + UsageTile(entry: entry, size: .small) { + TileHeader(provider: entry.provider, updatedAt: entry.updatedAt, size: .small) + } + .environment(\.widgetUsageShowsUsed, snapshot.usageBarsShowUsed) + .frame(width: 190, height: 190) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18)) + } + Text("Initial: 240,000 tokens · $0.72\nAfter append: 250,000 tokens · $0.75") + .font(.caption).monospacedDigit() + Text("Toggle Pi changes ownership. Refresh and relaunch reuse the same corpus and cache.") + .font(.caption).foregroundStyle(.secondary) + Spacer() + }.padding(18).frame(width: 245) + } + } + } +} diff --git a/Tests/CodexBarTests/PiProcessEnvironmentTests.swift b/Tests/CodexBarTests/PiProcessEnvironmentTests.swift new file mode 100644 index 0000000000..79825ace81 --- /dev/null +++ b/Tests/CodexBarTests/PiProcessEnvironmentTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiProcessEnvironmentTests { + @Test + func `filtering preserves unavailable and known empty process environments`() { + #expect(PiProcessEnvironment.filtered(nil) == nil) + #expect(PiProcessEnvironment.filtered([:]) == [:]) + #expect(PiProcessEnvironment.filtered(["PATH": "/synthetic/bin"]) == [:]) + #expect(PiProcessEnvironment.filtered([ + "HOME": "/synthetic/home", + "OMP_PROFILE": "work", + "OPENAI_API_KEY": "synthetic-secret", + ]) == ["HOME": "/synthetic/home", "OMP_PROFILE": "work"]) + } + + @Test + func `NUL environment parsing retains only Pi selectors and preserves their exact values`() { + let expected = [ + "HOME": "/synthetic/home", + "PI_CODING_AGENT_SESSION_DIR": "/synthetic/sessions=work", + "PI_CODING_AGENT_DIR": "relative agent", + "PI_CONFIG_DIR": ".custom-omp", + "OMP_PROFILE": "work", + "PI_PROFILE": "", + "XDG_DATA_HOME": "/synthetic/資料", + ] + let records = expected.keys.sorted().map { "\($0)=\(expected[$0] ?? "")" } + [ + "OPENAI_API_KEY=synthetic-secret", + "PATH=/synthetic/bin", + ] + let data = Data((records.joined(separator: "\0") + "\0").utf8) + + #expect(PiProcessEnvironment.parseNULSeparated(data) == expected) + #expect(PiProcessEnvironment.parseNULSeparated(Data()) == [:]) + #expect(PiProcessEnvironment.parseNULSeparated(Data([0, 0])) == [:]) + #expect(PiProcessEnvironment.parseNULSeparated(Data("PATH=/synthetic/bin\0".utf8)) == [:]) + #expect(PiProcessEnvironment.parseNULSeparated(Data("OMP_PROFILE=work\0OMP_PROFILE=work\0".utf8)) == [ + "OMP_PROFILE": "work", + ]) + } + + @Test(arguments: [ + "OMP_PROFILE=work", + "OMP_PROFILE=work\0partial", + "OMP_PROFILE\0", + "OMP_PROFILE=work\0OMP_PROFILE=personal\0", + ]) + func `incomplete or conflicting environment records remain unavailable`(_ payload: String) { + #expect(PiProcessEnvironment.parseNULSeparated(Data(payload.utf8)) == nil) + } + + @Test + func `invalid UTF8 in a selector remains unavailable without decoding unrelated values`() { + let invalidSelector = Data("OMP_PROFILE=".utf8) + Data([0xFF, 0]) + #expect(PiProcessEnvironment.parseNULSeparated(invalidSelector) == nil) + let unrelated = Data("UNRELATED=".utf8) + Data([0xFF, 0]) + #expect(PiProcessEnvironment.parseNULSeparated(unrelated) == [:]) + } + + @Test + func `Linux environment fixtures distinguish empty missing truncated and oversized reads`() throws { + let procRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("PiProcessEnvironmentTests-\(UUID().uuidString)", isDirectory: true) + let processRoot = procRoot.appendingPathComponent("101", isDirectory: true) + try FileManager.default.createDirectory(at: processRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: procRoot) } + let file = processRoot.appendingPathComponent("environ") + + try Data("OMP_PROFILE=work\0PATH=/synthetic/bin\0".utf8).write(to: file) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: procRoot) == [ + "OMP_PROFILE": "work", + ]) + try Data().write(to: file) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: procRoot) == [:]) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 102, procRoot: procRoot) == nil) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 0, procRoot: procRoot) == nil) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: -1, procRoot: procRoot) == nil) + + try Data("OMP_PROFILE=work".utf8).write(to: file) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: procRoot) == nil) + + let valueByteCount = PiProcessEnvironment.maxEnvironmentBytes - 6 + let atLimit = Data("HOME=".utf8) + Data(repeating: 120, count: valueByteCount) + Data([0]) + try atLimit.write(to: file) + let parsed = PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: procRoot) + #expect(parsed?["HOME"]?.utf8.count == valueByteCount) + let oversized = atLimit + Data([0]) + try oversized.write(to: file) + #expect(PiProcessEnvironment.readLinuxEnvironment(pid: 101, procRoot: procRoot) == nil) + #expect(PiProcessEnvironment.parseNULSeparated(oversized) == nil) + } + + @Test + func `scope keys distinguish evidence and selected values while ignoring unrelated environment`() { + #expect(PiProcessEnvironment.scopeKey(nil) != PiProcessEnvironment.scopeKey([:])) + #expect(PiProcessEnvironment.scopeKey([:]) == PiProcessEnvironment.scopeKey([ + "PATH": "/synthetic/bin", + "OPENAI_API_KEY": "synthetic-secret", + ])) + let first = ["HOME": "/synthetic/資料", "OMP_PROFILE": "work"] + let reordered = ["OMP_PROFILE": "work", "HOME": "/synthetic/資料"] + #expect(PiProcessEnvironment.scopeKey(first) == PiProcessEnvironment.scopeKey(reordered)) + #expect(PiProcessEnvironment.scopeKey(first) != PiProcessEnvironment.scopeKey([ + "HOME": "/synthetic/資料", "OMP_PROFILE": "personal", + ])) + #expect(PiProcessEnvironment.scopeKey([:]) != PiProcessEnvironment.scopeKey(["OMP_PROFILE": ""])) + #expect(PiProcessEnvironment.scopeKey([ + "HOME": "a\u{1F}OMP_PROFILE\u{1F}b", "OMP_PROFILE": "c", + ]) != PiProcessEnvironment.scopeKey([ + "HOME": "a", "OMP_PROFILE": "b\u{1F}OMP_PROFILE\u{1F}c", + ])) + } +} diff --git a/Tests/CodexBarTests/PiProcessRootEnvironmentTests.swift b/Tests/CodexBarTests/PiProcessRootEnvironmentTests.swift new file mode 100644 index 0000000000..abb402670a --- /dev/null +++ b/Tests/CodexBarTests/PiProcessRootEnvironmentTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiProcessRootEnvironmentTests { + @Test + func `process contexts retain distinct profiles for identical commands and directories`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let scanner = LocalAgentSessionScanner( + config: SessionScanConfig(maxProcessCount: 2), + processOutputProvider: { _ in + """ + 201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/omp + 202 1 Tue Jul 7 09:03:00 2026 /usr/local/bin/omp + """ + }, + cwdProvider: { _, _ in [201: env.root.path, 202: env.root.path] }, + processEnvironmentProvider: { _ in + [ + 201: ["HOME": env.root.path, "OMP_PROFILE": "work"], + 202: ["HOME": env.root.path, "OMP_PROFILE": "personal"], + ] + }) + + let contexts = await scanner.piSessionProcessContexts(environment: [ + "HOME": env.root.path, "OMP_PROFILE": "ambient", + ]) + #expect(contexts.count == 2) + #expect(Set(contexts.compactMap { $0.selectorEnvironment?["OMP_PROFILE"] }) == ["work", "personal"]) + #expect(Set(contexts.map(PiFamilySessionScanner.processRootSelectorKey)).count == 2) + } + + @Test(arguments: [false, true]) + func `synthetic process discovery distinguishes unavailable and known empty environments`( + environmentWasRead: Bool) async + { + let provider: LocalAgentSessionScanner.ProcessEnvironmentProvider? = environmentWasRead + ? { @Sendable _ in [201: [:]] } + : nil + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in "201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/pi" }, + cwdProvider: { _, _ in [201: "/synthetic/project"] }, + processEnvironmentProvider: provider) + let contexts = await scanner.piSessionProcessContexts(environment: [ + "HOME": "/scanner/home", "PI_CODING_AGENT_DIR": "/scanner/agent", + ]) + #expect(contexts.count == 1) + let expected: [String: String]? = environmentWasRead ? [:] : nil + #expect(contexts.first?.selectorEnvironment == expected) + } + + @Test + func `process environment selectors override project settings without ambient cwd replay`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let project = env.root.appendingPathComponent("project", isDirectory: true) + let selected = env.root.appendingPathComponent("selected-sessions", isDirectory: true) + let unwanted = project.appendingPathComponent("settings-sessions", isDirectory: true) + let settings = project.appendingPathComponent(".pi/settings.json") + try FileManager.default.createDirectory( + at: settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["sessionDir": unwanted.path]).write(to: settings) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: [PiSessionProcessContext( + command: "pi", + workingDirectory: project, + selectorEnvironment: [ + "HOME": env.root.path, + "PI_CODING_AGENT_SESSION_DIR": selected.path, + ])]) + + #expect(roots.contains { $0.url.path == selected.path && $0.resolutionIsComplete }) + #expect(!roots.contains { $0.url.path == unwanted.path }) + } + + @Test + func `environment selected omp profile suppresses unrelated profile discovery`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let selected = env.root.appendingPathComponent(".omp/profiles/work/sessions", isDirectory: true) + let unrelated = env.root.appendingPathComponent(".omp/profiles/personal/sessions", isDirectory: true) + try [selected, unrelated].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: [PiSessionProcessContext( + command: "omp", + workingDirectory: nil, + selectorEnvironment: ["HOME": env.root.path, "OMP_PROFILE": "work"])]) + + #expect(roots.contains { $0.url.path == selected.path && $0.resolutionIsComplete }) + #expect(!roots.contains { $0.url.path == unrelated.path }) + } + + @Test(arguments: [false, true]) + func `relative selectors require known process environment and cwd`(environmentWasRead: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let project = env.root.appendingPathComponent("project", isDirectory: true) + let selected = project.appendingPathComponent("sessions", isDirectory: true) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: [PiSessionProcessContext( + command: "pi --session-dir ./sessions", + workingDirectory: project, + selectorEnvironment: environmentWasRead ? [:] : nil)]) + + #expect(roots.contains { $0.url.path == selected.path } == environmentWasRead) + #expect(roots.contains { !$0.resolutionIsComplete } == !environmentWasRead) + } + + @Test(arguments: [false, true]) + func `only absolute argv selectors resolve without process environment or cwd`(homeRelative: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let selected = env.root.appendingPathComponent("sessions", isDirectory: true) + let selector = homeRelative ? "~/sessions" : selected.path + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: [PiSessionProcessContext( + command: "pi --session-dir \(selector)", + workingDirectory: nil)]) + + #expect(roots.contains { $0.url.path == selected.path } == !homeRelative) + #expect(roots.contains { !$0.resolutionIsComplete } == homeRelative) + } + + @Test + func `retained tilde settings keep the originating process home`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let guiHome = env.root.appendingPathComponent("gui-home", isDirectory: true) + let processHome = env.root.appendingPathComponent("process-home", isDirectory: true) + let project = env.root.appendingPathComponent("project", isDirectory: true) + let settings = project.appendingPathComponent(".pi/settings.json") + try FileManager.default.createDirectory( + at: settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data(#"{"sessionDir":"~/sessions"}"#.utf8).write(to: settings) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": guiHome.path], + baseDirectories: [guiHome], + processContexts: [PiSessionProcessContext( + command: "pi", + workingDirectory: project, + selectorEnvironment: ["HOME": processHome.path])]) + let selected = try #require(roots.first { $0.url.path == processHome.appendingPathComponent("sessions").path }) + let key = try #require(selected.retentionKeys.first { $0.hasPrefix("settings:") }) + guard case let .resolved(url, _) = PiFamilySessionScanner.retainedSettingsRootResolution( + retentionKey: key) + else { + Issue.record("Captured process HOME should resolve retained settings") + return + } + #expect(url.path == processHome.appendingPathComponent("sessions").path) + + let legacyKey = "settings:" + settings.standardizedFileURL.resolvingSymlinksInPath().path + guard case .unavailable = PiFamilySessionScanner.retainedSettingsRootResolution( + retentionKey: legacyKey) + else { + Issue.record("Legacy tilde selectors without HOME evidence must remain unresolved") + return + } + } +} diff --git a/Tests/CodexBarTests/PiProfileResolutionTests.swift b/Tests/CodexBarTests/PiProfileResolutionTests.swift new file mode 100644 index 0000000000..6ac445ad9a --- /dev/null +++ b/Tests/CodexBarTests/PiProfileResolutionTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiProfileResolutionTests { + @Test + func `pi provider resolves the selected profile direct sessions layout`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let selectedRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let unrelatedRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("personal", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: selectedRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unrelatedRoot, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: [ + "HOME": env.root.path, + "OMP_PROFILE": "work", + ], + baseDirectory: env.root) + + #expect(roots.contains { $0.url == selectedRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(!roots.contains { $0.url == unrelatedRoot.standardizedFileURL }) + } + + @Test + func `pi provider discovers profiles beneath the configured omp directory`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let selectedRoot = env.root + .appendingPathComponent(".custom-omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let unrelatedRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("personal", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: selectedRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unrelatedRoot, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: [ + "HOME": env.root.path, + "PI_CONFIG_DIR": ".custom-omp", + ], + baseDirectory: env.root) + + #expect(roots.contains { $0.url == selectedRoot.standardizedFileURL }) + #expect(!roots.contains { $0.url == unrelatedRoot.standardizedFileURL }) + } + + @Test + func `pi provider discovery keeps both profile session layouts during migration`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let profileRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + let directRoot = profileRoot.appendingPathComponent("sessions", isDirectory: true) + let legacyRoot = profileRoot + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: directRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: legacyRoot, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + + #expect(roots.contains { $0.url == directRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(roots.contains { $0.url == legacyRoot.standardizedFileURL && $0.resolutionIsComplete }) + } + + @Test + func `pi provider keeps default and xdg omp stores during migration`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let defaultRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let xdgRoot = env.root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: defaultRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: xdgRoot, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + + #expect(roots.contains { $0.url == defaultRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(roots.contains { $0.url == xdgRoot.standardizedFileURL && $0.resolutionIsComplete }) + } +} diff --git a/Tests/CodexBarTests/PiProjectOverflowTests.swift b/Tests/CodexBarTests/PiProjectOverflowTests.swift new file mode 100644 index 0000000000..c02ce40a75 --- /dev/null +++ b/Tests/CodexBarTests/PiProjectOverflowTests.swift @@ -0,0 +1,152 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PiProjectOverflowTests { + @Test(arguments: [false, true]) + func `native and Pi project model overflow remains unknown after later valid rows`(sameDay: Bool) async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let firstDay = try #require(calendar.date(from: DateComponents( + year: 2026, month: 4, day: 6, hour: 12))) + let secondDay = try #require(calendar.date(byAdding: .day, value: 1, to: firstDay)) + let lastDay = try #require(calendar.date(byAdding: .day, value: 2, to: firstDay)) + let now = lastDay.addingTimeInterval(60) + let large = 1 << 62 + let model = "fictional-shared-project-model" + let controlModel = "fictional-independent-project-model" + try self.writeNative(env, date: firstDay, name: "large", model: model, input: large) + try self.writeNative(env, date: lastDay, name: "later", model: model, input: 7) + try self.writeNative(env, date: lastDay, name: "control", model: controlModel, input: 3) + let piDay = sameDay ? firstDay : secondDay + _ = try env.writePiSessionFile( + relativePath: "project-overflow.jsonl", + contents: env.jsonl([[ + "type": "message", "id": "pi-large", "timestamp": env.isoString(for: piDay), + "message": [ + "role": "assistant", "provider": "openai-codex", "model": model, + "usage": ["input": large, "output": 0, "totalTokens": large], + ], + ]])) + let omp = env.root.appendingPathComponent("empty-omp", isDirectory: true) + try FileManager.default.createDirectory(at: omp, withIntermediateDirectories: true) + let environment = ["HOME": env.root.path] + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite"), + calendar: calendar) + options.refreshMinIntervalSeconds = 0 + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: omp, + cacheRoot: env.cacheRoot, + calendar: calendar, + refreshMinIntervalSeconds: 0, + environment: environment) + + let native = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: environment, + now: now, + historyDays: 3, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: false, + scannerOptions: options, + piScannerOptions: piOptions) + #expect(native.snapshot.historyCoverageIsEstablished) + #expect(native.snapshot.last30DaysTokens == large + 10) + #expect(native.snapshot.projects.count == 1) + #expect(native.snapshot.projects.first?.path == nil) + let pi = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: firstDay, + until: now, + now: now, + options: piOptions, + checkCancellation: nil) + #expect(pi.isComplete) + #expect(pi.report.summary?.totalTokens == large) + #expect(pi.report.summary?.totalCostUSD == nil) + + let fresh = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: environment, + now: now, + historyDays: 3, + allowPricingRefresh: false, + refreshPricingInBackground: false, + includePiSessions: true, + scannerOptions: options, + piScannerOptions: piOptions) + try self.expectOverflowProjection(fresh.snapshot, model: model, controlModel: controlModel) + + let cachedValue = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: now.addingTimeInterval(1), + historyDays: 3, + includePiSessions: true, + scannerOptions: options, + environment: environment, + piScannerOptions: piOptions) + let cached = try #require(cachedValue) + try self.expectOverflowProjection(cached.snapshot, model: model, controlModel: controlModel) + #expect(cached.snapshot.projects == fresh.snapshot.projects) + } + + private func expectOverflowProjection( + _ snapshot: CostUsageTokenSnapshot, + model: String, + controlModel: String) throws + { + #expect(snapshot.sessionTokens == 10) + #expect(snapshot.last30DaysTokens == nil) + let project = try #require(snapshot.projects.first { $0.path == nil }) + #expect(project.name == CostUsageProjectBreakdown.unknownProjectName) + #expect(project.totalTokens == nil) + let overflowed = try #require(project.modelBreakdowns?.first { $0.modelName == model }) + #expect(overflowed.totalTokens == nil) + let unaffected = try #require(project.modelBreakdowns?.first { $0.modelName == controlModel }) + #expect(unaffected.totalTokens == 3) + let source = try #require(project.sources.first { $0.path == nil }) + #expect(source.totalTokens == nil) + let sourceModel = try #require(source.modelBreakdowns?.first { $0.modelName == model }) + #expect(sourceModel.totalTokens == nil) + } + + private func writeNative( + _ env: CostUsageTestEnvironment, + date: Date, + name: String, + model: String, + input: Int) throws + { + // Deliberately omit cwd: both native history and Pi must meet in the unknown-project bucket. + _ = try env.writeCodexSessionFile( + day: date, + filename: "project-overflow-\(name).jsonl", + contents: env.jsonl([ + [ + "type": "session_meta", "timestamp": env.isoString(for: date), + "payload": ["id": "synthetic-project-overflow-\(name)"], + ], + [ + "type": "turn_context", "timestamp": env.isoString(for: date), + "payload": ["model": model], + ], + [ + "type": "event_msg", "timestamp": env.isoString(for: date.addingTimeInterval(1)), + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "last_token_usage": ["input_tokens": input, "cached_input_tokens": 0, "output_tokens": 0], + ], + ], + ], + ])) + } +} diff --git a/Tests/CodexBarTests/PiProviderTests.swift b/Tests/CodexBarTests/PiProviderTests.swift new file mode 100644 index 0000000000..1044d5ab16 --- /dev/null +++ b/Tests/CodexBarTests/PiProviderTests.swift @@ -0,0 +1,886 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiProviderTests { + @Test + func `pi provider honors the configured session directory`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 5) + let configuredRoot = env.root.appendingPathComponent("configured-pi-sessions", isDirectory: true) + try FileManager.default.createDirectory(at: configuredRoot, withIntermediateDirectories: true) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 7, "output": 5, "totalTokens": 12], + ], + ] + let fileURL = configuredRoot.appendingPathComponent( + "2026-04-05T10-00-00-000Z_configured.jsonl", + isDirectory: false) + try env.jsonl([entry]).write(to: fileURL, atomically: true, encoding: .utf8) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + environment: [ + "HOME": env.root.path, + "PI_CODING_AGENT_SESSION_DIR": configuredRoot.path, + ], + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot)) + + #expect(snapshot.sessionTokens == 12) + #expect(snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi provider honors the selected omp profile root`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 6) + let configuredRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: configuredRoot, withIntermediateDirectories: true) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 8, "output": 4, "totalTokens": 12], + ], + ] + let fileURL = configuredRoot.appendingPathComponent( + "2026-04-06T10-00-00-000Z_omp.jsonl", + isDirectory: false) + try env.jsonl([entry]).write(to: fileURL, atomically: true, encoding: .utf8) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + environment: [ + "HOME": env.root.path, + "OMP_PROFILE": "work", + ], + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot)) + + #expect(snapshot.sessionTokens == 12) + #expect(snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi profile selection does not discover unrelated omp profiles`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let selectedRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let unrelatedRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("personal", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: selectedRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: unrelatedRoot, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: [ + "HOME": env.root.path, + "PI_PROFILE": "work", + ], + baseDirectory: env.root) + let selectedURL = selectedRoot.standardizedFileURL + let unrelatedURL = unrelatedRoot.standardizedFileURL + + #expect(roots.contains { $0.url == selectedURL }) + #expect(!roots.contains { $0.url == unrelatedURL }) + } + + @Test + func `pi cost roots resolve project settings for every correlated working directory`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let firstProject = env.root.appendingPathComponent("first-project", isDirectory: true) + let secondProject = env.root.appendingPathComponent("second-project", isDirectory: true) + let firstRoot = env.root.appendingPathComponent("first-sessions", isDirectory: true) + let secondRoot = env.root.appendingPathComponent("second-sessions", isDirectory: true) + for directory in [firstProject, secondProject, firstRoot, secondRoot] { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + for (project, sessionRoot) in [(firstProject, firstRoot), (secondProject, secondRoot)] { + let settings = project.appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + try FileManager.default.createDirectory( + at: settings.deletingLastPathComponent(), + withIntermediateDirectories: true) + let value = "{\"sessionDir\":\"\(sessionRoot.path)\"}" + try Data(value.utf8).write(to: settings) + } + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [firstProject, secondProject]) + + #expect(roots.contains { $0.url == firstRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(roots.contains { $0.url == secondRoot.standardizedFileURL && $0.resolutionIsComplete }) + } + + @Test + func `pi working directories follow live pi processes`() async { + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + """ + 201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/pi --project alpha + 202 1 Tue Jul 7 09:03:00 2026 /usr/local/bin/pi --project beta + 203 1 Wed Jul 8 09:03:00 2026 /usr/local/bin/claude + """ + }, + cwdProvider: { pids, _ in + Dictionary(uniqueKeysWithValues: pids.compactMap { pid in + switch pid { + case 201: (pid, "/projects/alpha") + case 202: (pid, "/projects/beta") + default: nil + } + }) + }) + + let directories = await scanner.piWorkingDirectories(environment: [:]) + + #expect(directories.map(\.path) == ["/projects/alpha", "/projects/beta"]) + } + + @Test + func `pi process contexts keep an absolute session selector when cwd is unavailable`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let sessionRoot = env.root.appendingPathComponent("absolute-sessions", isDirectory: true) + try FileManager.default.createDirectory(at: sessionRoot, withIntermediateDirectories: true) + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + "201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/pi --session-dir \(sessionRoot.path)" + }, + cwdProvider: { _, _ in [:] }) + + let contexts = await scanner.piSessionProcessContexts(environment: ["HOME": env.root.path]) + let context = try #require(contexts.first) + #expect(context.workingDirectory == nil) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + processContexts: contexts) + #expect(roots.contains { + $0.url == sessionRoot.standardizedFileURL && + $0.resolutionIsComplete && + $0.preserveAfterProcessExit + }) + } + + @Test + func `xdg data home fallback keeps default omp root known empty`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let xdgDataHome = env.root.appendingPathComponent("xdg", isDirectory: true) + try FileManager.default.createDirectory(at: xdgDataHome, withIntermediateDirectories: true) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: [ + "HOME": env.root.path, + "XDG_DATA_HOME": xdgDataHome.path, + ], + baseDirectory: env.root) + let defaultOMPRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .standardizedFileURL + let ompRoot = try #require(roots.first { $0.url.path == defaultOMPRoot.path }) + #expect(ompRoot.missingIsKnownEmpty) + #expect(ompRoot.resolutionIsComplete) + } + + @Test + func `empty auto discovered omp profiles do not make cost roots incomplete`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let emptyProfile = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("empty", isDirectory: true) + try FileManager.default.createDirectory(at: emptyProfile, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + let emptyProfileSessions = emptyProfile + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .standardizedFileURL + + #expect(!roots.contains { $0.url == emptyProfileSessions }) + let defaultOMPRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .standardizedFileURL + let ompRoot = try #require(roots.first { $0.url == defaultOMPRoot }) + #expect(ompRoot.missingIsKnownEmpty) + #expect(ompRoot.resolutionIsComplete) + } + + @Test + func `omp profile discovery ignores non-directory entries`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let profilesDirectory = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory(at: profilesDirectory, withIntermediateDirectories: true) + try Data("profile metadata".utf8).write( + to: profilesDirectory.appendingPathComponent("README", isDirectory: false)) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + let defaultOMPRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .standardizedFileURL + let ompRoot = try #require(roots.first { $0.url == defaultOMPRoot }) + #expect(ompRoot.resolutionIsComplete) + #expect(!roots.contains { $0.url.path == "/.codexbar-unresolved-omp" }) + } + + @Test + func `failed omp profile discovery keeps cost roots incomplete`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let profilesDirectory = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + try FileManager.default.createDirectory( + at: profilesDirectory.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("temporarily unavailable".utf8).write(to: profilesDirectory) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + let unresolvedOMPRoot = try #require(roots.first { + $0.url.path == "/.codexbar-unresolved-omp" + }) + + #expect(!unresolvedOMPRoot.missingIsKnownEmpty) + #expect(!unresolvedOMPRoot.resolutionIsComplete) + } + + @Test + func `failed pi settings resolution keeps cost roots incomplete`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let projectSettings = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + let globalSettings = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("settings.json") + try FileManager.default.createDirectory( + at: projectSettings.deletingLastPathComponent(), + withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: globalSettings.deletingLastPathComponent(), + withIntermediateDirectories: true) + try Data("not json".utf8).write(to: projectSettings) + try Data(#"{"sessionDir":"custom-pi-sessions"}"#.utf8).write(to: globalSettings) + + let rootsWithMalformedProjectSettings = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + let unresolvedPiRoot = try #require(rootsWithMalformedProjectSettings.first { + $0.url.path == "/.codexbar-unresolved-pi" + }) + #expect(!unresolvedPiRoot.missingIsKnownEmpty) + #expect(!unresolvedPiRoot.resolutionIsComplete) + #expect(!rootsWithMalformedProjectSettings.contains { + $0.url.path.hasSuffix("custom-pi-sessions") + }) + + try FileManager.default.removeItem(at: projectSettings) + try Data("not json".utf8).write(to: globalSettings) + let rootsWithMalformedGlobalSettings = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + let unresolvedGlobalPiRoot = try #require(rootsWithMalformedGlobalSettings.first { + $0.url.path == "/.codexbar-unresolved-pi" + }) + #expect(!unresolvedGlobalPiRoot.missingIsKnownEmpty) + #expect(!unresolvedGlobalPiRoot.resolutionIsComplete) + } + + @Test + func `pi provider exposes an independent aggregate token snapshot`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let entries: [[String: Any]] = [ + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ], + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 4, "output": 1, "totalTokens": 5], + ], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-04-02T10-00-00-000Z_aggregate.jsonl", + contents: env.jsonl(entries)) + + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: piOptions) + + #expect(snapshot.sessionTokens == 30) + #expect(snapshot.last30DaysTokens == 30) + #expect(snapshot.historyCoverageIsEstablished) + #expect(snapshot.costProvenance == .listPriceEstimate) + + let cached = PiSessionCostScanner.loadCachedDailyReport( + provider: .pi, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot) + #expect(cached?.summary?.totalTokens == 30) + } + + @Test + func `pi provider keeps recognized assistant rows incomplete without valid timestamps`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 3) + let entries: [[String: Any]] = [ + [ + "type": "message", + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ], + [ + "type": "message", + "timestamp": true, + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "usage": ["input": 4, "output": 1, "totalTokens": 5], + ], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-04-03T10-00-00-000Z-invalid-timestamps.jsonl", + contents: env.jsonl(entries)) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect((snapshot.sessionTokens ?? 0) == 0) + #expect(!snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi provider accepts numeric assistant timestamps`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let entry: [String: Any] = [ + "type": "message", + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 7, "output": 5, "totalTokens": 12], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-04-04T10-00-00-000Z-numeric-timestamp.jsonl", + contents: env.jsonl([entry])) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(snapshot.sessionTokens == 12) + #expect(snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi provider descriptor is registered for token history`() { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .pi) + + #expect(descriptor.metadata.displayName == "Pi") + #expect(descriptor.tokenCost.supportsTokenCost) + #expect(descriptor.tokenCost.supportsTokenSnapshot) + #expect(descriptor.metadata.defaultEnabled == false) + #expect(descriptor.cli.supportsCostCommand) + } + + @Test + func `pi provider does not establish history when a configured root is missing`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let missingRoot = env.root.appendingPathComponent("not-mounted") + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + piSessionsRoot: missingRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(!snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi provider does not establish history when a configured root cannot be inspected`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let unreadableRoot = env.root.appendingPathComponent("not-a-session-directory") + try Data("not a directory".utf8).write(to: unreadableRoot) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + piSessionsRoot: unreadableRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(!snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi provider keeps the last report while a refresh root is unavailable`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-04-02T10-00-00-000Z_existing.jsonl", + contents: env.jsonl([entry])) + let initialOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let initial = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: initialOptions) + #expect(initial.sessionTokens == 25) + #expect(initial.historyCoverageIsEstablished) + + let unavailableRoot = env.root.appendingPathComponent("temporarily-unavailable") + try Data("not a directory".utf8).write(to: unavailableRoot) + let refreshed = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + now: day.addingTimeInterval(1), + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + piSessionsRoot: unavailableRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0)) + + #expect(refreshed.sessionTokens == 25) + #expect(!refreshed.historyCoverageIsEstablished) + #expect(refreshed.updatedAt == initial.updatedAt) + } + + @Test + func `inclusive pi usage propagates incomplete coverage and cache freshness`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 9) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-04-09T10-00-00-000Z_inclusive.jsonl", + contents: env.jsonl([entry])) + + let scannerOptions = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot) + let piOptions = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let initial = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: true, + scannerOptions: scannerOptions, + piScannerOptions: piOptions) + #expect(initial.last30DaysTokens == 25) + #expect(initial.historyCoverageIsEstablished) + + try FileManager.default.removeItem(at: env.piSessionsRoot) + try Data("temporarily unavailable".utf8).write(to: env.piSessionsRoot) + let refreshed = try await CostUsageFetcher.loadTokenSnapshot( + provider: .claude, + now: day.addingTimeInterval(1), + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: true, + scannerOptions: scannerOptions, + piScannerOptions: piOptions) + + #expect(refreshed.last30DaysTokens == 25) + #expect(!refreshed.historyCoverageIsEstablished) + #expect(refreshed.updatedAt == initial.updatedAt) + } + + @Test + func `pi scanner does not combine old and new roots after an incomplete refresh`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 10) + let firstRoot = env.root.appendingPathComponent("first-pi-root", isDirectory: true) + let secondRoot = env.root.appendingPathComponent("second-pi-root", isDirectory: true) + try FileManager.default.createDirectory(at: firstRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondRoot, withIntermediateDirectories: true) + + let firstEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ] + try env.jsonl([firstEntry]).write( + to: firstRoot.appendingPathComponent("2026-04-10T10-00-00-000Z_first.jsonl"), + atomically: true, + encoding: .utf8) + let initial = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: firstRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0), + checkCancellation: nil) + #expect(initial.isComplete) + #expect(initial.report.summary?.totalTokens == 25) + let firstScope = try #require(initial.scopeFingerprint) + + let secondEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 7, "output": 3, "totalTokens": 10], + ], + ] + try env.jsonl([secondEntry]).write( + to: secondRoot.appendingPathComponent("2026-04-10T10-00-00-000Z_a-valid.jsonl"), + atomically: true, + encoding: .utf8) + try "{malformed}\n".write( + to: secondRoot.appendingPathComponent("2026-04-10T10-00-00-000Z_z-malformed.jsonl"), + atomically: true, + encoding: .utf8) + + let refreshed = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + piSessionsRoot: secondRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0), + checkCancellation: nil) + + #expect(!refreshed.isComplete) + #expect(refreshed.report.summary?.totalTokens == 25) + #expect(refreshed.scopeFingerprint == firstScope) + #expect(refreshed.scopeFingerprint != PiSessionCostScanner + .scopeFingerprint(options: PiSessionCostScanner.Options( + piSessionsRoot: secondRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0))) + } + + @Test + func `pi provider keeps cached usage when an explicit omp root cannot resolve`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) + let defaultPiRoot = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: defaultPiRoot, withIntermediateDirectories: true) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ] + try env.jsonl([entry]).write( + to: defaultPiRoot.appendingPathComponent( + "2026-04-02T10-00-00-000Z_default.jsonl", + isDirectory: false), + atomically: true, + encoding: .utf8) + + let baseEnvironment = ["HOME": env.root.path] + let initial = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600, + environment: baseEnvironment, + workingDirectory: env.root), + checkCancellation: nil) + #expect(initial.isComplete) + #expect(initial.report.data.first?.totalTokens == 25) + + for selection in [ + ["HOME": env.root.path, "OMP_PROFILE": "bad/profile"], + ["HOME": env.root.path, "PI_CONFIG_DIR": "/outside"], + ] { + let refreshed = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600, + environment: selection, + workingDirectory: env.root), + checkCancellation: nil) + #expect(!refreshed.isComplete) + #expect(refreshed.report.data.first?.totalTokens == 25) + } + } + + @Test + func `pi provider marks a session read failure incomplete and keeps cached usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 3) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ] + let fileURL = try env.writePiSessionFile( + relativePath: "2026-04-03T10-00-00-000Z_read-failure.jsonl", + contents: env.jsonl([entry])) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let initial = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + #expect(initial.isComplete) + #expect(initial.report.data.first?.totalTokens == 25) + + let removeFile: @Sendable () -> Void = { + try? FileManager.default.removeItem(at: fileURL) + } + let refreshed = try PiSessionCostScanner.$sessionParseObserverForTesting.withValue(removeFile) { + try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day.addingTimeInterval(1), + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + forceRescan: true), + checkCancellation: nil) + } + #expect(!refreshed.isComplete) + #expect(refreshed.report.data.first?.totalTokens == 25) + } + + @Test + func `pi provider marks truncated records incomplete`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let padding = String(repeating: "x", count: 16 * 1024 * 1024 + 1024) + let oversized = "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"padding\":\"\(padding)\"}}\n" + _ = try env.writePiSessionFile( + relativePath: "2026-04-04T10-00-00-000Z_truncated.jsonl", + contents: oversized) + let result = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0), + checkCancellation: nil) + + #expect(!result.isComplete) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift b/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift index 4864578816..e5d150d2fe 100644 --- a/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift +++ b/Tests/CodexBarTests/PiSessionCostCompatibilityTests.swift @@ -187,9 +187,11 @@ struct PiSessionCostCompatibilityTests { "usage": ["input": 100, "output": 10, "totalTokens": 110], ], ]])) + let emptyOMPRoot = env.root.appendingPathComponent("empty-omp", isDirectory: true) + try FileManager.default.createDirectory(at: emptyOMPRoot, withIntermediateDirectories: true) let options = PiSessionCostScanner.Options( piSessionsRoot: env.piSessionsRoot, - ompSessionsRoot: env.root.appendingPathComponent("empty-omp"), + ompSessionsRoot: emptyOMPRoot, cacheRoot: env.cacheRoot, refreshMinIntervalSeconds: 3600) #expect(try ModelsDevCache.save( diff --git a/Tests/CodexBarTests/PiSessionCostNumericTests.swift b/Tests/CodexBarTests/PiSessionCostNumericTests.swift index 95b8c7d649..8cae9f9f51 100644 --- a/Tests/CodexBarTests/PiSessionCostNumericTests.swift +++ b/Tests/CodexBarTests/PiSessionCostNumericTests.swift @@ -4,8 +4,8 @@ import Testing struct PiSessionCostNumericTests { @Test(arguments: [false, true]) - func `numeric and string usage counts reject overflow while retaining rounding`(asString: Bool) throws { - for (input, expected) in [(Double(Int.max), 0), (Double.greatestFiniteMagnitude, 0), (12.6, 13)] { + func `invalid numeric fields keep history incomplete while valid fractions retain rounding`(asString: Bool) throws { + for (input, expected) in [(Double(Int.max), nil), (Double.greatestFiniteMagnitude, nil), (12.6, Optional(13))] { let env = try CostUsageTestEnvironment() defer { env.cleanup() } let day = try env.makeLocalNoon(year: 2026, month: 4, day: 2) @@ -21,7 +21,7 @@ struct PiSessionCostNumericTests { ], ] _ = try env.writePiSessionFile(relativePath: "bounds.jsonl", contents: env.jsonl([entry])) - let report = PiSessionCostScanner.loadDailyReport( + let result = try PiSessionCostScanner.loadDailyReportResultCancellable( provider: .codex, since: day, until: day, @@ -29,10 +29,18 @@ struct PiSessionCostNumericTests { options: .init( piSessionsRoot: env.piSessionsRoot, cacheRoot: env.cacheRoot, - refreshMinIntervalSeconds: 0)) - #expect(report.data.first?.inputTokens == (expected == 0 ? nil : expected)) - #expect(report.data.first?.outputTokens == 2) - #expect(report.summary?.totalTokens == expected + 2) + refreshMinIntervalSeconds: 0), + checkCancellation: nil) + if let expected { + #expect(result.isComplete) + #expect(result.report.data.first?.inputTokens == expected) + #expect(result.report.data.first?.outputTokens == 2) + #expect(result.report.summary?.totalTokens == expected + 2) + } else { + #expect(!result.isComplete) + #expect(result.report.data.isEmpty) + #expect(result.lastScanAt == nil) + } } } } diff --git a/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift b/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift new file mode 100644 index 0000000000..e95f15ef79 --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostRefreshReliabilityTests.swift @@ -0,0 +1,212 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PiSessionCostRefreshReliabilityTests { + @Test + func `an incomplete catalog reprice retains the previous report until every source can be repriced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 10) + let contents = try env.jsonl([ + self.row(env, day, input: 150_000, model: "gpt-5.6-sol"), + ]) + _ = try env.writePiSessionFile(relativePath: "a-good.jsonl", contents: contents) + let badFile = try env.writePiSessionFile(relativePath: "b-bad.jsonl", contents: contents) + var options = try self.options(env) + options.refreshMinIntervalSeconds = 3600 + #expect(try ModelsDevCache.save( + catalog: Self.catalog(inputCostPerMillion: 4), fetchedAt: day, cacheRoot: env.cacheRoot)) + let original = try self.scan(day, now: day, options: options) + #expect(original.isComplete) + #expect(original.report.summary?.totalTokens == 300_000) + let originalCost = try #require(original.report.summary?.totalCostUSD) + #expect(abs(originalCost - 1.2) < 0.000001) + let cacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + let savedBytes = try Data(contentsOf: cacheURL) + let oldPricingKey = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).pricingKey + + #expect(try ModelsDevCache.save( + catalog: Self.catalog(inputCostPerMillion: 8), + fetchedAt: day.addingTimeInterval(1), + cacheRoot: env.cacheRoot)) + try self.append("{broken}\n", to: badFile) + let incomplete = try self.scan(day, now: day.addingTimeInterval(2), options: options) + #expect(!incomplete.isComplete) + #expect(incomplete.report.data == original.report.data) + #expect(incomplete.report.summary == original.report.summary) + let retainedCost = try #require(incomplete.report.summary?.totalCostUSD) + #expect(abs(retainedCost - 1.2) < 0.000001) + #expect(incomplete.lastScanAt == original.lastScanAt) + #expect(incomplete.scopeFingerprint == original.scopeFingerprint) + #expect(try Data(contentsOf: cacheURL) == savedBytes) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).pricingKey == oldPricingKey) + + try contents.write(to: badFile, atomically: true, encoding: .utf8) + let recoveredAt = day.addingTimeInterval(3) + let recovered = try self.scan(day, now: recoveredAt, options: options) + #expect(recovered.isComplete) + #expect(recovered.report.summary?.totalTokens == 300_000) + let recoveredCost = try #require(recovered.report.summary?.totalCostUSD) + #expect(abs(recoveredCost - 2.4) < 0.000001) + #expect(recovered.lastScanAt == recoveredAt) + #expect(recovered.scopeFingerprint == original.scopeFingerprint) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).pricingKey != oldPricingKey) + } + + @Test(arguments: [false, true]) + func `replacement after bytes are read cannot advance full or incremental cache freshness`( + incremental: Bool) throws + { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + let first = try env.jsonl([self.row(env, day, input: 10, output: 5)]) + let changedFirst = try env.jsonl([self.row(env, day, input: 90, output: 5)]) + #expect(first.utf8.count == changedFirst.utf8.count) + let file = try env.writePiSessionFile(relativePath: "during-read.jsonl", contents: first) + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + let original = try self.scan(day, now: day, options: options) + #expect(original.isComplete) + #expect(original.report.summary?.totalTokens == 15) + let cacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + let savedBytes = try Data(contentsOf: cacheURL) + let originalIdentity = try #require(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + .files.values.first?.fileIdentity) + + let suffix = incremental ? try env.jsonl([self.row(env, day, input: 20, output: 10)]) : "" + if incremental { try self.append(suffix, to: file) } + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + let replacement = Data((changedFirst + suffix).utf8) + #expect(try Data(contentsOf: file).count == replacement.count) + let trigger = PiCostReadReplacement(file: file, replacement: replacement, modifiedAt: day) + let observer: @Sendable () -> Void = { trigger.arm() } + var raceOptions = options + raceOptions.forceRescan = !incremental + let raced = try PiSessionCostScanner.$sessionParseObserverForTesting.withValue(observer) { + try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .pi, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: raceOptions, + checkCancellation: { try trigger.check() }) + } + #expect(trigger.didReplace) + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + let device = try #require(attributes[.systemNumber] as? NSNumber).uint64Value + let inode = try #require(attributes[.systemFileNumber] as? NSNumber).uint64Value + #expect(originalIdentity != "\(device):\(inode)") + #expect(try #require(attributes[.size] as? NSNumber).intValue == replacement.count) + let modified = try #require(attributes[.modificationDate] as? Date) + #expect(Int64(modified.timeIntervalSince1970 * 1000) == Int64(day.timeIntervalSince1970 * 1000)) + #expect(!raced.isComplete) + #expect(raced.report.data == original.report.data) + #expect(raced.report.summary == original.report.summary) + #expect(raced.lastScanAt == original.lastScanAt) + #expect(raced.scopeFingerprint == original.scopeFingerprint) + #expect(try Data(contentsOf: cacheURL) == savedBytes) + + let recovered = try self.scan(day, now: day.addingTimeInterval(2), options: options) + #expect(recovered.isComplete) + #expect(recovered.report.summary?.totalTokens == (incremental ? 125 : 95)) + #expect(recovered.lastScanAt == day.addingTimeInterval(2)) + } + + private func options(_ env: CostUsageTestEnvironment) throws -> PiSessionCostScanner.Options { + let omp = env.root.appendingPathComponent("empty-omp", isDirectory: true) + try FileManager.default.createDirectory(at: omp, withIntermediateDirectories: true) + return .init( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: omp, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: ["HOME": env.root.path]) + } + + private func scan( + _ day: Date, now: Date, options: PiSessionCostScanner.Options) throws + -> PiSessionCostScanner.DailyReportResult + { + try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .pi, since: day, until: day, now: now, options: options, checkCancellation: nil) + } + + private func row( + _ env: CostUsageTestEnvironment, + _ day: Date, + input: Int, + output: Int = 0, + model: String = "gpt-5.4") -> [String: Any] + { + [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", "provider": "openai-codex", "model": model, + "usage": ["input": input, "output": output, "totalTokens": input + output], + ], + ] + } + + private func append(_ contents: String, to url: URL) throws { + let writer = try FileHandle(forWritingTo: url) + defer { try? writer.close() } + try writer.seekToEnd() + try writer.write(contentsOf: Data(contents.utf8)) + } + + private static func catalog(inputCostPerMillion: Double) throws -> ModelsDevCatalog { + let json = """ + {"openai":{"id":"openai","models":{"gpt-5.6-sol":{ + "id":"gpt-5.6-sol", + "cost":{"input":\(inputCostPerMillion),"output":30,"cache_read":0.5,"cache_write":6.25} + }}}} + """ + return try JSONDecoder().decode(ModelsDevCatalog.self, from: Data(json.utf8)) + } +} + +private final class PiCostReadReplacement: @unchecked Sendable { + private let lock = NSLock() + private let file: URL + private let replacement: Data + private let modifiedAt: Date + private var armed = false + private var checks = 0 + private var replaced = false + + init(file: URL, replacement: Data, modifiedAt: Date) { + self.file = file + self.replacement = replacement + self.modifiedAt = modifiedAt + } + + var didReplace: Bool { + self.lock.withLock { self.replaced } + } + + func arm() { + self.lock.withLock { + self.armed = true + self.checks = 0 + } + } + + func check() throws { + let shouldReplace = self.lock.withLock { + guard self.armed, !self.replaced else { return false } + self.checks += 1 + // JSONL checks once before reading and again after the first chunk is loaded. + guard self.checks == 2 else { return false } + self.armed = false + return true + } + guard shouldReplace else { return } + try self.replacement.write(to: self.file, options: .atomic) + try FileManager.default.setAttributes([.modificationDate: self.modifiedAt], ofItemAtPath: self.file.path) + self.lock.withLock { self.replaced = true } + } +} diff --git a/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift b/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift new file mode 100644 index 0000000000..a98ae83ecf --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostReliabilityTests.swift @@ -0,0 +1,316 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostReliabilityTests { + @Test + func `explicit zero token usage remains measured zero in reports and snapshots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + _ = try env.writePiSessionFile( + relativePath: "zero.jsonl", + contents: env.jsonl([self.row(env, day, id: "zero", input: 0)])) + let result = try self.scan(day, options: options) + #expect(result.isComplete) + #expect(result.report.summary?.totalTokens == 0) + #expect(result.report.data.first?.totalTokens == 0) + #expect(result.report.data.first?.modelBreakdowns?.first?.totalTokens == 0) + #expect(result.report.data.first?.requestCount == 1) + let snapshot = CostUsageFetcher.tokenSnapshot(from: result.report, now: day, historyDays: 1) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.last30DaysTokens == 0) + } + + @Test(arguments: [false, true]) + func `atomic replacement reparses the prefix even when metadata matches or the file grows`( + grows: Bool) throws + { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + let first = try env.jsonl([self.row(env, day, id: "first", input: 10, output: 5)]) + let replacement = try env.jsonl([self.row(env, day, id: "first", input: 90, output: 5)]) + #expect(first.utf8.count == replacement.utf8.count) + let file = try env.writePiSessionFile(relativePath: "replacement.jsonl", contents: first) + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + #expect(try self.scan(day, options: options).report.summary?.totalTokens == 15) + let oldIdentity = try #require(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + .files.values.first?.fileIdentity) + let suffix = grows ? try env.jsonl([self.row(env, day, id: "second", input: 20, output: 10)]) : "" + try (replacement + suffix).write(to: file, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: file.path) + let cachedReplacement = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .pi, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot, + options: options) + #expect(cachedReplacement?.isComplete == false) + #expect(cachedReplacement?.report.summary?.totalTokens == 15) + + let warmed = try self.scan(day.addingTimeInterval(1), options: options) + #expect(warmed.isComplete) + #expect(warmed.report.summary?.totalTokens == (grows ? 125 : 95)) + let newIdentity = try #require(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + .files.values.first?.fileIdentity) + #expect(newIdentity != oldIdentity) + var forcedOptions = options + forcedOptions.forceRescan = true + #expect(try self.scan(day, options: forcedOptions).report.data == warmed.report.data) + } + + @Test + func `missing file identity rejects hydration and bypasses the fresh cache debounce`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + var options = try self.options(env) + options.refreshMinIntervalSeconds = 3600 + let file = try env.writePiSessionFile( + relativePath: "migration.jsonl", + contents: env.jsonl([self.row(env, day, id: "first", input: 10)])) + #expect(try self.scan(day, options: options).isComplete) + var cache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + for path in cache.files.keys { + cache.files[path]?.fileIdentity = nil + } + PiSessionCostCacheIO.save(cache: cache, cacheRoot: env.cacheRoot) + #expect(PiSessionCostScanner.loadCachedDailyReportResult( + provider: .pi, + since: day, + until: day, + cacheRoot: env.cacheRoot, + options: options) == nil) + try env.jsonl([self.row(env, day, id: "first", input: 90)]) + .write(to: file, atomically: true, encoding: .utf8) + let rebuilt = try self.scan(day.addingTimeInterval(1), options: options) + #expect(rebuilt.isComplete) + #expect(rebuilt.report.summary?.totalTokens == 90) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).files.values + .allSatisfy { $0.fileIdentity != nil }) + } + + @Test + func `narrow cache reads validate the full stored inventory and unscoped reads stay unverified`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let oldDay = day.addingTimeInterval(-90 * 86400) + let options = try self.options(env) + let oldFile = try env.writePiSessionFile( + relativePath: "old.jsonl", + contents: env.jsonl([self.row(env, oldDay, id: "old", input: 20)])) + try FileManager.default.setAttributes([.modificationDate: oldDay], ofItemAtPath: oldFile.path) + _ = try env.writePiSessionFile( + relativePath: "current.jsonl", + contents: env.jsonl([self.row(env, day, id: "current", input: 10)])) + #expect(try self.scan(day, since: oldDay, options: options).report.summary?.totalTokens == 30) + let narrow = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .pi, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot, + options: options) + #expect(narrow?.isComplete == true) + #expect(narrow?.report.summary?.totalTokens == 10) + try FileManager.default.moveItem(at: env.piSessionsRoot, to: env.root.appendingPathComponent("offline")) + let unverified = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .pi, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot) + #expect(unverified?.isComplete == false) + #expect(unverified?.report.summary?.totalTokens == 10) + #expect(unverified?.lastScanAt == day) + } + + @Test(arguments: [false, true]) + func `unsupported backends stay incomplete across cached reads and recover after a growing rewrite`( + mixed: Bool) throws + { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + var rows = [self.row(env, day, id: "unsupported", input: 30, provider: "openrouter")] + if mixed { rows.append(self.row(env, day, id: "supported", input: 10)) } + let path = try env.writePiSessionFile(relativePath: "coverage.jsonl", contents: env.jsonl(rows)) + let first = try self.scan(day, options: options) + #expect(!first.isComplete) + #expect(first.report.summary?.totalTokens == (mixed ? 10 : nil)) + #expect(first.lastScanAt == day) + #expect(PiSessionCostScanner.loadCachedDailyReportResult( + provider: .pi, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot, + options: options, + allowEstablishedEmpty: true) == nil) + let native = try self.scan(day, options: options, provider: .codex) + #expect(native.isComplete) + #expect(native.report.summary?.totalTokens == (mixed ? 10 : nil)) + + rows[0] = self.row(env, day, id: "unsupported", input: 30) + rows.append(self.row(env, day, id: "appended", input: 7)) + try env.jsonl(rows).write(to: path, atomically: true, encoding: .utf8) + let restored = try self.scan(day.addingTimeInterval(1), options: options) + #expect(restored.isComplete) + #expect(restored.report.summary?.totalTokens == (mixed ? 47 : 37)) + } + + @Test + func `unsupported model change context survives an append without poisoning native partitions`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + let change: [String: Any] = ["type": "model_change", "provider": "google", "modelId": "gemini-2.5-pro"] + let message: [String: Any] = [ + "type": "message", "timestamp": env.isoString(for: day), + "message": ["role": "assistant", "usage": ["input": 10, "output": 1]], + ] + let file = try env.writePiSessionFile(relativePath: "context.jsonl", contents: env.jsonl([change, message])) + #expect(try !self.scan(day, options: options).isComplete) + try self.append(env.jsonl([message]), to: file) + #expect(try !self.scan(day.addingTimeInterval(1), options: options).isComplete) + #expect(try self.scan(day, options: options, provider: .claude).isComplete) + } + + @Test(arguments: [UsageProvider.pi, .claude]) + func `mixed pricing keeps known subtotal and explicit unpriced coverage`(provider: UsageProvider) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + _ = try env.writePiSessionFile(relativePath: "pricing.jsonl", contents: env.jsonl([ + self.row(env, day, id: "priced", input: 100, provider: "anthropic", model: "claude-sonnet-4-6"), + self.row(env, day, id: "unknown", input: 100, provider: "anthropic", model: "fictional-unpriced-model"), + ])) + let result = try self.scan(day, options: options, provider: provider) + let entry = try #require(result.report.data.first) + #expect(entry.totalTokens == 200) + #expect((entry.costUSD ?? 0) > 0) + #expect(entry.coverageCounts.estimated == 1) + #expect(entry.coverageCounts.unpriced == 1) + #expect(entry.coverageCounts.priced == 0) + let cached = try #require(PiSessionCostScanner.loadCachedDailyReportResult( + provider: provider, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot, + options: options)) + #expect(cached.report.data == result.report.data) + } + + @Test + func `an unrepresentable monetary amount retains valid tokens as unpriced`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + _ = try env.writePiSessionFile(relativePath: "money.jsonl", contents: env.jsonl([ + self.row(env, day, id: "large", input: 10_000_000_000_000_000), + ])) + let result = try self.scan(day, options: options) + #expect(result.isComplete) + #expect(result.report.summary?.totalTokens == 10_000_000_000_000_000) + #expect(result.report.summary?.totalCostUSD == nil) + #expect(result.report.data.first?.coverageCounts.unpriced == 1) + } + + @Test(arguments: ["row", "money", "model", "day", "boolean"]) + func `invalid numeric appends retain the previous cache and its original age`(failure: String) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = try self.options(env) + let large = 1 << 62 + let firstInput = failure == "money" ? 1_000_000_000_000_000 : failure == "row" || failure == "boolean" ? 5 : + large + let model = failure == "money" || failure == "row" || failure == "boolean" ? "gpt-5.4" : "fictional-unpriced-a" + let file = try env.writePiSessionFile(relativePath: "append.jsonl", contents: env.jsonl([ + self.row(env, day, id: "first", input: firstInput, model: model), + ])) + let first = try self.scan(day, options: options) + #expect(first.isComplete) + let cacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + let saved = try Data(contentsOf: cacheURL) + let nextDay = failure == "day" ? day.addingTimeInterval(86400) : day + let badInput: Any = failure == "boolean" ? true : failure == "row" ? large : firstInput + try self.append(env.jsonl([self.row( + env, + nextDay, + id: "second", + input: badInput, + output: failure == "row" ? large : 0, + model: failure == "model" ? "fictional-unpriced-b" : model)]), to: file) + let failed = try self.scan(nextDay, since: day, options: options) + #expect(!failed.isComplete) + #expect(failed.report.summary == first.report.summary) + #expect(failed.lastScanAt == first.lastScanAt) + #expect(try Data(contentsOf: cacheURL) == saved) + } + + private func options(_ env: CostUsageTestEnvironment) throws -> PiSessionCostScanner.Options { + let omp = env.root.appendingPathComponent("empty-omp") + try FileManager.default.createDirectory(at: omp, withIntermediateDirectories: true) + return .init( + piSessionsRoot: env.piSessionsRoot, + ompSessionsRoot: omp, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: ["HOME": env.root.path]) + } + + private func scan( + _ day: Date, + since: Date? = nil, + options: PiSessionCostScanner.Options, + provider: UsageProvider = .pi) throws -> PiSessionCostScanner.DailyReportResult + { + try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: provider, + since: since ?? day, + until: day, + now: day, + options: options, + checkCancellation: nil) + } + + private func row( + _ env: CostUsageTestEnvironment, + _ day: Date, + id: String, + input: Any, + output: Int = 0, + provider: String = "openai-codex", + model: String = "gpt-5.4") -> [String: Any] + { + [ + "type": "message", + "id": id, + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": provider, + "model": model, + "usage": ["input": input, "output": output], + ], + ] + } + + private func append(_ contents: String, to url: URL) throws { + let file = try FileHandle(forWritingTo: url) + defer { try? file.close() } + try file.seekToEnd() + try file.write(contentsOf: Data(contents.utf8)) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerOverlapTests.swift b/Tests/CodexBarTests/PiSessionCostScannerOverlapTests.swift new file mode 100644 index 0000000000..6dd5e10dc6 --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostScannerOverlapTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostScannerOverlapTests { + @Test + func `scanner deduplicates session files from overlapping roots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 18) + let sharedRoot = env.root.appendingPathComponent("shared-sessions", isDirectory: true) + let nestedRoot = sharedRoot.appendingPathComponent("nested", isDirectory: true) + try FileManager.default.createDirectory(at: nestedRoot, withIntermediateDirectories: true) + + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "usage": ["input": 7, "output": 0, "totalTokens": 7], + ], + ] + let sessionURL = nestedRoot.appendingPathComponent( + "2026-07-18T10-00-00-000Z_shared.jsonl", + isDirectory: false) + try env.jsonl([entry]).write(to: sessionURL, atomically: true, encoding: .utf8) + + let result = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .pi, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: sharedRoot, + ompSessionsRoot: nestedRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0), + checkCancellation: nil) + + #expect(result.report.data.first?.totalTokens == 7) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).files.count == 1) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests+Incremental.swift b/Tests/CodexBarTests/PiSessionCostScannerTests+Incremental.swift new file mode 100644 index 0000000000..cba6bbed31 --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostScannerTests+Incremental.swift @@ -0,0 +1,89 @@ +import Foundation +import Testing +@testable import CodexBarCore + +extension PiSessionCostScannerTests { + @Test + func `pi scanner refreshes appended file without duplicating existing usage`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let firstTimestamp = Int(day.timeIntervalSince1970 * 1000) + let secondTimestamp = Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000) + + let firstAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": firstTimestamp, + "usage": [ + "input": 10, + "output": 5, + "totalTokens": 15, + ], + ], + ] + let secondAssistant: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": secondTimestamp, + "usage": [ + "input": 20, + "output": 10, + "totalTokens": 30, + ], + ], + ] + + let url = try env.writePiSessionFile( + relativePath: "2026-04-04T10-00-00-000Z_test.jsonl", + contents: env.jsonl([firstAssistant])) + + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + let firstReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let firstExpectedCost = CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 10, + cachedInputTokens: 0, + outputTokens: 5) + #expect(firstReport.data.count == 1) + #expect(firstReport.data.first?.totalTokens == 15) + #expect(abs((firstReport.data.first?.costUSD ?? 0) - (firstExpectedCost ?? 0)) < 0.000001) + + let appendHandle = try FileHandle(forWritingTo: url) + try appendHandle.seekToEnd() + try appendHandle.write(contentsOf: Data(env.jsonl([secondAssistant]).utf8)) + try appendHandle.close() + + let secondReport = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options) + let secondExpectedCost = (firstExpectedCost ?? 0) + (CostUsagePricing.codexCostUSD( + model: "gpt-5.4", + inputTokens: 20, + cachedInputTokens: 0, + outputTokens: 10) ?? 0) + #expect(secondReport.data.count == 1) + #expect(secondReport.data.first?.totalTokens == 45) + #expect(abs((secondReport.data.first?.costUSD ?? 0) - secondExpectedCost) < 0.000001) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostScannerTests.swift b/Tests/CodexBarTests/PiSessionCostScannerTests.swift index fc27d3387c..fb8ad69e2e 100644 --- a/Tests/CodexBarTests/PiSessionCostScannerTests.swift +++ b/Tests/CodexBarTests/PiSessionCostScannerTests.swift @@ -306,87 +306,6 @@ struct PiSessionCostScannerTests { #expect(report.data.first?.modelBreakdowns?.map(\.modelName) == ["gpt-5.3-codex"]) } - @Test - func `pi scanner refreshes appended file without duplicating existing usage`() throws { - let env = try CostUsageTestEnvironment() - defer { env.cleanup() } - - let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) - let firstTimestamp = Int(day.timeIntervalSince1970 * 1000) - let secondTimestamp = Int(day.addingTimeInterval(60).timeIntervalSince1970 * 1000) - - let firstAssistant: [String: Any] = [ - "type": "message", - "timestamp": env.isoString(for: day), - "message": [ - "role": "assistant", - "provider": "openai-codex", - "model": "openai/gpt-5.4", - "timestamp": firstTimestamp, - "usage": [ - "input": 10, - "output": 5, - "totalTokens": 15, - ], - ], - ] - let secondAssistant: [String: Any] = [ - "type": "message", - "timestamp": env.isoString(for: day), - "message": [ - "role": "assistant", - "provider": "openai-codex", - "model": "gpt-5.4", - "timestamp": secondTimestamp, - "usage": [ - "input": 20, - "output": 10, - "totalTokens": 30, - ], - ], - ] - - let url = try env.writePiSessionFile( - relativePath: "2026-04-04T10-00-00-000Z_test.jsonl", - contents: env.jsonl([firstAssistant])) - - let options = PiSessionCostScanner.Options( - piSessionsRoot: env.piSessionsRoot, - cacheRoot: env.cacheRoot, - refreshMinIntervalSeconds: 0) - let firstReport = PiSessionCostScanner.loadDailyReport( - provider: .codex, - since: day, - until: day, - now: day, - options: options) - let firstExpectedCost = CostUsagePricing.codexCostUSD( - model: "gpt-5.4", - inputTokens: 10, - cachedInputTokens: 0, - outputTokens: 5) - #expect(firstReport.data.count == 1) - #expect(firstReport.data.first?.totalTokens == 15) - #expect(abs((firstReport.data.first?.costUSD ?? 0) - (firstExpectedCost ?? 0)) < 0.000001) - - try env.jsonl([firstAssistant, secondAssistant]).write(to: url, atomically: true, encoding: .utf8) - - let secondReport = PiSessionCostScanner.loadDailyReport( - provider: .codex, - since: day, - until: day, - now: day, - options: options) - let secondExpectedCost = (firstExpectedCost ?? 0) + (CostUsagePricing.codexCostUSD( - model: "gpt-5.4", - inputTokens: 20, - cachedInputTokens: 0, - outputTokens: 10) ?? 0) - #expect(secondReport.data.count == 1) - #expect(secondReport.data.first?.totalTokens == 45) - #expect(abs((secondReport.data.first?.costUSD ?? 0) - secondExpectedCost) < 0.000001) - } - @Test func `pi scanner ignores explicit unsupported provider even with fallback context`() throws { let env = try CostUsageTestEnvironment() @@ -448,7 +367,7 @@ struct PiSessionCostScannerTests { } @Test - func `pi scanner force rescan bypasses stale same size metadata cache`() throws { + func `pi scanner force rescan bypasses unchanged metadata after an in-place rewrite`() throws { let env = try CostUsageTestEnvironment() defer { env.cleanup() } @@ -516,7 +435,7 @@ struct PiSessionCostScannerTests { Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs))) PiSessionCostCacheIO.save(cache: releasedCache, cacheRoot: env.cacheRoot) - try secondContents.write(to: url, atomically: true, encoding: .utf8) + try secondContents.write(to: url, atomically: false, encoding: .utf8) try FileManager.default.setAttributes([.modificationDate: stableModifiedAt], ofItemAtPath: url.path) let replacedModifiedAt = try #require( FileManager.default.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) @@ -764,8 +683,8 @@ struct PiSessionCostScannerTests { #expect(FileManager.default.fileExists(atPath: newCacheURL.path)) let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCacheURL.lastPathComponent == "pi-sessions-v8.json") - #expect(newCache.version == 8) + #expect(newCacheURL.lastPathComponent == "pi-sessions-v9.json") + #expect(newCache.version == 9) #expect(rebuilt?.usageSampleCount == 1) #expect(rebuilt?.costSampleCount == 1) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) @@ -873,11 +792,120 @@ struct PiSessionCostScannerTests { let newCache = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) let rebuilt = newCache.daysByProvider[UsageProvider.codex.rawValue]?[dayKey]?[model] - #expect(newCache.version == 8) + #expect(newCache.version == 9) #expect(rebuilt?.costNanos == Int64((expectedCost * 1_000_000_000).rounded())) } } +extension PiSessionCostScannerTests { + @Test + func `pi scanner invalidates the debounce cache when session roots change`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 4) + let firstRoot = env.root.appendingPathComponent("pi-sessions-first", isDirectory: true) + let secondRoot = env.root.appendingPathComponent("pi-sessions-second", isDirectory: true) + try FileManager.default.createDirectory(at: firstRoot, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondRoot, withIntermediateDirectories: true) + + func writeAssistant(to root: URL, input: Int, output: Int) throws { + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": input, "output": output, "totalTokens": input + output], + ], + ] + let fileURL = root.appendingPathComponent( + "2026-04-04T10-00-00-000Z_root.jsonl", + isDirectory: false) + try env.jsonl([entry]).write(to: fileURL, atomically: true, encoding: .utf8) + } + + try writeAssistant(to: firstRoot, input: 10, output: 5) + try writeAssistant(to: secondRoot, input: 20, output: 10) + + let first = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: firstRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600)) + #expect(first.data.first?.totalTokens == 15) + + let second = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + piSessionsRoot: secondRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600)) + #expect(second.data.first?.totalTokens == 30) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).sessionRootsFingerprint != nil) + } + + @Test + func `pi scanner marks malformed records and unfinished tails incomplete`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 5) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 20, "output": 5, "totalTokens": 25], + ], + ] + let fileURL = try env.writePiSessionFile( + relativePath: "2026-04-05T10-00-00-000Z_malformed.jsonl", + contents: env.jsonl([entry])) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + + let initial = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: options, + checkCancellation: nil) + #expect(initial.isComplete) + #expect(initial.report.data.first?.totalTokens == 25) + + let handle = try FileHandle(forWritingTo: fileURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data("{\"type\":\n".utf8)) + try handle.close() + + let refreshed = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options, + checkCancellation: nil) + #expect(!refreshed.isComplete) + #expect(refreshed.report.data.first?.totalTokens == 25) + } +} + extension PiSessionCostScannerTests { @Test func `pi scanner uses historical GPT-5_6 rates before July 2026 cutoff`() throws { diff --git a/Tests/CodexBarTests/PiSessionCostScopeTests.swift b/Tests/CodexBarTests/PiSessionCostScopeTests.swift new file mode 100644 index 0000000000..814a33646a --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostScopeTests.swift @@ -0,0 +1,59 @@ +import Foundation +import Testing +@testable import CodexBarCore + +@Suite(.serialized) +struct PiSessionCostScopeTests { + @Test + func `cached pi report rejects a changed root scope`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 12) + let firstRoot = env.root.appendingPathComponent("pi-first", isDirectory: true) + let secondRoot = env.root.appendingPathComponent("pi-second", isDirectory: true) + try [firstRoot, secondRoot].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 3, "output": 2, "totalTokens": 5], + ], + ] + try env.jsonl([entry]).write( + to: firstRoot.appendingPathComponent("2026-04-12T10-00-00-000Z_scope.jsonl"), + atomically: true, + encoding: .utf8) + + let firstOptions = PiSessionCostScanner.Options( + piSessionsRoot: firstRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0) + _ = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: firstOptions) + + let secondOptions = PiSessionCostScanner.Options( + piSessionsRoot: secondRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600) + let cached = PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: day, + until: day, + now: day, + cacheRoot: env.cacheRoot, + calendar: secondOptions.calendar, + options: secondOptions) + #expect(cached == nil) + } +} diff --git a/Tests/CodexBarTests/PiSessionCostV8UpgradeTests.swift b/Tests/CodexBarTests/PiSessionCostV8UpgradeTests.swift new file mode 100644 index 0000000000..20ad09182a --- /dev/null +++ b/Tests/CodexBarTests/PiSessionCostV8UpgradeTests.swift @@ -0,0 +1,270 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostV8UpgradeTests { + @Test + func `released v8 reparses a recent cache despite unchanged transcript metadata`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let fixture = try Self.seedReleasedV8(in: env) + let replacement = try Self.transcript(in: env, day: fixture.day, input: 20) + #expect(Int64(replacement.utf8.count) == fixture.size) + try replacement.write(to: fixture.sessionURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.modificationDate: fixture.modifiedAt], + ofItemAtPath: fixture.sessionURL.path) + let attributes = try FileManager.default.attributesOfItem(atPath: fixture.sessionURL.path) + #expect(try #require((attributes[.size] as? NSNumber)?.int64Value) == fixture.size) + let modifiedAt = try #require(attributes[.modificationDate] as? Date) + #expect(Int64(modifiedAt.timeIntervalSince1970 * 1000) == + Int64(fixture.modifiedAt.timeIntervalSince1970 * 1000)) + #expect(Self.cachedReport(fixture, now: fixture.day.addingTimeInterval(1)) == nil) + + let counter = PiV8UpgradeParseCounter() + let observer: @Sendable () -> Void = { counter.increment() } + let now = fixture.day.addingTimeInterval(1) + let result = try PiSessionCostScanner.$sessionParseObserverForTesting.withValue(observer) { + try Self.scan(fixture, now: now) + } + + #expect(counter.value == 1) + #expect(result.isComplete) + #expect(result.report.summary?.totalTokens == 25) + #expect(result.lastScanAt == now) + #expect(result.scopeFingerprint == PiSessionCostScanner.scopeFingerprint(options: fixture.options)) + let rebuilt = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(rebuilt.version == 9) + #expect(rebuilt.lastScanUnixMs == Int64(now.timeIntervalSince1970 * 1000)) + #expect(Set(rebuilt.files.keys) == [Self.canonicalPath(fixture.sessionURL)]) + #expect(rebuilt.files.values.first?.parsedBytes == fixture.size) + #expect(try Data(contentsOf: fixture.cacheURL) == fixture.cacheBytes) + #expect(Self.cachedReport(fixture, now: now)?.report.summary?.totalTokens == 25) + } + + @Test + func `released v8 stays untouched while a configured root is unavailable and rebuilds on recovery`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let fixture = try Self.seedReleasedV8(in: env) + let offlineRoot = env.root.appendingPathComponent("temporarily-offline", isDirectory: true) + try FileManager.default.moveItem(at: env.piSessionsRoot, to: offlineRoot) + let v9URL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + let failedAt = fixture.day.addingTimeInterval(1) + #expect(Self.cachedReport(fixture, now: failedAt) == nil) + + let unavailable = try Self.scan(fixture, now: failedAt) + #expect(!unavailable.isComplete) + #expect(unavailable.report.data.isEmpty) + #expect(unavailable.report.summary == nil) + #expect(unavailable.lastScanAt == nil) + #expect(unavailable.scopeFingerprint == nil) + #expect(!FileManager.default.fileExists(atPath: v9URL.path)) + #expect(try Data(contentsOf: fixture.cacheURL) == fixture.cacheBytes) + #expect(Self.cachedReport(fixture, now: failedAt) == nil) + + try FileManager.default.moveItem(at: offlineRoot, to: env.piSessionsRoot) + let recoveredAt = fixture.day.addingTimeInterval(2) + let recovered = try Self.scan(fixture, now: recoveredAt) + #expect(recovered.isComplete) + #expect(recovered.report.summary?.totalTokens == 15) + #expect(recovered.lastScanAt == recoveredAt) + #expect(recovered.scopeFingerprint == PiSessionCostScanner.scopeFingerprint(options: fixture.options)) + #expect(FileManager.default.fileExists(atPath: v9URL.path)) + #expect(try Data(contentsOf: fixture.cacheURL) == fixture.cacheBytes) + let hydrated = try #require(Self.cachedReport(fixture, now: recoveredAt.addingTimeInterval(1))) + #expect(hydrated.report.summary?.totalTokens == 15) + #expect(hydrated.lastScanAt == recoveredAt) + #expect(hydrated.scopeFingerprint == recovered.scopeFingerprint) + } + + @Test(arguments: [false, true]) + func `released v8 root A is never attributed to replacement root B`(incompleteFirst: Bool) throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let fixture = try Self.seedReleasedV8(in: env) + let rootB = env.root.appendingPathComponent("replacement-sessions", isDirectory: true) + try FileManager.default.createDirectory(at: rootB, withIntermediateDirectories: true) + let sessionB = rootB.appendingPathComponent(fixture.sessionURL.lastPathComponent) + try Self.transcript(in: env, day: fixture.day, input: 30) + .write(to: sessionB, atomically: true, encoding: .utf8) + var optionsB = fixture.options + optionsB.piSessionsRoot = rootB + let expectedScopeB = PiSessionCostScanner.scopeFingerprint(options: optionsB) + #expect(Self.cachedReport(fixture, now: fixture.day, options: optionsB) == nil) + + if incompleteFirst { + let malformedURL = rootB.appendingPathComponent("2026-09-19T12-00-00-000Z_malformed.jsonl") + try "{malformed}\n".write(to: malformedURL, atomically: true, encoding: .utf8) + let incomplete = try Self.scan(fixture, now: fixture.day.addingTimeInterval(1), options: optionsB) + #expect(!incomplete.isComplete) + #expect(incomplete.report.data.isEmpty) + #expect(incomplete.report.summary == nil) + #expect(incomplete.lastScanAt == nil) + #expect(incomplete.scopeFingerprint == nil) + #expect(!FileManager.default.fileExists( + atPath: PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot).path)) + #expect(try Data(contentsOf: fixture.cacheURL) == fixture.cacheBytes) + #expect(Self.cachedReport(fixture, now: fixture.day, options: optionsB) == nil) + try FileManager.default.removeItem(at: malformedURL) + } + + let completedAt = fixture.day.addingTimeInterval(2) + let result = try Self.scan(fixture, now: completedAt, options: optionsB) + #expect(result.isComplete) + #expect(result.report.summary?.totalTokens == 35) + #expect(result.lastScanAt == completedAt) + #expect(result.scopeFingerprint == expectedScopeB) + let rebuilt = PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot) + #expect(Set(rebuilt.files.keys) == [Self.canonicalPath(sessionB)]) + #expect(rebuilt.files[Self.canonicalPath(fixture.sessionURL)] == nil) + #expect(rebuilt.sessionRootsFingerprint == expectedScopeB) + #expect(try Data(contentsOf: fixture.cacheURL) == fixture.cacheBytes) + #expect(Self.cachedReport(fixture, now: completedAt, options: optionsB)? + .report.summary?.totalTokens == 35) + #expect(Self.cachedReport(fixture, now: completedAt) == nil) + } + + private struct Fixture { + let day: Date + let sessionURL: URL + let modifiedAt: Date + let size: Int64 + let cacheURL: URL + let cacheBytes: Data + let options: PiSessionCostScanner.Options + } + + private static func seedReleasedV8(in env: CostUsageTestEnvironment) throws -> Fixture { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let day = try #require(ISO8601DateFormatter().date(from: "2026-09-19T12:00:00Z")) + let range = CostUsageScanner.CostUsageDayRange(since: day, until: day, calendar: calendar) + let sessionURL = try env.writePiSessionFile( + relativePath: "2026-09-19T12-00-00-000Z_upgrade.jsonl", + contents: Self.transcript(in: env, day: day, input: 10)) + try FileManager.default.setAttributes([.modificationDate: day], ofItemAtPath: sessionURL.path) + let attributes = try FileManager.default.attributesOfItem(atPath: sessionURL.path) + let modifiedAt = try #require(attributes[.modificationDate] as? Date) + let size = try #require((attributes[.size] as? NSNumber)?.int64Value) + + // v0.62.0 (4b3ed1a2a49a) JSON shape. Do not encode today's structs with version 8: + // the released artifact had no root fingerprint or unsupported-history provenance fields. + let usage: [String: Any] = [ + "inputTokens": 10, + "cacheReadTokens": 0, + "cacheWriteTokens": 0, + "outputTokens": 5, + "totalTokens": 15, + "costNanos": 100_000, + "costSampleCount": 1, + "usageSampleCount": 1, + ] + let contributions: [String: Any] = ["codex": [range.sinceKey: ["gpt-5.4": usage]]] + let file: [String: Any] = [ + "mtimeUnixMs": Int64(modifiedAt.timeIntervalSince1970 * 1000), + "size": size, + "parsedBytes": size, + "contributions": contributions, + "unkeyedContributions": contributions, + "entryUsages": [String: Any](), + ] + let object: [String: Any] = [ + "version": 8, + "lastScanUnixMs": Int64(day.timeIntervalSince1970 * 1000), + "scanSinceKey": range.scanSinceKey, + "scanUntilKey": range.scanUntilKey, + "timeZoneIdentifier": calendar.timeZone.identifier, + "pricingKey": CostUsagePricingKey.codex( + modelsDevArtifact: nil, + formulaVersion: 2, + parserHash: "865a444e01b818f1", + modelsDevProviderIDs: CostUsagePricing.codexModelsDevProviderIDs.union( + Set(CostUsagePricing.claudeFirstPartyModelsDevProviderIDs)), + customPricingFingerprint: CostUsageCustomPricing.empty.fingerprint), + "daysByProvider": contributions, + "files": [sessionURL.path: file], + ] + let cacheURL = env.cacheRoot + .appendingPathComponent("cost-usage", isDirectory: true) + .appendingPathComponent("pi-sessions-v8.json") + let cacheBytes = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try FileManager.default.createDirectory( + at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try cacheBytes.write(to: cacheURL) + let options = PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + calendar: calendar, + refreshMinIntervalSeconds: 3600, + environment: ["HOME": env.root.path]) + return Fixture( + day: day, + sessionURL: sessionURL, + modifiedAt: modifiedAt, + size: size, + cacheURL: cacheURL, + cacheBytes: cacheBytes, + options: options) + } + + private static func transcript(in env: CostUsageTestEnvironment, day: Date, input: Int) throws -> String { + try env.jsonl([[ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "usage": ["input": input, "output": 5, "totalTokens": input + 5], + ], + ]]) + } + + private static func scan( + _ fixture: Fixture, + now: Date, + options: PiSessionCostScanner.Options? = nil) throws -> PiSessionCostScanner.DailyReportResult + { + try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: fixture.day, + until: fixture.day, + now: now, + options: options ?? fixture.options, + checkCancellation: nil) + } + + private static func cachedReport( + _ fixture: Fixture, + now: Date, + options: PiSessionCostScanner.Options? = nil) -> PiSessionCostScanner.CachedDailyReportResult? + { + PiSessionCostScanner.loadCachedDailyReportResult( + provider: .codex, + since: fixture.day, + until: fixture.day, + now: now, + cacheRoot: fixture.options.cacheRoot, + calendar: fixture.options.calendar, + options: options ?? fixture.options, + allowEstablishedEmpty: true) + } + + private static func canonicalPath(_ url: URL) -> String { + url.standardizedFileURL.resolvingSymlinksInPath().standardizedFileURL.path + } +} + +private final class PiV8UpgradeParseCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + var value: Int { + self.lock.withLock { self.count } + } + + func increment() { + self.lock.withLock { self.count += 1 } + } +} diff --git a/Tests/CodexBarTests/PiSessionProcessContextTests.swift b/Tests/CodexBarTests/PiSessionProcessContextTests.swift new file mode 100644 index 0000000000..cd33e50cc6 --- /dev/null +++ b/Tests/CodexBarTests/PiSessionProcessContextTests.swift @@ -0,0 +1,840 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionProcessContextTests { + @Test + func `pi cost cache keeps a live process root after the process exits`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 7) + let project = env.root.appendingPathComponent("live-project", isDirectory: true) + let liveRoot = env.root.appendingPathComponent("live-session-root", isDirectory: true) + let defaultRoot = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try [project, liveRoot, defaultRoot].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + + let liveEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "openai/gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 3, "output": 2, "totalTokens": 5], + ], + ] + let defaultEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 4, "output": 3, "totalTokens": 7], + ], + ] + try env.jsonl([liveEntry]).write( + to: liveRoot.appendingPathComponent("2026-04-07T10-00-00-000Z_live.jsonl"), + atomically: true, + encoding: .utf8) + try env.jsonl([defaultEntry]).write( + to: defaultRoot.appendingPathComponent("2026-04-07T10-00-00-000Z_default.jsonl"), + atomically: true, + encoding: .utf8) + + let environment = ["HOME": env.root.path] + let liveContext = PiSessionProcessContext( + command: "/usr/local/bin/pi --session-dir \(liveRoot.path)", + workingDirectory: project) + let first = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + environment: environment, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectories: [project], + processContexts: [liveContext])) + #expect(first.sessionTokens == 12) + + let afterExit = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + environment: environment, + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot), + piScannerOptions: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectories: [project])) + #expect(afterExit.sessionTokens == 12) + #expect(afterExit.historyCoverageIsEstablished) + let afterExitResult = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .pi, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectories: [project]), + checkCancellation: nil) + #expect(afterExitResult.scopeFingerprint == PiSessionCostScanner.scopeFingerprint(options: .init( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectories: [project]))) + } + + @Test + func `pi cost roots carry live process selectors and preserve the default root`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let piProject = env.root.appendingPathComponent("pi-project", isDirectory: true) + let ompProject = env.root.appendingPathComponent("omp-project", isDirectory: true) + let piRoot = env.root.appendingPathComponent("pi-process-sessions", isDirectory: true) + let ompRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try [piProject, ompProject, piRoot, ompRoot].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [piProject, ompProject], + processContexts: [ + PiSessionProcessContext( + command: "/usr/local/bin/pi --session-dir \(piRoot.path)", + workingDirectory: piProject), + PiSessionProcessContext( + command: "/usr/local/bin/omp --profile work", + workingDirectory: ompProject, + selectorEnvironment: ["HOME": env.root.path]), + ]) + + #expect(roots.contains { $0.url == piRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(roots.contains { $0.url == ompRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(roots.contains { $0.url == piRoot.standardizedFileURL && $0.preserveAfterProcessExit }) + #expect(roots.contains { $0.url == ompRoot.standardizedFileURL && $0.preserveAfterProcessExit }) + let defaultPiRoot = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + .standardizedFileURL + #expect(roots.contains { + $0.url == defaultPiRoot && $0.missingIsKnownEmpty && !$0.preserveAfterProcessExit + }) + } + + @Test + func `pi cost roots do not retain environment-only process roots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let project = env.root.appendingPathComponent("pi-project", isDirectory: true) + let selectedRoot = env.root.appendingPathComponent("environment-selected", isDirectory: true) + try [project, selectedRoot].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + + let environment = [ + "HOME": env.root.path, + "PI_CODING_AGENT_SESSION_DIR": selectedRoot.path, + ] + let roots = PiFamilySessionScanner.costSessionRoots( + environment: environment, + baseDirectories: [project], + processContexts: [ + PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: environment), + ]) + + #expect(roots.contains { $0.url == selectedRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(roots.contains { + $0.url == selectedRoot.standardizedFileURL && !$0.preserveAfterProcessExit + }) + } + + @Test + func `pi cost roots keep current project settings after process exit`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let project = env.root.appendingPathComponent("pi-project-settings", isDirectory: true) + let configuredRoot = env.root.appendingPathComponent("project-session-root", isDirectory: true) + let settingsDirectory = project.appendingPathComponent(".pi", isDirectory: true) + try [project, configuredRoot, settingsDirectory].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data("{\"sessionDir\":\"\(configuredRoot.path)\"}".utf8).write( + to: settingsDirectory.appendingPathComponent("settings.json"), + options: .atomic) + + let liveContext = PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: ["HOME": env.root.path]) + let liveRoots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [project], + processContexts: [liveContext]) + let afterExitRoots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [project]) + + #expect(liveRoots.contains { + $0.url == configuredRoot.standardizedFileURL && $0.preserveAfterProcessExit + }) + #expect(afterExitRoots.contains { + $0.url == configuredRoot.standardizedFileURL && !$0.preserveAfterProcessExit + }) + } + + @Test + func `pi cost roots drop a retained project setting after the setting is removed`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let project = env.root.appendingPathComponent("pi-project-settings-removed", isDirectory: true) + let configuredRoot = env.root.appendingPathComponent("project-session-removed", isDirectory: true) + let defaultRoot = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let settingsURL = project + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + try [project, configuredRoot, defaultRoot, settingsURL.deletingLastPathComponent()].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(("{\"sessionDir\":\"" + configuredRoot.path + "\"}").utf8).write( + to: settingsURL, + options: .atomic) + + let environment = ["HOME": env.root.path] + let liveOptions = PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: project, + processContexts: [PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: environment)]) + let first = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: Date(timeIntervalSince1970: 1_776_000_000), + until: Date(timeIntervalSince1970: 1_776_000_000), + now: Date(timeIntervalSince1970: 1_776_000_000), + options: liveOptions, + checkCancellation: nil) + #expect(first.scopeFingerprint?.contains(configuredRoot.path) == true) + + try FileManager.default.removeItem(at: settingsURL) + let afterRemoval = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: Date(timeIntervalSince1970: 1_776_000_000), + until: Date(timeIntervalSince1970: 1_776_000_000), + now: Date(timeIntervalSince1970: 1_776_000_001), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: project), + checkCancellation: nil) + + #expect(afterRemoval.scopeFingerprint?.contains(configuredRoot.path) == false) + #expect(afterRemoval.scopeFingerprint?.contains(defaultRoot.path) == true) + } + + @Test + func `pi cost cache revalidates a retained project setting after process exit`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 14) + let project = env.root.appendingPathComponent("pi-project-settings-retained", isDirectory: true) + let ambient = env.root.appendingPathComponent("ambient", isDirectory: true) + let settingsURL = project + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + let sessionRoot = project.appendingPathComponent("sessions", isDirectory: true) + try [project, ambient, sessionRoot, settingsURL.deletingLastPathComponent()].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(#"{"sessionDir":"sessions"}"#.utf8).write(to: settingsURL, options: .atomic) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 8, "output": 4, "totalTokens": 12], + ], + ] + try env.jsonl([entry]).write( + to: sessionRoot.appendingPathComponent("2026-04-14T10-00-00-000Z_retained.jsonl"), + atomically: true, + encoding: .utf8) + + let environment = ["HOME": env.root.path] + let first = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient, + processContexts: [PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: environment)]), + checkCancellation: nil) + #expect(first.isComplete) + #expect(first.report.summary?.totalTokens == 12) + #expect(first.scopeFingerprint?.contains(sessionRoot.path) == true) + + let afterExit = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient), + checkCancellation: nil) + #expect(afterExit.isComplete) + #expect(afterExit.report.summary?.totalTokens == 12) + #expect(afterExit.scopeFingerprint?.contains(sessionRoot.path) == true) + } + + @Test + func `pi cost cache retains both settings selectors when shared roots diverge`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 14) + let project = env.root.appendingPathComponent("pi-project-settings-retained", isDirectory: true) + let ambient = env.root.appendingPathComponent("ambient", isDirectory: true) + let settingsURL = project + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + let sessionRoot = project.appendingPathComponent("sessions", isDirectory: true) + try [project, ambient, sessionRoot, settingsURL.deletingLastPathComponent()].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(#"{"sessionDir":"sessions"}"#.utf8).write(to: settingsURL, options: .atomic) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 8, "output": 4, "totalTokens": 12], + ], + ] + try env.jsonl([entry]).write( + to: sessionRoot.appendingPathComponent("2026-04-14T10-00-00-000Z_retained.jsonl"), + atomically: true, + encoding: .utf8) + + let environment = ["HOME": env.root.path] + let first = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient, + processContexts: [PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: environment)]), + checkCancellation: nil) + #expect(first.isComplete) + #expect(first.report.summary?.totalTokens == 12) + #expect(first.scopeFingerprint?.contains(sessionRoot.path) == true) + + let secondProject = env.root.appendingPathComponent("second-project", isDirectory: true) + let secondSettings = secondProject.appendingPathComponent(".pi/settings.json") + try FileManager.default.createDirectory( + at: secondSettings.deletingLastPathComponent(), + withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["sessionDir": sessionRoot.path]).write(to: secondSettings) + let shared = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient, + processContexts: [project, secondProject].map { + PiSessionProcessContext(command: "pi", workingDirectory: $0, selectorEnvironment: environment) + }), checkCancellation: nil) + #expect(shared.isComplete) + #expect(shared.report.summary?.totalTokens == 12) + let newRoot = project.appendingPathComponent("new-sessions", isDirectory: true) + try FileManager.default.createDirectory(at: newRoot, withIntermediateDirectories: true) + try JSONSerialization.data(withJSONObject: ["sessionDir": newRoot.path]).write(to: settingsURL) + let afterExit = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient), + checkCancellation: nil) + #expect(afterExit.isComplete) + #expect(afterExit.report.summary?.totalTokens == 12) + #expect(afterExit.scopeFingerprint?.contains(sessionRoot.path) == true) + } + + @Test(arguments: [false, true], ["invalidJSON", "missingProject", "danglingSettingsDirectory"]) + func `pi cost cache preserves the previous report when retained settings are unavailable`( + usesDefaultRoot: Bool, failure: String) throws + { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 15) + let project = env.root.appendingPathComponent("pi-project-settings-unavailable", isDirectory: true) + let ambient = env.root.appendingPathComponent("ambient-unavailable", isDirectory: true) + let configuredRoot = env.root.appendingPathComponent( + usesDefaultRoot ? ".pi/agent/sessions" : "settings-unavailable-sessions", isDirectory: true) + let settingsURL = project + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + try [project, ambient, configuredRoot, settingsURL.deletingLastPathComponent()].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(("{\"sessionDir\":\"" + configuredRoot.path + "\"}").utf8).write( + to: settingsURL, + options: .atomic) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 5, "output": 4, "totalTokens": 9], + ], + ] + try env.jsonl([entry]).write( + to: configuredRoot.appendingPathComponent("2026-04-15T10-00-00-000Z_unavailable.jsonl"), + atomically: true, + encoding: .utf8) + + let environment = ["HOME": env.root.path] + let first = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient, + processContexts: [PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: environment)]), + checkCancellation: nil) + #expect(first.isComplete) + #expect(first.report.summary?.totalTokens == 9) + + let savedSettings = try Data(contentsOf: settingsURL) + let parked = env.root.appendingPathComponent("unavailable-project-backup", isDirectory: true) + switch failure { + case "missingProject": + try FileManager.default.moveItem(at: project, to: parked) + case "danglingSettingsDirectory": + try FileManager.default.moveItem(at: settingsURL.deletingLastPathComponent(), to: parked) + try FileManager.default.createSymbolicLink( + at: settingsURL.deletingLastPathComponent(), + withDestinationURL: env.root.appendingPathComponent("offline-volume", isDirectory: true)) + default: + try Data("{".utf8).write(to: settingsURL, options: .atomic) + } + let afterFailure = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient), + checkCancellation: nil) + #expect(!afterFailure.isComplete) + #expect(afterFailure.report.summary?.totalTokens == 9) + #expect(afterFailure.scopeFingerprint == first.scopeFingerprint) + #expect(afterFailure.lastScanAt == first.lastScanAt) + #expect(PiSessionCostScanner.scopeFingerprint(options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient)) == first.scopeFingerprint) + + switch failure { + case "missingProject": + try FileManager.default.moveItem(at: parked, to: project) + case "danglingSettingsDirectory": + try FileManager.default.removeItem(at: settingsURL.deletingLastPathComponent()) + try FileManager.default.moveItem(at: parked, to: settingsURL.deletingLastPathComponent()) + default: + try savedSettings.write(to: settingsURL, options: .atomic) + } + let restored = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(2), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: ambient), + checkCancellation: nil) + #expect(restored.isComplete) + #expect(restored.report.summary?.totalTokens == 9) + #expect(restored.lastScanAt == day.addingTimeInterval(2)) + } + + @Test + func `a relative Pi selector with unavailable cwd keeps history incomplete`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let environment = ["HOME": env.root.path] + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + "201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/pi --session-dir ./sessions" + }, + cwdProvider: { _, _ in [:] }, + processEnvironmentProvider: { _ in [201: environment] }) + let contexts = await scanner.piSessionProcessContexts(environment: environment) + #expect(contexts.count == 1) + #expect(contexts.first?.workingDirectory == nil) + let day = try env.makeLocalNoon(year: 2026, month: 7, day: 6) + let result = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .pi, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + environment: environment, + processContexts: contexts), + checkCancellation: nil) + #expect(!result.isComplete) + #expect(result.lastScanAt == nil) + } + + @Test + func `omp profile process context survives missing cwd`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let profileRoot = env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try FileManager.default.createDirectory(at: profileRoot, withIntermediateDirectories: true) + + let environment = ["HOME": env.root.path] + let scanner = LocalAgentSessionScanner( + processOutputProvider: { _ in + "201 1 Mon Jul 6 09:03:00 2026 /usr/local/bin/omp --profile work" + }, + cwdProvider: { _, _ in [:] }, + processEnvironmentProvider: { _ in [201: environment] }) + let contexts = await scanner.piSessionProcessContexts(environment: environment) + let context = try #require(contexts.first) + #expect(context.workingDirectory == nil) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: environment, + processContexts: contexts) + #expect(roots.contains { + $0.url == profileRoot.standardizedFileURL && + $0.resolutionIsComplete && + $0.preserveAfterProcessExit + }) + } + + @Test + func `pi process context cap preserves a distinct older session root`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let sessionRoot = env.root.appendingPathComponent("older-session-root", isDirectory: true) + try FileManager.default.createDirectory(at: sessionRoot, withIntermediateDirectories: true) + let environment = ["HOME": env.root.path] + let scanner = LocalAgentSessionScanner( + config: SessionScanConfig(maxProcessCount: 2), + processOutputProvider: { _ in + """ + 204 1 Mon Jul 6 09:06:00 2026 /usr/local/bin/pi --model fictitious-alpha + 203 1 Mon Jul 6 09:05:00 2026 /usr/local/bin/pi --model fictitious-beta + 202 1 Mon Jul 6 09:04:00 2026 /usr/local/bin/pi --model fictitious-gamma + 201 1 Sun Jul 5 09:03:00 2026 /usr/local/bin/pi --session-dir \(sessionRoot.path) + """ + }, + cwdProvider: { pids, _ in + Dictionary(uniqueKeysWithValues: pids.map { ($0, env.root.path) }) + }, + processEnvironmentProvider: { pids in + Dictionary(uniqueKeysWithValues: pids.map { ($0, environment) }) + }) + + let contexts = await scanner.piSessionProcessContexts(environment: environment) + + #expect(contexts.count == 2) + #expect(contexts.contains { $0.command.contains("--model fictitious-alpha") }) + #expect(contexts.contains { $0.command.contains("--session-dir \(sessionRoot.path)") }) + } + + @Test + func `global relative settings keep CWD-specific retention provenance`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let firstProject = env.root.appendingPathComponent("relative-first", isDirectory: true) + let secondProject = env.root.appendingPathComponent("relative-second", isDirectory: true) + let globalSettings = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("settings.json") + let firstRoot = firstProject.appendingPathComponent("sessions", isDirectory: true) + let secondRoot = secondProject.appendingPathComponent("sessions", isDirectory: true) + try [firstProject, secondProject, firstRoot, secondRoot, globalSettings.deletingLastPathComponent()].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(#"{"sessionDir":"sessions"}"#.utf8).write(to: globalSettings, options: .atomic) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [firstProject, secondProject]) + let firstResolved = try #require(roots.first { $0.url == firstRoot.standardizedFileURL }) + let secondResolved = try #require(roots.first { $0.url == secondRoot.standardizedFileURL }) + + #expect(!firstResolved.retentionKeys.isEmpty) + #expect(!secondResolved.retentionKeys.isEmpty) + #expect(firstResolved.retentionKeys != secondResolved.retentionKeys) + } + + @Test + func `pi cost roots replace a retained project settings selector`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let project = env.root.appendingPathComponent("pi-project-settings-switch", isDirectory: true) + let firstRoot = env.root.appendingPathComponent("project-session-first", isDirectory: true) + let secondRoot = env.root.appendingPathComponent("project-session-second", isDirectory: true) + let settingsDirectory = project.appendingPathComponent(".pi", isDirectory: true) + try [project, firstRoot, secondRoot, settingsDirectory].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 13) + let environment = ["HOME": env.root.path] + + func writeUsage(to root: URL, totalTokens: Int, name: String) throws { + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": totalTokens - 1, "output": 1, "totalTokens": totalTokens], + ], + ] + try env.jsonl([entry]).write( + to: root.appendingPathComponent("2026-04-13T10-00-00-000Z_" + name + ".jsonl"), + atomically: true, + encoding: .utf8) + } + + try Data(("{\"sessionDir\":\"" + firstRoot.path + "\"}").utf8).write( + to: settingsDirectory.appendingPathComponent("settings.json"), + options: .atomic) + try writeUsage(to: firstRoot, totalTokens: 15, name: "first") + let first = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: project, + processContexts: [PiSessionProcessContext( + command: "/usr/local/bin/pi", + workingDirectory: project, + selectorEnvironment: environment)]), + checkCancellation: nil) + #expect(first.report.summary?.totalTokens == 15) + + try Data(("{\"sessionDir\":\"" + secondRoot.path + "\"}").utf8).write( + to: settingsDirectory.appendingPathComponent("settings.json"), + options: .atomic) + try writeUsage(to: secondRoot, totalTokens: 30, name: "second") + let second = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0, + environment: environment, + workingDirectory: project), + checkCancellation: nil) + + #expect(second.isComplete) + #expect(second.report.summary?.totalTokens == 30) + #expect(second.scopeFingerprint?.contains(firstRoot.path) == false) + #expect(second.scopeFingerprint?.contains(secondRoot.path) == true) + } + + @Test + func `pi cost roots preserve whitespace in live session selectors`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let project = env.root.appendingPathComponent("pi-project", isDirectory: true) + let explicitRoot = env.root.appendingPathComponent("pi sessions", isDirectory: true) + try [project, explicitRoot].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [project], + processContexts: [ + PiSessionProcessContext( + command: "/usr/local/bin/pi --session-dir \(explicitRoot.path)", + arguments: ["/usr/local/bin/pi", "--session-dir", explicitRoot.path], + workingDirectory: project), + ]) + + #expect(roots.contains { $0.url == explicitRoot.standardizedFileURL && $0.resolutionIsComplete }) + #expect(!roots + .contains { $0.url.path == explicitRoot.deletingLastPathComponent().appendingPathComponent("pi").path }) + } + + @Test + func `pi cost cache drops superseded configured roots`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let firstRoot = env.root.appendingPathComponent("configured-first", isDirectory: true) + let secondRoot = env.root.appendingPathComponent("configured-second", isDirectory: true) + try [firstRoot, secondRoot].forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + + func writeAssistant(to root: URL, input: Int, output: Int, name: String) throws { + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": input, "output": output, "totalTokens": input + output], + ], + ] + try env.jsonl([entry]).write( + to: root.appendingPathComponent("2026-04-08T10-00-00-000Z_" + name + ".jsonl"), + atomically: true, + encoding: .utf8) + } + + try writeAssistant(to: firstRoot, input: 10, output: 5, name: "first") + try writeAssistant(to: secondRoot, input: 20, output: 10, name: "second") + + func options(environment: [String: String]) -> PiSessionCostScanner.Options { + PiSessionCostScanner.Options( + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 3600, + environment: environment) + } + let firstEnvironment = [ + "HOME": env.root.path, + "PI_CODING_AGENT_SESSION_DIR": firstRoot.path, + ] + let secondEnvironment = [ + "HOME": env.root.path, + "PI_CODING_AGENT_SESSION_DIR": secondRoot.path, + ] + + let first = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day, + options: options(environment: firstEnvironment)) + #expect(first.data.first?.totalTokens == 15) + + let second = PiSessionCostScanner.loadDailyReport( + provider: .codex, + since: day, + until: day, + now: day.addingTimeInterval(1), + options: options(environment: secondEnvironment)) + #expect(second.data.first?.totalTokens == 30) + #expect(PiSessionCostCacheIO.load(cacheRoot: env.cacheRoot).sessionRootsFingerprint? + .contains(firstRoot.path) != true) + } +} diff --git a/Tests/CodexBarTests/PiSharedRootMergeTests.swift b/Tests/CodexBarTests/PiSharedRootMergeTests.swift new file mode 100644 index 0000000000..e50ea87a3a --- /dev/null +++ b/Tests/CodexBarTests/PiSharedRootMergeTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSharedRootMergeTests { + @Test + func `live omp profiles suppress unrelated ambient discovery`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + func profileRoot(_ name: String) -> URL { + env.root + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } + + let selectedRoots = [profileRoot("work"), profileRoot("personal")] + let unrelatedRoot = profileRoot("unrelated") + try (selectedRoots + [unrelatedRoot]).forEach { + try FileManager.default.createDirectory(at: $0, withIntermediateDirectories: true) + } + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: selectedRoots.map { root in + let profile = root + .deletingLastPathComponent() + .deletingLastPathComponent() + .lastPathComponent + return PiSessionProcessContext( + command: "/usr/local/bin/omp --profile \(profile)", + workingDirectory: env.root, + selectorEnvironment: ["HOME": env.root.path]) + }) + + #expect(selectedRoots.allSatisfy { selected in + roots.contains { $0.url == selected.standardizedFileURL && $0.preserveAfterProcessExit } + }) + #expect(!roots.contains { $0.url == unrelatedRoot.standardizedFileURL }) + } + + @Test + func `same pi dialect root keeps required process provenance`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let sharedRoot = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: [ + PiSessionProcessContext( + command: "pi", + workingDirectory: env.root, + selectorEnvironment: ["HOME": env.root.path]), + PiSessionProcessContext( + command: "pi --session-dir \(sharedRoot.path)", + workingDirectory: env.root), + ]) + + let root = try #require(roots.first { $0.url == sharedRoot.standardizedFileURL }) + #expect(!root.missingIsKnownEmpty) + #expect(root.preserveAfterProcessExit) + #expect(root.retentionKeys == ["process:pi:session-dir:\(sharedRoot.standardizedFileURL.path)"]) + } + + @Test + func `shared pi and omp root keeps required process provenance`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let sharedRoot = env.root + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectories: [env.root], + processContexts: [ + PiSessionProcessContext( + command: "omp --session-dir \(sharedRoot.path)", + workingDirectory: env.root), + ]) + + let root = try #require(roots.first { $0.url == sharedRoot.standardizedFileURL }) + #expect(!root.missingIsKnownEmpty) + #expect(root.preserveAfterProcessExit) + #expect(root.retentionKeys == ["process:omp:session-dir:\(sharedRoot.standardizedFileURL.path)"]) + } +} diff --git a/Tests/CodexBarTests/PiWidgetFreshnessTests.swift b/Tests/CodexBarTests/PiWidgetFreshnessTests.swift new file mode 100644 index 0000000000..be23ecc13c --- /dev/null +++ b/Tests/CodexBarTests/PiWidgetFreshnessTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +struct PiWidgetFreshnessTests { + @Test + func `Pi widget keeps the history measurement age after an empty local usage refresh`() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let settings = testSettingsStore( + suiteName: "PiWidgetFreshnessTests", + userDefaults: InMemoryUserDefaults(), + config: testConfigWithAllProvidersDisabled()) + settings.costUsageEnabled = true + let metadata = try #require(ProviderRegistry.shared.metadata[.pi]) + settings.setProviderEnabled(provider: .pi, metadata: metadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(homeDirectory: root.path, fileExists: { _ in false }), + settings: settings, + startupBehavior: .testing, + environmentBase: [:], + widgetSnapshotURL: root.appendingPathComponent("widget.json")) + let measuredAt = Date().addingTimeInterval(-3600) + var saved: WidgetSnapshot? + store._test_widgetSnapshotSaveOverride = { saved = $0 } + store.publishTokenSnapshot( + CostUsageTokenSnapshot( + sessionTokens: 100, + sessionCostUSD: 1, + last30DaysTokens: 100, + last30DaysCostUSD: 1, + historyCoverageIsEstablished: false, + daily: [], + updatedAt: measuredAt), + for: .pi, + accounting: .piOnly(scope: "synthetic-source")) + + for refresh in [Date(), Date().addingTimeInterval(60)] { + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: refresh), + provider: .pi) + store.persistWidgetSnapshot(reason: "pi-history-age") + await store.widgetSnapshotPersistTask?.value + let entry = try #require(saved?.entries.first { $0.provider == .pi }) + #expect(entry.updatedAt == measuredAt) + #expect(entry.tokenUsage?.updatedAt == measuredAt) + #expect(entry.tokenUsage?.last30DaysTokens == 100) + } + } +} diff --git a/Tests/CodexBarTests/PiXDGProfileTests.swift b/Tests/CodexBarTests/PiXDGProfileTests.swift new file mode 100644 index 0000000000..126c9dde51 --- /dev/null +++ b/Tests/CodexBarTests/PiXDGProfileTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiXDGProfileTests { + @Test + func `pi provider honors a selected profile in the default xdg data home`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 6) + let configuredRoot = Self.defaultXDGProfileRoot(home: env.root) + try FileManager.default.createDirectory(at: configuredRoot, withIntermediateDirectories: true) + let entry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "anthropic", + "model": "claude-sonnet-4-6", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 9, "output": 3, "totalTokens": 12], + ], + ] + try env.jsonl([entry]).write( + to: configuredRoot.appendingPathComponent("2026-04-06T10-00-00-000Z_omp-xdg.jsonl"), + atomically: true, + encoding: .utf8) + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .pi, + environment: [ + "HOME": env.root.path, + "OMP_PROFILE": "work", + ], + now: day, + forceRefresh: true, + historyDays: 1, + allowPricingRefresh: false, + scannerOptions: CostUsageScanner.Options(cacheRoot: env.cacheRoot)) + + #expect(snapshot.sessionTokens == 12) + #expect(snapshot.historyCoverageIsEstablished) + } + + @Test + func `pi cost roots discover profiles in the default xdg data home`() throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let profileRoot = Self.defaultXDGProfileRoot(home: env.root) + try FileManager.default.createDirectory(at: profileRoot, withIntermediateDirectories: true) + + let roots = PiFamilySessionScanner.costSessionRoots( + environment: ["HOME": env.root.path], + baseDirectory: env.root) + + #expect(roots.contains { $0.url == profileRoot.standardizedFileURL }) + } + + private static func defaultXDGProfileRoot(home: URL) -> URL { + home + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("omp", isDirectory: true) + .appendingPathComponent("profiles", isDirectory: true) + .appendingPathComponent("work", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + } +} diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index d4759a56f4..14bdc3d881 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -155,8 +155,8 @@ struct ProviderArchitectureGatekeeperTests { Self.hash(descriptor.branding.burnDownWidgetColor, into: &burnDownFingerprint) } - #expect(widgetFingerprint == 1_046_823_491_863_569_602) - #expect(burnDownFingerprint == 3_766_053_096_839_076_055) + #expect(widgetFingerprint == 17_382_738_755_413_253_330) + #expect(burnDownFingerprint == 17_324_801_121_975_792_184) } @Test @@ -206,11 +206,11 @@ struct ProviderArchitectureGatekeeperTests { #if os(macOS) // Antigravity and Muse use bounded local usage readers. #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .cursor, .vertexai, .bedrock, .antigravity, .muse, + .codex, .claude, .cursor, .vertexai, .bedrock, .antigravity, .muse, .pi, ]) #else #expect(Set(descriptors.filter(\.tokenCost.supportsTokenSnapshot).map(\.id)) == [ - .codex, .claude, .vertexai, .bedrock, .antigravity, .muse, + .codex, .claude, .vertexai, .bedrock, .antigravity, .muse, .pi, ]) #endif #expect(Set(descriptors.filter { $0.cli.binaryLocator != nil }.map(\.id)) == [ @@ -840,6 +840,36 @@ struct ProviderArchitectureGatekeeperTests { /// documents why that token is an external contract or ownership data rather than shared provider-selection /// policy. private static let suppressedProviderReferences: [SuppressedProviderReference] = [ + SuppressedProviderReference( + path: "Sources/CodexBar/IconRenderer.swift", + line: 626, + anchor: "let eyeTiltAngle: CGFloat = .pi / 3 // 60 degrees tilt", + expectedProviderIDs: ["pi"], + reason: "This is the mathematical constant π used by the renderer, not a provider selection."), + SuppressedProviderReference( + path: "Sources/CodexBar/StatusItemController+Animation.swift", + line: 321, + anchor: "style == .combined ? 0 : self.tiltAmount(for: primaryProvider) * .pi / 28", + expectedProviderIDs: ["pi"], + reason: "This is the mathematical constant π used for an animation angle, not a provider selection."), + SuppressedProviderReference( + path: "Sources/CodexBarCore/PiFamilySessionScanner.swift", + line: 480, + anchor: "case .pi:", + expectedProviderIDs: ["pi"], + reason: "This branch dispatches the Pi-family root resolver for the fixed Pi dialect."), + SuppressedProviderReference( + path: "Sources/CodexBarCore/PiFamilySessionScanner.swift", + line: 519, + anchor: "case .pi:", + expectedProviderIDs: ["pi"], + reason: "This branch dispatches the Pi-family root resolver for the fixed Pi dialect."), + SuppressedProviderReference( + path: "Sources/CodexBarCore/PiFamilySessionScanner.swift", + line: 646, + anchor: "case .pi:", + expectedProviderIDs: ["pi"], + reason: "This branch checks command selectors for the fixed Pi dialect."), SuppressedProviderReference( path: "Sources/CodexBar/CodexAccountUsageSnapshotStore.swift", line: 224, @@ -962,55 +992,55 @@ struct ProviderArchitectureGatekeeperTests { reason: "This observation touchpoint reads a fixed provider field so UI invalidation tracks that setting."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 432, + line: 444, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 434, + line: 446, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex).metadata.displayName,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 520, + line: 532, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 523, + line: 535, anchor: "modelProviderName: ProviderDescriptorRegistry.descriptor(for: .codex)", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 603, + line: 615, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 647, + line: 659, anchor: "let providerName = store.metadata(for: .codex).displayName", expectedProviderIDs: ["codex"], reason: "This Codex account projection passes its fixed provider identity to shared spend infrastructure."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1717, + line: 1752, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This OpenCodex enrichment descriptor maps the canonical source back to the Codex family."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1746, + line: 1782, anchor: "if providerID == UsageProvider.codex.rawValue {", expectedProviderIDs: ["codex"], reason: "This publication projection expands the fixed Codex provider family into its account sources."), SuppressedProviderReference( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1763, + line: 1800, anchor: "if sourceID.hasPrefix(\"codex:\") { return .codex }", expectedProviderIDs: ["codex"], reason: "This publication projection maps stable Codex account source IDs back to their provider family."), @@ -1058,43 +1088,49 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 28, + line: 31, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 29, + line: 32, anchor: "let scopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 55, + line: 58, anchor: "providerConfigRevision: self.settings.providerConfigRevision(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 281, - anchor: "self.publishConfirmedEmptyTokenSnapshot(for: .codex)", + line: 60, + anchor: "includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: .codex),", + expectedProviderIDs: ["codex"], + reason: "Codex catch-up passes its fixed provider identity to the shared Pi inclusion policy."), + SuppressedProviderReference( + path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", + line: 292, + anchor: "self.publishConfirmedEmptyTokenSnapshot(for: .codex, accounting: result.accounting)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 284, - anchor: "self.publishTokenSnapshot(snapshot, for: .codex)", + line: 295, + anchor: "self.publishTokenSnapshot(snapshot, for: .codex, accounting: result.accounting)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 322, + line: 337, anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 323, + line: 338, anchor: "&& self.isEnabled(.codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), @@ -1242,7 +1278,6 @@ struct ProviderArchitectureGatekeeperTests { anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), - SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", line: 1496, @@ -1251,98 +1286,98 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 92, + line: 188, anchor: "allowVertexClaudeFallback: !self.isEnabled(.claude),", expectedProviderIDs: ["claude"], reason: "The local transcript scan permits Vertex fallback only when Claude is disabled to avoid " + "double-counting the same logs."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 256, + line: 377, anchor: "let scope = self.tokenCostScope(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 258, + line: 379, anchor: "let publicationRevision = self.providerPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 259, + line: 380, anchor: "let providerConfigRevision = self.settings.providerConfigRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 261, + line: 382, anchor: "let tokenSnapshotScopeSignature = self.tokenSnapshotScopeSignature(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 262, + line: 383, anchor: "let tokenSnapshotPublicationRevision = self.tokenSnapshotPublicationRevision(for: .codex)", expectedProviderIDs: ["codex"], reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 292, + line: 422, anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 293, + line: 423, anchor: "self.isEnabled(.codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 302, - anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex)", + line: 432, + anchor: "self.installCachedTokenSnapshot(result.snapshot, for: .codex, accounting: result.accounting)", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 414, + line: 550, anchor: "return CookieHeaderCache.loadForDisplay(provider: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 424, + line: 560, anchor: "let scope = self.tokenCostScope(for: .cursor)", expectedProviderIDs: ["cursor"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 313, + line: 316, anchor: "return self.tokenAccountSnapshotCacheKey(provider: .claude, account: account)", expectedProviderIDs: ["claude"], reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 317, + line: 320, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "Claude widget quota ownership uses the selected Claude account's isolated snapshot key."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1072, + line: 1083, anchor: "provider: .deepseek,", expectedProviderIDs: ["deepseek"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1174, + line: 1185, anchor: "let sourceMode = self.sourceMode(for: .claude)", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore.swift", - line: 1178, + line: 1189, anchor: "provider: .claude,", expectedProviderIDs: ["claude"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), @@ -1366,25 +1401,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 458, + line: 464, anchor: "lines.append(Self.costEstimateHint(provider: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 481, + line: 487, anchor: "lines.append(Self.costEstimateHint(provider: .codex))", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 868, + line: 892, anchor: "let account = try context.resolvedAccounts(for: .cursor).first", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), SuppressedProviderReference( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 869, + line: 893, anchor: "return context.settingsSnapshot(for: .cursor, account: account)?.cursor", expectedProviderIDs: ["cursor"], reason: "The Cursor-only cookie-settings resolver passes its fixed identity to token-account helpers."), @@ -1396,37 +1431,37 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 311, + line: 378, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 328, + line: 410, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 846, + line: 1124, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 923, + line: 1208, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1005, + line: 1309, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 298, + line: 359, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -1576,19 +1611,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This logged-out-page classifier matches OpenAI's public landing-page brand token."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 429, + line: 451, anchor: ".appendingPathComponent(\".claude\", isDirectory: true)", expectedProviderIDs: ["claude"], reason: "The Claude transcript locator follows Claude Code's fixed default projects directory."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 503, + line: 525, anchor: ".appendingPathComponent(\".claude\", isDirectory: true)", expectedProviderIDs: ["claude"], reason: "The budgeted Claude transcript locator follows Claude Code's fixed default projects directory."), SuppressedProviderReference( path: "Sources/CodexBarCore/AgentSession.swift", - line: 610, + line: 632, anchor: "if value.contains(\"ide\") || value.contains(\"vscode\") || value.contains(\"cursor\") || value.contains(\"zed\") {", expectedProviderIDs: ["cursor", "zed"], reason: "This session-source classifier recognizes editor-origin strings emitted by upstream clients."), @@ -1684,31 +1719,31 @@ struct ProviderArchitectureGatekeeperTests { reason: "This WidgetKit default or preview pins the established Codex sample provider."), SuppressedProviderReference( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 82, + line: 84, anchor: "@Parameter(title: \"Provider\", default: .codex)", expectedProviderIDs: ["codex"], reason: "This WidgetKit default or preview pins the established Codex sample provider."), SuppressedProviderReference( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 114, + line: 116, anchor: "@Parameter(title: \"Provider\", default: .codex)", expectedProviderIDs: ["codex"], reason: "This WidgetKit default or preview pins the established Codex sample provider."), SuppressedProviderReference( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 150, + line: 152, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This WidgetKit default or preview pins the established Codex sample provider."), SuppressedProviderReference( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 235, + line: 237, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This WidgetKit default or preview pins the established Codex sample provider."), SuppressedProviderReference( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 280, + line: 282, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This WidgetKit default or preview pins the established Codex sample provider."), @@ -2136,9 +2171,9 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/MenuDescriptor.swift", line: 208, anchor: "case .codex: \"⌘\"", - expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["codex@0", "claude@1"], + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["codex@0", "claude@1", "pi@2"], reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuDescriptor.swift", @@ -2383,7 +2418,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 636, + line: 648, anchor: "(providers.contains(.codex) && settings.codexLocalSessionCostLedgerEnabled)", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2415,15 +2450,18 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 292, + line: 300, anchor: "for provider in providers where provider != .codex {", - expectedProviderIDs: ["codex", "grok"], - expectedReferenceCount: 7, - expectedReferenceFingerprint: ["codex@0", "grok@3", "grok@5", "grok@6", "grok@10", "grok@11", "grok@14"], + expectedProviderIDs: ["codex", "grok", "pi"], + expectedReferenceCount: 10, + expectedReferenceFingerprint: [ + "pi@0", "pi@1", "pi@2", "codex@10", "grok@13", "grok@15", "grok@16", "grok@20", + "grok@21", "grok@24", + ], reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 698, + line: 710, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2431,7 +2469,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 726, + line: 738, anchor: "if providers.contains(.codex) {", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -2439,7 +2477,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardController.swift", - line: 1790, + line: 1827, anchor: "guard input.provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2455,7 +2493,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/SpendDashboardModel.swift", - line: 1270, + line: 1274, anchor: "guard provider == .mistral || provider == .openrouter || provider == .xai else { return displayCalendar }", expectedProviderIDs: ["mistral", "openrouter", "xai"], expectedReferenceCount: 3, @@ -2533,6 +2571,14 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 1, expectedReferenceFingerprint: ["warp@0"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), + AllowedProviderConstruct( + path: "Sources/CodexBar/StatusItemController+Animation.swift", + line: 893, + anchor: "if provider == .kiro {", + expectedProviderIDs: ["cursor", "kiro", "mimo", "mistral", "openrouter"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["openrouter@0", "mistral@1", "mimo@7", "kiro@13", "cursor@21"], + reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+CostMenuCard.swift", line: 129, @@ -2679,7 +2725,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 23, + line: 26, anchor: "guard provider == .codex else { return }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2687,7 +2733,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - line: 319, + line: 334, anchor: "&& self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision", expectedProviderIDs: ["codex"], expectedReferenceCount: 3, @@ -3162,7 +3208,6 @@ struct ProviderArchitectureGatekeeperTests { expectedReferenceCount: 2, expectedReferenceFingerprint: ["claude@0", "deepseek@4"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), - AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenAccounts.swift", line: 1567, @@ -3173,33 +3218,35 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 265, + line: 385, anchor: "guard self.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil else { return }", expectedProviderIDs: ["codex"], - expectedReferenceCount: 1, - expectedReferenceFingerprint: ["codex@0"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["codex@0", "codex@1", "codex@7", "codex@15", "codex@16"], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 289, - anchor: "guard self.providerPublicationRevisionIsCurrent(publicationRevision, for: .codex),", + line: 417, + anchor: "guard await self.refreshPiHistoryScope(for: .codex),", expectedProviderIDs: ["codex"], - expectedReferenceCount: 9, + expectedReferenceCount: 11, expectedReferenceFingerprint: [ "codex@0", "codex@1", - "codex@5", + "codex@2", + "codex@3", "codex@7", - "codex@8", "codex@9", - "codex@14", - "codex@23", - "codex@24", + "codex@10", + "codex@11", + "codex@16", + "codex@25", + "codex@26", ], reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 326, + line: 456, anchor: "return provider == .codex && self.codexCostCatchUpActivity?.phase == .indexing", expectedProviderIDs: ["claude", "codex", "vertexai"], expectedReferenceCount: 4, @@ -3207,7 +3254,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 391, + line: 527, anchor: "guard provider == .cursor else {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3215,7 +3262,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 489, + line: 625, anchor: "if provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3223,7 +3270,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 512, + line: 648, anchor: "guard provider == .cursor,", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3231,7 +3278,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 534, + line: 670, anchor: "case .openai:", expectedProviderIDs: ["grok", "mistral", "openai", "opencodego", "openrouter", "xai"], expectedReferenceCount: 12, @@ -3252,7 +3299,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - line: 603, + line: 739, anchor: "self.tokenFailureGates[.codex]?.reset()", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3279,13 +3326,13 @@ struct ProviderArchitectureGatekeeperTests { path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", line: 254, anchor: "if provider == .codex, let snapshot {", - expectedProviderIDs: ["claude", "codex", "deepseek", "devin", "openrouter"], - expectedReferenceCount: 5, - expectedReferenceFingerprint: ["codex@0", "devin@12", "claude@19", "deepseek@26", "openrouter@26"], - reason: "This widget projection maps provider-owned credits, costs, quota ownership, and balance text."), + expectedProviderIDs: ["claude", "codex", "deepseek", "devin", "openrouter", "pi"], + expectedReferenceCount: 6, + expectedReferenceFingerprint: ["codex@0", "devin@12", "claude@19", "deepseek@26", "openrouter@26", "pi@33"], + reason: "This widget projection maps provider-owned credits, costs, quota ownership, balance text, and Pi history freshness."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 312, + line: 315, anchor: "if let account = self.settings.effectiveSelectedTokenAccount(for: .claude) {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3293,7 +3340,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 328, + line: 331, anchor: "guard let entry, entry.provider == .claude else { return nil }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3301,7 +3348,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 382, + line: 385, anchor: "let sessionLabel = if provider == .bedrock || provider == .mistral {", expectedProviderIDs: ["bedrock", "codex", "mistral"], expectedReferenceCount: 4, @@ -3309,7 +3356,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 454, + line: 457, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3317,7 +3364,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 473, + line: 476, anchor: "if provider == .claude,", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3325,7 +3372,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 412, + line: 415, anchor: "if provider == .cursor, snapshot.detailRow(label: \"Request quota\") != nil {", expectedProviderIDs: ["alibabatokenplan", "amp", "crof", "cursor", "doubao", "grok", "ollama"], expectedReferenceCount: 7, @@ -3341,7 +3388,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 486, + line: 489, anchor: "if provider == .antigravity,", expectedProviderIDs: ["alibabatokenplan", "amp", "antigravity"], expectedReferenceCount: 4, @@ -3349,7 +3396,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 528, + line: 531, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3357,7 +3404,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Cursor Grok Bot weekly included usage is a named extraRateWindow on the shared widget projection."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 541, + line: 544, anchor: "if provider == .claude, self.settings.claudeModelScopedWeeklyUsageVisible {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3365,7 +3412,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "Claude's opt-in widget projection adds provider-owned model-scoped weekly quota rows."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore+WidgetSnapshot.swift", - line: 555, + line: 558, anchor: "if provider == .kimi {", expectedProviderIDs: ["kimi"], expectedReferenceCount: 1, @@ -3373,7 +3420,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 632, + line: 643, anchor: "self.metadata(for: .codex).browserCookieOrder ?? Browser.defaultImportOrder", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3381,7 +3428,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 684, + line: 695, anchor: "self.providerSpecs[provider]?.style ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3389,7 +3436,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 717, + line: 728, anchor: "guard provider != .codex else { return true }", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3397,7 +3444,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1046, + line: 1057, anchor: "let claudeDebugConfiguration: ClaudeDebugLogConfiguration? = if provider == .claude {", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3405,7 +3452,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1069, + line: 1080, anchor: "let deepSeekHasTokenAccount = self.settings.selectedTokenAccount(for: .deepseek) != nil", expectedProviderIDs: ["deepseek"], expectedReferenceCount: 1, @@ -3413,7 +3460,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1126, + line: 1137, anchor: "case .amp:", expectedProviderIDs: ["amp", "deepseek", "notion", "ollama", "warp"], expectedReferenceCount: 7, @@ -3429,7 +3476,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/UsageStore.swift", - line: 1181, + line: 1192, anchor: "let claudeSettings = snapshot.claude ?? ProviderSettingsSnapshot.ClaudeProviderSettings(", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3445,7 +3492,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 547, + line: 553, anchor: "provider == .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3453,7 +3500,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 632, + line: 656, anchor: "let projects = provider == .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3461,7 +3508,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 878, + line: 902, anchor: "guard provider == .cursor else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3469,7 +3516,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCLI/CLICostCommand.swift", - line: 898, + line: 922, anchor: "guard provider == .cursor, settings?.cookieSource == .manual else { return nil }", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3485,7 +3532,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact CLI construct preserves the provider-specific command and output contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 215, + line: 229, anchor: "return AgentSession.Provider.claude.rawValue", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3493,23 +3540,25 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration normalizes the Claude Desktop wrapper to its agent provider name."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 248, + line: 262, anchor: "if basename == AgentSession.Provider.codex.rawValue {", - expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 5, - expectedReferenceFingerprint: ["codex@0", "claude@7", "claude@10", "claude@18", "claude@30"], + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 7, + expectedReferenceFingerprint: [ + "codex@0", "claude@7", "claude@10", "claude@18", "pi@26", "claude@30", "pi@33", + ], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 304, + line: 318, anchor: "guard self.provider(for: record) == .claude else { return .cli }", - expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["claude@0", "codex@6"], + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 4, + expectedReferenceFingerprint: ["pi@0", "pi@1", "claude@13", "codex@19"], reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 328, + line: 342, anchor: "guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3517,7 +3566,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration recognizes only the Codex app-server bundled in ChatGPT.app."), AllowedProviderConstruct( path: "Sources/CodexBarCore/AgentSession.swift", - line: 345, + line: 367, anchor: "URL(fileURLWithPath: $0).lastPathComponent == AgentSession.Provider.claude.rawValue", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3541,7 +3590,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 638, + line: 889, anchor: "var historyCoverageIsEstablished = provider != .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3549,15 +3598,23 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 664, + line: 922, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 4, - expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@18"], + expectedReferenceCount: 2, + expectedReferenceFingerprint: ["claude@0", "codex@0"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 727, + line: 954, + anchor: "if provider == .codex {", + expectedProviderIDs: ["codex"], + expectedReferenceCount: 1, + expectedReferenceFingerprint: ["codex@0"], + reason: "Codex project totals must merge native-ledger and Pi contributions before publishing the report."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/CostUsageFetcher.swift", + line: 1002, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3565,7 +3622,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 753, + line: 1028, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3573,7 +3630,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1418, + line: 1731, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3581,7 +3638,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1723, + line: 2001, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1, @@ -3589,7 +3646,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 129, + line: 130, anchor: "guard AgentPSOutputParser.provider(for: process) == .codex else { return nil }", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -3597,7 +3654,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 245, + line: 306, anchor: "let claudeProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .claude }", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3605,7 +3662,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 261, + line: 322, anchor: "let codexProcesses = processes.filter { AgentPSOutputParser.provider(for: $0) == .codex }", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 3, @@ -3613,7 +3670,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact host integration maps a provider-owned process, path, or window contract."), AllowedProviderConstruct( path: "Sources/CodexBarCore/LocalAgentSessionScanner.swift", - line: 287, + line: 348, anchor: "case .codex:", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3637,15 +3694,23 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 230, - anchor: "guard provider == .codex || provider == .claude else { return nil }", - expectedProviderIDs: ["claude", "codex"], - expectedReferenceCount: 2, - expectedReferenceFingerprint: ["claude@0", "codex@0"], + line: 163, + anchor: "guard provider == .codex || provider == .claude || provider == .pi else {", + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 3, + expectedReferenceFingerprint: ["claude@0", "codex@0", "pi@0"], reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 841, + line: 377, + anchor: "guard provider != .pi || !self.hasUnsupportedHistory(cache: cache, range: range) else { return nil }", + expectedProviderIDs: ["claude", "codex", "pi"], + expectedReferenceCount: 5, + expectedReferenceFingerprint: ["pi@0", "pi@2", "claude@5", "codex@5", "codex@5"], + reason: "The Pi aggregate rejects unsupported history and combines only the Codex and Claude tariffs it can price."), + AllowedProviderConstruct( + path: "Sources/CodexBarCore/PiSessionCostScanner.swift", + line: 1281, anchor: "case .codex:", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3653,7 +3718,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 854, + line: 1295, anchor: "case .claude:", expectedProviderIDs: ["claude"], expectedReferenceCount: 1, @@ -3661,7 +3726,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/PiSessionCostScanner.swift", - line: 880, + line: 1332, anchor: ".codex", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3848,7 +3913,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact pricing bridge preserves the legacy OpenCodex transport label contract."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 86, + line: 88, anchor: "self.provider = .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3856,7 +3921,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact WidgetKit construct preserves its compile-time provider selection contract."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 121, + line: 123, anchor: "self.provider = .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3864,7 +3929,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact WidgetKit construct preserves its compile-time provider selection contract."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 181, + line: 183, anchor: "provider: providers.first ?? .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3872,7 +3937,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact WidgetKit construct preserves its compile-time provider selection contract."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 203, + line: 205, anchor: "let selected = providers.first { $0.instanceID == stored } ?? providers.first ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3880,7 +3945,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact WidgetKit construct preserves its compile-time provider selection contract."), AllowedProviderConstruct( path: "Sources/CodexBarWidget/CodexBarWidgetProvider.swift", - line: 227, + line: 229, anchor: "return supported.isEmpty ? [.codex] : supported", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/ProviderHistoryCapabilityTests.swift b/Tests/CodexBarTests/ProviderHistoryCapabilityTests.swift index 4b5518e0bd..ce17dc16f2 100644 --- a/Tests/CodexBarTests/ProviderHistoryCapabilityTests.swift +++ b/Tests/CodexBarTests/ProviderHistoryCapabilityTests.swift @@ -8,6 +8,6 @@ struct ProviderHistoryCapabilityTests { descriptor.history.alwaysTracksPlanUtilization ? descriptor.id : nil }) - #expect(alwaysTracked == [.codex, .claude, .antigravity, .opencodego]) + #expect(alwaysTracked == [.codex, .claude, .antigravity, .opencodego, .pi]) } } diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index b4a480cf20..ea9f5d5acd 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -79,6 +79,53 @@ struct ProviderIconResourcesTests { #expect(visiblePixels < 240) } + @Test + func `pi provider icon is a transparent vector template`() throws { + let root = try Self.repoRoot() + let resourceURL = root + .appending(path: "Sources/CodexBar/Resources", directoryHint: .isDirectory) + .appending(path: "ProviderIcon-pi.svg") + let svg = try String(contentsOf: resourceURL, encoding: .utf8) + + #expect(!svg.contains(" 0 + { + visiblePixels += 1 + } + } + #expect(visiblePixels > 20) + #expect(visiblePixels < 180) + } + @Test func `registered providers resolve bundled brand icons`() { ProviderBrandIcon.resetCacheForTesting() diff --git a/Tests/CodexBarTests/SettingsStoreTokenCostSourceTests.swift b/Tests/CodexBarTests/SettingsStoreTokenCostSourceTests.swift new file mode 100644 index 0000000000..ab2d678bb6 --- /dev/null +++ b/Tests/CodexBarTests/SettingsStoreTokenCostSourceTests.swift @@ -0,0 +1,86 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct SettingsStoreTokenCostSourceTests { + @Test + func `token cost source detection includes live pi process roots`() throws { + let fileManager = FileManager.default + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let project = env.root.appendingPathComponent("pi-project", isDirectory: true) + let projectSettings = project + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("settings.json") + let projectSessions = project.appendingPathComponent("sessions", isDirectory: true) + let explicitSessions = env.root.appendingPathComponent("explicit-sessions", isDirectory: true) + try [projectSettings.deletingLastPathComponent(), projectSessions, explicitSessions].forEach { + try fileManager.createDirectory(at: $0, withIntermediateDirectories: true) + } + try Data(#"{"sessionDir":"sessions"}"#.utf8).write(to: projectSettings, options: .atomic) + fileManager.createFile( + atPath: projectSessions.appendingPathComponent("project.jsonl").path, + contents: Data("{}".utf8)) + fileManager.createFile( + atPath: explicitSessions.appendingPathComponent("explicit.jsonl").path, + contents: Data("{}".utf8)) + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: ["HOME": env.root.path], + fileManager: fileManager, + homeDirectory: env.root, + workingDirectory: env.root, + processContexts: [ + PiSessionProcessContext( + command: "pi", + workingDirectory: project, + selectorEnvironment: ["HOME": env.root.path]), + PiSessionProcessContext( + command: "pi --session-dir \(explicitSessions.path)", + workingDirectory: nil), + ])) + } + + @Test + func `token cost source detection includes pi and omp roots`() throws { + let fileManager = FileManager.default + + let piHome = fileManager.temporaryDirectory.appendingPathComponent( + "pi-token-cost-\(UUID().uuidString)", + isDirectory: true) + let piSessions = piHome + .appendingPathComponent(".pi", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try fileManager.createDirectory(at: piSessions, withIntermediateDirectories: true) + fileManager.createFile( + atPath: piSessions.appendingPathComponent("session.jsonl").path, + contents: Data("{}".utf8)) + defer { try? fileManager.removeItem(at: piHome) } + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: ["HOME": piHome.path], + fileManager: fileManager, + homeDirectory: piHome)) + + let ompHome = fileManager.temporaryDirectory.appendingPathComponent( + "omp-token-cost-\(UUID().uuidString)", + isDirectory: true) + let ompSessions = ompHome + .appendingPathComponent(".omp", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("sessions", isDirectory: true) + try fileManager.createDirectory(at: ompSessions, withIntermediateDirectories: true) + fileManager.createFile( + atPath: ompSessions.appendingPathComponent("session.jsonl").path, + contents: Data("{}".utf8)) + defer { try? fileManager.removeItem(at: ompHome) } + + #expect(SettingsStore.hasAnyTokenCostUsageSources( + env: ["HOME": ompHome.path], + fileManager: fileManager, + homeDirectory: ompHome)) + } +} diff --git a/Tests/CodexBarTests/SpendDashboardControllerTests.swift b/Tests/CodexBarTests/SpendDashboardControllerTests.swift index c355b7c404..967550a195 100644 --- a/Tests/CodexBarTests/SpendDashboardControllerTests.swift +++ b/Tests/CodexBarTests/SpendDashboardControllerTests.swift @@ -885,6 +885,78 @@ struct SpendDashboardControllerTests { } } +@MainActor +extension SpendDashboardControllerTests { + @Test + func `capture keeps inclusive claude totals when pi source is absent`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-pi-absent-projection") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == .claude) + } + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let inclusive = Self.input(provider: .claude, cost: 9).snapshot + let native = Self.input(provider: .claude, cost: 4).snapshot + store._setSpendDashboardTokenSnapshotForTesting( + inclusive, + for: .claude, + accounting: .includesPi(scope: "pi-scope", native: native)) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + + let captured = try #require(request.capturedInputs.first) + #expect(request.capturedInputs.count == 1) + #expect(captured.provider == .claude) + #expect(captured.snapshot.last30DaysCostUSD == inclusive.last30DaysCostUSD) + } + + @Test + func `hidden pi source still owns pi rows in the claude projection`() async throws { + let settings = testSettingsStore(suiteName: "SpendDashboardControllerTests-pi-hidden-projection") + settings.costUsageEnabled = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .claude || provider == .pi) + } + settings.spendDashboardHiddenSourceIDs = [UsageProvider.pi.rawValue] + let store = UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + let inclusive = Self.input(provider: .claude, cost: 9).snapshot + let native = Self.input(provider: .claude, cost: 4).snapshot + let pi = Self.input(provider: .pi, cost: 5).snapshot + store._setSpendDashboardTokenSnapshotForTesting( + inclusive, + for: .claude, + accounting: .includesPi(scope: "pi-scope", native: native)) + store._setSpendDashboardTokenSnapshotForTesting(pi, for: .pi) + + let request = await SpendDashboardSource.makeRequest( + settings: settings, + store: store, + mode: .captureOnly) + + let claude = try #require(request.capturedInputs.first { $0.provider == .claude }) + #expect(request.configuration.hiddenSourceIDs == [UsageProvider.pi.rawValue]) + #expect(claude.snapshot.last30DaysCostUSD == native.last30DaysCostUSD) + } +} + @MainActor struct SpendDashboardRequestTimeTests { @Test diff --git a/Tests/CodexBarTests/SpendDashboardLocalHistoryTests.swift b/Tests/CodexBarTests/SpendDashboardLocalHistoryTests.swift new file mode 100644 index 0000000000..a3a0d517d3 --- /dev/null +++ b/Tests/CodexBarTests/SpendDashboardLocalHistoryTests.swift @@ -0,0 +1,121 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite(.serialized) +struct SpendDashboardLocalHistoryTests { + @Test + func `local Pi history stays out of the subscription denominator`() throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + let snapshot = CostUsageTokenSnapshot( + sessionTokens: nil, + sessionCostUSD: nil, + last30DaysTokens: 10, + last30DaysCostUSD: 7, + currencyCode: "USD", + daily: [ + CostUsageDailyReport.Entry( + date: "2026-07-15", + inputTokens: nil, + outputTokens: nil, + totalTokens: 10, + costUSD: 7, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + let piInput = SpendDashboardModel.ProviderInput( + id: UsageProvider.pi.rawValue, + provider: .pi, + displayName: "Pi", + snapshot: snapshot, + sourceKind: .localHistory) + let publication = SpendDashboardPublication( + revision: 1, + generation: 1, + configuration: nil, + loadedAt: now, + isRefreshing: false, + inputs: [piInput], + sources: [ + SpendSourcePublication( + id: UsageProvider.pi.rawValue, + provider: .pi, + displayName: "Pi", + role: .localHistory, + state: .available), + ]) + + let model = publication.model( + requestedDays: 30, + now: now, + calendar: calendar, + preferredCurrencyCode: "USD", + providerScope: [.pi]) + let summary = OverviewSpendSummary( + model: model, + providerCount: publication.subscriptionCount(providerScope: [.pi]), + knownCostProviderCount: publication.knownCostSubscriptionCount(model: model, providerScope: [.pi]), + knownTokenProviderCount: publication.knownTokenSubscriptionCount(model: model, providerScope: [.pi])) + + #expect(model.groups.first?.providers.first?.sourceKind == .localHistory) + #expect(publication.subscriptionCount(providerScope: [.pi]) == 0) + #expect(summary.providerCoverageText == "0 of 0 subscriptions have spend") + #expect(summary.primarySpendText == "$7.00") + #expect(!summary.isPartial) + let group = try #require(model.groups.first) + CodexBarLocalizationOverride.$appLanguage.withValue("en") { + #expect(spendDashboardProviderCountTitle(group) == "Sources") + #expect(spendDashboardProviderPanelTitle(group) == "By source") + #expect(spendDashboardPartialSourceCoverageText(group) == "1 of 1 sources have spend") + } + } + + @Test + func `Pi keeps local history role without a loaded snapshot`() async throws { + let now = Date(timeIntervalSince1970: 1_784_179_200) + let sourceID = UsageProvider.pi.rawValue + let configuration = SpendDashboardConfiguration( + costUsageEnabled: true, + providerIDs: [sourceID], + codexAccountIdentities: []) + + for state in [SpendSourcePublication.State.unavailable, .confirmedEmpty] { + let suiteName = "SpendDashboardLocalHistoryTests-\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + let unavailableSourceIDs: Set = state == .unavailable ? [sourceID] : [] + let confirmedEmptySourceIDs: Set = state == .confirmedEmpty ? [sourceID] : [] + let controller = SpendDashboardController( + userDefaults: defaults, + requestBuilder: { mode in + SpendDashboardLoadRequest( + configuration: configuration, + capturedInputs: [], + unavailableSourceIDs: unavailableSourceIDs, + confirmedEmptySourceIDs: confirmedEmptySourceIDs, + codexRequests: [], + now: now, + force: mode.forcesLoader) + }, + loader: { request in + SpendDashboardLoadResult(inputs: [], failedSourceIDs: request.unavailableSourceIDs) + }, + nowProvider: { now }) + + controller.update(configuration: configuration) + #expect(controller.publication.sources.first?.role == .localHistory) + try await SpendDashboardStateWait.until { !controller.isRefreshing } + let source = try #require(controller.publication.sources.first) + #expect(source.provider == .pi) + #expect(source.role == .localHistory) + #expect(source.state == state) + #expect(controller.publication.subscriptionCount(providerScope: [.pi]) == 0) + controller.stop() + defaults.removePersistentDomain(forName: suiteName) + } + } +} diff --git a/Tests/CodexBarTests/SpendDashboardModelTests.swift b/Tests/CodexBarTests/SpendDashboardModelTests.swift index 1466f2c07c..fcdcd09ab3 100644 --- a/Tests/CodexBarTests/SpendDashboardModelTests.swift +++ b/Tests/CodexBarTests/SpendDashboardModelTests.swift @@ -82,7 +82,7 @@ struct SpendDashboardModelTests { .openrouter, .xai, // Antigravity and Muse provide local token history without monetary values. - .antigravity, .muse, + .antigravity, .muse, .pi, ]) } diff --git a/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift b/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift index b86348e8e4..7d684272f1 100644 --- a/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift +++ b/Tests/CodexBarTests/SpendDashboardPartialCostTests.swift @@ -192,7 +192,9 @@ struct SpendDashboardPartialCostTests { CodexBarLocalizationOverride.$appLanguage.withValue("en") { #expect(spendDashboardGroupCostText(group).hasPrefix("~")) #expect(spendDashboardGroupTokenText(group) == "240") - #expect(spendDashboardPartialSubscriptionsText(group) == "1 of 3 subscriptions have spend") + #expect(spendDashboardProviderCountTitle(group) == "Subscriptions") + #expect(spendDashboardProviderPanelTitle(group) == "By subscription") + #expect(spendDashboardPartialSourceCoverageText(group) == "1 of 3 subscriptions have spend") #expect(spendDashboardHistoryCaption(group, requestedDays: 30).contains("Partial estimate")) } } diff --git a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift index 9f88752535..f7341cd5b5 100644 --- a/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift +++ b/Tests/CodexBarTests/StatusMenuOverviewSpendTests.swift @@ -602,6 +602,26 @@ extension StatusMenuTests { #expect(staleOwnerModel.groups.isEmpty) } + @Test + func `overview fallback excludes pi local history from subscription count`() { + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.costUsageEnabled = true + let store = self.makeCodexStore(settings: settings, dashboardAuthorized: false) + let controller = StatusItemController( + store: store, + settings: settings, + account: UsageFetcher().loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + defer { controller.releaseStatusItemsForTesting() } + + #expect(store.spendDashboardPublication.configuration == nil) + #expect(controller.overviewSpendSubscriptionCount(providers: [.claude, .pi]) == 1) + } + @Test func `overview accounts for all six selected providers while summing only available spend`() { let settings = self.makeSettings() diff --git a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift index 99aa056e1c..dc44fe0a13 100644 --- a/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCachedTokenHydrationTests.swift @@ -46,6 +46,72 @@ struct UsageStoreCachedTokenHydrationTests { #expect(store.tokenError(for: .codex) == nil) } + @Test + func `cached codex hydration does not duplicate pi rows when pi owns cost`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "cached.jsonl", + tokens: 42) + let piEntry: [String: Any] = [ + "type": "message", + "timestamp": env.isoString(for: day), + "message": [ + "role": "assistant", + "provider": "openai-codex", + "model": "gpt-5.4", + "timestamp": Int(day.timeIntervalSince1970 * 1000), + "usage": ["input": 7, "output": 0, "totalTokens": 7], + ], + ] + _ = try env.writePiSessionFile( + relativePath: "2026-04-08T10-00-00-000Z_pi.jsonl", + contents: env.jsonl([piEntry])) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + _ = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + includePiSessions: false, + scannerOptions: options) + let piResult = try PiSessionCostScanner.loadDailyReportResultCancellable( + provider: .codex, + since: day, + until: day, + now: day, + options: PiSessionCostScanner.Options( + piSessionsRoot: env.piSessionsRoot, + cacheRoot: env.cacheRoot, + refreshMinIntervalSeconds: 0), + checkCancellation: nil) + #expect(piResult.report.data.first?.totalTokens == 7) + + let settings = Self.makeCodexOnlySettings(historyDays: 1) + let piMetadata = try #require(ProviderRegistry.shared.metadata[.pi]) + settings.setProviderEnabled(provider: .pi, metadata: piMetadata, enabled: true) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: CostUsageFetcher(scannerOptions: options), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + + #expect(!store.shouldIncludePiSessionsInTokenSnapshot(for: .codex)) + store.hydrateCachedTokenSnapshots(now: day) + try await Self.waitForCodexTokenSnapshot(in: store) + + #expect(store.tokenSnapshot(for: .codex)?.sessionTokens == 42) + } + @Test func `cached codex token hydration skips managed codex homes`() async throws { let env = try CostUsageTestEnvironment() @@ -116,9 +182,12 @@ struct UsageStoreCachedTokenHydrationTests { provider: .codex, now: now, historyDays: 1, + includePiSessions: false, scannerOptions: options) let settings = Self.makeCodexOnlySettings(historyDays: 1) + let piMetadata = try #require(ProviderRegistry.shared.metadata[.pi]) + settings.setProviderEnabled(provider: .pi, metadata: piMetadata, enabled: true) let store = UsageStore( fetcher: UsageFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), diff --git a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpPublicationTests.swift b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpPublicationTests.swift index b308a08cc8..51ab9cb1d1 100644 --- a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpPublicationTests.swift +++ b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpPublicationTests.swift @@ -249,6 +249,179 @@ struct UsageStoreCodexCostCatchUpPublicationTests { #expect(store.tokenSnapshotPublicationRevision(for: .codex) > 1) } + @Test + func `completed catch-up remains native only when Pi owns history and no Pi cache exists`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let now = Date() + let iso = env.isoString(for: now) + _ = try env.writeCodexSessionFile( + day: now, + filename: "rollout-pi-owned-publication.jsonl", + contents: """ + {"type":"session_meta","timestamp":"\(iso)","payload":{"session_id":"pi-owned-publication"}} + {"type":"turn_context","timestamp":"\(iso)","payload":{"model":"openai/gpt-5.2-codex"}} + \(Self.tokenRecord(iso: iso, input: 100)) + + """) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing.sqlite")) + options.refreshMinIntervalSeconds = 0 + let expected = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + environment: [:], + now: now, + allowPricingRefresh: false, + includePiSessions: false, + scannerOptions: options) + #expect(expected.last30DaysTokens == 100) + #expect(expected.historyCoverageIsEstablished) + let piCacheURL = PiSessionCostCacheIO.cacheFileURL(cacheRoot: env.cacheRoot) + #expect(!FileManager.default.fileExists(atPath: piCacheURL.path)) + + let store = try Self.makeStore( + suite: "pi-owned-native-cache", + costUsageFetcher: CostUsageFetcher(scannerOptions: options)) + defer { store.cancelCodexCostCatchUp() } + store.settings.codexLocalSessionCostLedgerEnabled = true + store.settings.costUsageBucketTimeZoneIdentifier = options.calendar.timeZone.identifier + let piMetadata = try #require(ProviderRegistry.shared.metadata[.pi]) + store.settings.setProviderEnabled(provider: .pi, metadata: piMetadata, enabled: true) + #expect(!store.shouldIncludePiSessionsInTokenSnapshot(for: .codex)) + store._test_piHistoryScopeResolver = { _ in + Issue.record("A native-only Codex completion must not resolve Pi processes or roots") + return "unexpected-pi-scope" + } + store._test_widgetSnapshotSaveOverride = { _ in } + store.publishTokenSnapshot( + Self.snapshot(tokens: 1, now: now.addingTimeInterval(-3600)), + for: .codex, + accounting: .nativeOnly) + Self.stubCompletion(on: store) + // Leave the cached loader override unset: this must exercise the production + // completed-cache helper and its forwarded includePiSessions policy. + #expect(store._test_cachedCodexTokenSnapshotLoaderOverride == nil) + + store.startCodexCostCatchUpIfNeeded(mode: .accelerated) + await store.codexCostCatchUpTask?.value + await store.widgetSnapshotPersistTask?.value + + let publication = try #require(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex)) + let completed = try #require(publication.snapshot) + #expect(completed.last30DaysTokens == 100) + #expect(completed.historyCoverageIsEstablished) + #expect(completed.hourly == expected.hourly) + #expect(completed.quotaSlices == expected.quotaSlices) + #expect(abs(completed.updatedAt.timeIntervalSince(expected.updatedAt)) < 0.002) + #expect(publication.accounting == .nativeOnly) + #expect(store.codexCostCatchUpActivity?.phase == .complete) + #expect(store.tokenErrors[.codex] == nil) + #expect(!FileManager.default.fileExists(atPath: piCacheURL.path)) + } + + @Test(arguments: [false, true]) + func `suspended completed cache cannot publish across a Pi root generation change`( + returnsToOriginalRoot: Bool) async throws + { + let store = try Self.makeStore(suite: "pi-root-change-\(returnsToOriginalRoot)") + let gate = PiCatchUpCachedReadGate() + defer { + gate.release() + store.cancelCodexCostCatchUp() + } + let piMetadata = try #require(ProviderRegistry.shared.metadata[.pi]) + store.settings.setProviderEnabled(provider: .pi, metadata: piMetadata, enabled: false) + store.settings.codexLocalSessionCostLedgerEnabled = true + let root = PiCatchUpScopeValue("root-A") + store._test_piHistoryScopeResolver = { _ in await root.read() } + #expect(await store.refreshPiHistoryScope(for: .codex)) + let initialGeneration = store.piHistoryScopeGeneration + let oldTime = Date().addingTimeInterval(-3600) + store.publishTokenSnapshot(Self.snapshot(tokens: 100, now: oldTime), for: .codex) + let originalPublicationRevision = store.tokenSnapshotPublicationRevision(for: .codex) + Self.stubCompletion(on: store) + // A rejected completion legitimately requests another refresh. Keep that retry + // contained while this test checks only the obsolete publication. + store._test_tokenUsageRefreshOverride = { _, _ in } + store._test_widgetSnapshotSaveOverride = { _ in } + store._test_cachedCodexTokenSnapshotLoaderOverride = { _, _, _ in + await gate.enter() + return (Self.snapshot(tokens: 200, now: oldTime), oldTime, nil) + } + + store.startCodexCostCatchUpIfNeeded(mode: .accelerated) + let catchUp = try #require(store.codexCostCatchUpTask) + await gate.waitForStart() + await root.set("root-B") + #expect(await store.refreshPiHistoryScope(for: .codex)) + if returnsToOriginalRoot { + await root.set("root-A") + #expect(await store.refreshPiHistoryScope(for: .codex)) + } + #expect(store.piHistoryScopeGeneration == initialGeneration + (returnsToOriginalRoot ? 2 : 1)) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == originalPublicationRevision) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil) + store.tokenErrors[.codex] = "Error belonging to the current Pi scope" + gate.release() + await catchUp.value + + #expect(store.piHistoryScopeFingerprint == (returnsToOriginalRoot ? "root-A" : "root-B")) + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .codex) == nil) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == originalPublicationRevision) + #expect(store.tokenErrors[.codex] == "Error belonging to the current Pi scope") + #expect(store.codexCostCatchUpTask == nil) + + // Prevent a queued retry from surviving the test's synthetic scope. + store.settings.costUsageEnabled = false + store.settings.codexLocalSessionCostLedgerEnabled = false + let retry = store.tokenRefreshSequenceTask + retry?.cancel() + await retry?.value + await Task.yield() + store.tokenRefreshRetryProviders.removeAll() + await store.widgetSnapshotPersistTask?.value + let relief = store.memoryPressureReliefTask + relief?.cancel() + await relief?.value + } + + @Test + func `retained Pi history from another stable scope does not request immediate retries`() async throws { + let store = try Self.makeStore(suite: "stable-pi-scope-mismatch") + let piMetadata = try #require(ProviderRegistry.shared.metadata[.pi]) + store.settings.setProviderEnabled(provider: .pi, metadata: piMetadata, enabled: true) + store._test_piHistoryScopeResolver = { _ in "scope-B" } + #expect(await store.refreshPiHistoryScope(for: .pi)) + let now = Date() + let signature = store.tokenSnapshotScopeSignature(for: .pi) + let context = UsageStore.TokenUsageRefreshContext( + provider: .pi, + now: now, + historyDays: store.settings.costUsageHistoryDays, + costScopeSignature: signature, + publicationScope: store.tokenRefreshPublicationScope( + for: .pi, + historyDays: store.settings.costUsageHistoryDays, + costScopeSignature: signature), + startedAt: now) + let result = CostUsageTokenResult( + snapshot: Self.snapshot(tokens: 100, now: now.addingTimeInterval(-3600), complete: false), + accounting: .piOnly(scope: "scope-A")) + + do { + try store.commitTokenUsageResult(result, context: context) + Issue.record("Unavailable replacement roots must surface a normal history failure") + } catch UsageStore.TokenSnapshotError.historyUnavailable { + // Expected: a periodic or explicit refresh can retry after the root is available. + } + #expect(store.tokenSnapshotPublicationForCurrentProviderConfig(for: .pi) == nil) + #expect(store.tokenRefreshRetryProviders.isEmpty) + #expect(store.tokenRefreshSequenceTask == nil) + store.settings.costUsageEnabled = false + } + private static func stubCompletion(on store: UsageStore) { var advanced = false store._test_codexCostCatchUpStatusOverride = { _ in @@ -282,7 +455,10 @@ struct UsageStoreCodexCostCatchUpPublicationTests { updatedAt: now) } - private static func makeStore(suite: String) throws -> UsageStore { + private static func makeStore( + suite: String, + costUsageFetcher: CostUsageFetcher = CostUsageFetcher()) throws -> UsageStore + { let settings = testSettingsStore( suiteName: "UsageStoreCodexCostCatchUpPublicationTests-\(suite)", userDefaults: InMemoryUserDefaults(), @@ -297,6 +473,7 @@ struct UsageStoreCodexCostCatchUpPublicationTests { let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), + costUsageFetcher: costUsageFetcher, settings: settings, startupBehavior: .testing, environmentBase: [:]) @@ -318,3 +495,48 @@ struct UsageStoreCodexCostCatchUpPublicationTests { + #""model":"openai/gpt-5.2-codex"}}}"# } } + +private actor PiCatchUpScopeValue { + private var value: String + + init(_ value: String) { + self.value = value + } + + func read() -> String { + self.value + } + + func set(_ value: String) { + self.value = value + } +} + +@MainActor +private final class PiCatchUpCachedReadGate { + private var started = false + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func enter() async { + self.started = true + let waiters = self.startWaiters + self.startWaiters.removeAll() + waiters.forEach { $0.resume() } + guard !self.released else { return } + await withCheckedContinuation { self.releaseWaiters.append($0) } + } + + func waitForStart() async { + guard !self.started else { return } + await withCheckedContinuation { self.startWaiters.append($0) } + } + + func release() { + self.released = true + let waiters = self.releaseWaiters + self.releaseWaiters.removeAll() + waiters.forEach { $0.resume() } + } +} diff --git a/Tests/CodexBarTests/UsageStoreCoverageTests.swift b/Tests/CodexBarTests/UsageStoreCoverageTests.swift index cbb8b03b08..4bc5fea298 100644 --- a/Tests/CodexBarTests/UsageStoreCoverageTests.swift +++ b/Tests/CodexBarTests/UsageStoreCoverageTests.swift @@ -76,6 +76,41 @@ struct UsageStoreCoverageTests { #expect(!fingerprint.contains("fixture=a")) } + @Test + func `claude and codex token ownership follows visible pi cost source`() throws { + let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-claude-pi-ownership") + settings.costUsageEnabled = true + let store = Self.makeUsageStore(settings: settings) + let metadata = ProviderRegistry.shared.metadata + try settings.setProviderEnabled( + provider: .claude, + metadata: #require(metadata[.claude]), + enabled: true) + try settings.setProviderEnabled( + provider: .pi, + metadata: #require(metadata[.pi]), + enabled: false) + + let fallbackSignature = store.tokenSnapshotScopeSignature(for: .claude) + #expect(store.shouldIncludePiSessionsInTokenSnapshot(for: .claude)) + #expect(store.shouldIncludePiSessionsInTokenSnapshot(for: .codex)) + #expect(fallbackSignature.contains("|piRows=fallback")) + let codexFallbackSignature = store.tokenSnapshotScopeSignature(for: .codex) + #expect(codexFallbackSignature.contains("|piRows=fallback")) + + try settings.setProviderEnabled( + provider: .pi, + metadata: #require(metadata[.pi]), + enabled: true) + + #expect(!store.shouldIncludePiSessionsInTokenSnapshot(for: .claude)) + #expect(fallbackSignature != store.tokenSnapshotScopeSignature(for: .claude)) + #expect(!store.shouldIncludePiSessionsInTokenSnapshot(for: .codex)) + #expect(codexFallbackSignature != store.tokenSnapshotScopeSignature(for: .codex)) + #expect(store.tokenSnapshotScopeSignature(for: .codex).contains("|piRows=owned")) + #expect(store.shouldIncludePiSessionsInTokenSnapshot(for: .pi)) + } + @Test func `cursor manual cost refresh rejects an empty cookie without falling back`() async throws { let settings = Self.makeSettingsStore(suite: "UsageStoreCoverageTests-cursor-manual-cost") diff --git a/Tests/CodexBarTests/UsageStorePiHistoryScopeTests.swift b/Tests/CodexBarTests/UsageStorePiHistoryScopeTests.swift new file mode 100644 index 0000000000..70ca407bd7 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePiHistoryScopeTests.swift @@ -0,0 +1,195 @@ +import Foundation +import Testing +@testable import CodexBar +@testable import CodexBarCore + +@MainActor +@Suite(.serialized) +struct UsageStorePiHistoryScopeTests { + @Test + func `concurrent Pi history consumers share one pending scope resolution`() async { + let store = Self.makeStore() + let resolver = PiHistoryScopeResolutionGate() + let secondStarted = PiHistoryScopeCallSignal() + store._test_piHistoryScopeResolver = { _ in await resolver.resolve() } + + let first = Task { @MainActor in + await store.refreshPiHistoryScope(for: .codex) + } + await resolver.waitForStart() + #expect(store.piHistoryScopeRefreshTask != nil) + #expect(store.piHistoryScopeGeneration == 0) + + let second = Task { @MainActor in + // Signal synchronously, then enter refresh without yielding the main actor. + secondStarted.signal() + return await store.refreshPiHistoryScope(for: .claude) + } + await secondStarted.wait() + await resolver.release("scope-A") + let firstSucceeded = await first.value + let secondSucceeded = await second.value + let coalescedCallCount = await resolver.callCount + + #expect(firstSucceeded) + #expect(secondSucceeded) + #expect(coalescedCallCount == 1) + #expect(store.piHistoryScopeFingerprint == "scope-A") + #expect(store.piHistoryScopeGeneration == 1) + #expect(store.piHistoryScopeRefreshTask == nil) + + // Completion must clear the task so later refreshes inspect the source again. + let refreshed = await store.refreshPiHistoryScope(for: .codex) + let subsequentCallCount = await resolver.callCount + #expect(refreshed) + #expect(subsequentCallCount == 2) + #expect(store.piHistoryScopeGeneration == 1) + #expect(store.piHistoryScopeRefreshTask == nil) + } + + @Test + func `returning to the same Pi roots rejects publication from their previous generation`() async { + let store = Self.makeStore() + let resolver = PiHistoryScopeResolverState("scope-A") + store._test_piHistoryScopeResolver = { _ in await resolver.resolve() } + + #expect(await store.refreshPiHistoryScope(for: .codex)) + let firstGeneration = store.piHistoryScopeGeneration + let firstSignature = store.tokenSnapshotScopeSignature(for: .codex) + let firstDashboardSignature = store.spendDashboardTokenSnapshotScopeSignature(for: .codex) + let originalPublication = store.tokenRefreshPublicationScope( + for: .codex, + historyDays: store.settings.costUsageHistoryDays, + costScopeSignature: firstSignature) + #expect(store.tokenRefreshPublicationDisposition( + provider: .codex, + scope: originalPublication) == .current) + + #expect(await store.refreshPiHistoryScope(for: .claude)) + #expect(store.piHistoryScopeGeneration == firstGeneration) + #expect(store.tokenSnapshotScopeSignature(for: .codex) == firstSignature) + #expect(store.spendDashboardTokenSnapshotScopeSignature(for: .codex) == firstDashboardSignature) + + await resolver.setFingerprint("scope-B") + #expect(await store.refreshPiHistoryScope(for: .codex)) + #expect(store.piHistoryScopeGeneration == firstGeneration + 1) + #expect(store.tokenRefreshPublicationDisposition( + provider: .codex, + scope: originalPublication) == .scopeChanged) + + await resolver.setFingerprint("scope-A") + #expect(await store.refreshPiHistoryScope(for: .codex)) + #expect(store.piHistoryScopeFingerprint == "scope-A") + #expect(store.piHistoryScopeGeneration == firstGeneration + 2) + // The fingerprint matches again; only the generation distinguishes this publication. + #expect(store.tokenAccountingScopeIsCurrent(.piOnly(scope: "scope-A"), for: .codex)) + #expect(store.tokenSnapshotScopeSignature(for: .codex) != firstSignature) + #expect(store.spendDashboardTokenSnapshotScopeSignature(for: .codex) != firstDashboardSignature) + #expect(store.tokenRefreshPublicationDisposition( + provider: .codex, + scope: originalPublication) == .scopeChanged) + + let currentPublication = store.tokenRefreshPublicationScope( + for: .codex, + historyDays: store.settings.costUsageHistoryDays, + costScopeSignature: store.tokenSnapshotScopeSignature(for: .codex)) + #expect(store.tokenRefreshPublicationDisposition( + provider: .codex, + scope: currentPublication) == .current) + } + + private static func makeStore() -> UsageStore { + let settings = testSettingsStore( + suiteName: "UsageStorePiHistoryScopeTests", + userDefaults: InMemoryUserDefaults(), + keychainAccessPolicy: .init(setDisabled: { _ in }, isExplicitlyDisabled: { false })) + settings.refreshFrequency = .fiveMinutes + settings.statusChecksEnabled = false + settings.costUsageEnabled = true + settings.costUsageHistoryDays = 30 + settings.openAIWebAccessEnabled = false + settings.codexCookieSource = .off + settings.providerDetectionCompleted = true + for provider in UsageProvider.allCases { + guard let metadata = ProviderRegistry.shared.metadata[provider] else { continue } + settings.setProviderEnabled( + provider: provider, + metadata: metadata, + enabled: provider == .codex || provider == .claude) + } + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: [:]) + } +} + +private actor PiHistoryScopeResolutionGate { + private(set) var callCount = 0 + private var releasedFingerprint: String? + private var startWaiters: [CheckedContinuation] = [] + private var resolutionWaiters: [CheckedContinuation] = [] + + func resolve() async -> String { + self.callCount += 1 + let waiters = self.startWaiters + self.startWaiters.removeAll() + waiters.forEach { $0.resume() } + if let fingerprint = self.releasedFingerprint { return fingerprint } + return await withCheckedContinuation { continuation in + self.resolutionWaiters.append(continuation) + } + } + + func waitForStart() async { + guard self.callCount == 0 else { return } + await withCheckedContinuation { continuation in + self.startWaiters.append(continuation) + } + } + + func release(_ fingerprint: String) { + self.releasedFingerprint = fingerprint + let waiters = self.resolutionWaiters + self.resolutionWaiters.removeAll() + waiters.forEach { $0.resume(returning: fingerprint) } + } +} + +@MainActor +private final class PiHistoryScopeCallSignal { + private var signaled = false + private var waiters: [CheckedContinuation] = [] + + func signal() { + self.signaled = true + let waiters = self.waiters + self.waiters.removeAll() + waiters.forEach { $0.resume() } + } + + func wait() async { + guard !self.signaled else { return } + await withCheckedContinuation { continuation in + self.waiters.append(continuation) + } + } +} + +private actor PiHistoryScopeResolverState { + private var fingerprint: String + + init(_ fingerprint: String) { + self.fingerprint = fingerprint + } + + func setFingerprint(_ fingerprint: String) { + self.fingerprint = fingerprint + } + + func resolve() -> String { + self.fingerprint + } +} diff --git a/Tests/CodexBarTests/WidgetProviderChoiceTests.swift b/Tests/CodexBarTests/WidgetProviderChoiceTests.swift index 6539edffaa..b22fcf9fc4 100644 --- a/Tests/CodexBarTests/WidgetProviderChoiceTests.swift +++ b/Tests/CodexBarTests/WidgetProviderChoiceTests.swift @@ -25,6 +25,7 @@ struct WidgetProviderChoiceTests { "kimi": "Kimi Code", "deepseek": "DeepSeek", "openrouter": "OpenRouter", + "pi": "Pi", ] @Test diff --git a/TestsLinux/PiSessionCostCacheLinuxTests.swift b/TestsLinux/PiSessionCostCacheLinuxTests.swift new file mode 100644 index 0000000000..8b462bfb2b --- /dev/null +++ b/TestsLinux/PiSessionCostCacheLinuxTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct PiSessionCostCacheLinuxTests { + @Test + func `first and replacement saves roundtrip changed cache without temporary files`() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("pi-cache-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let url = PiSessionCostCacheIO.cacheFileURL(cacheRoot: root) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(secondsFromGMT: 0)) + + for tokens in [100, 250] { + var cache = PiSessionCostCache() + cache.lastScanUnixMs = Int64(tokens) + cache.scanSinceKey = "2026-08-20" + cache.scanUntilKey = "2026-08-21" + cache.pricingKey = "fixture-pricing-\(tokens)" + cache.daysByProvider = ["codex": ["2026-08-20": [ + "gpt-5.4": PiPackedUsage(inputTokens: tokens, totalTokens: tokens), + ]]] + cache.files = [root.appendingPathComponent("session.jsonl").path: PiSessionFileUsage( + mtimeUnixMs: Int64(tokens), + size: Int64(tokens), + parsedBytes: Int64(tokens), + lastModelContext: nil, + contributions: cache.daysByProvider)] + + PiSessionCostCacheIO.save(cache: cache, cacheRoot: root, calendar: calendar) + + // Read the final artifact directly so load's empty-cache fallback cannot hide a failed save. + let data = try Data(contentsOf: url) + let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(json["lastScanUnixMs"] as? Int == tokens) + let decoded = try JSONDecoder().decode(PiSessionCostCache.self, from: data) + for saved in [decoded, PiSessionCostCacheIO.load(cacheRoot: root)] { + #expect(saved.version == cache.version) + #expect(saved.lastScanUnixMs == cache.lastScanUnixMs) + #expect(saved.scanSinceKey == cache.scanSinceKey) + #expect(saved.scanUntilKey == cache.scanUntilKey) + #expect(saved.pricingKey == cache.pricingKey) + #expect(saved.timeZoneIdentifier == calendar.timeZone.identifier) + #expect(saved.daysByProvider == cache.daysByProvider) + #expect(saved.files.values.first?.parsedBytes == Int64(tokens)) + #expect(saved.files.values.first?.contributions == cache.daysByProvider) + } + let contents = try FileManager.default.contentsOfDirectory(atPath: url.deletingLastPathComponent().path) + #expect(contents == [url.lastPathComponent]) + } + } +} diff --git a/docs/claude.md b/docs/claude.md index 5765ef8517..5a49051219 100644 --- a/docs/claude.md +++ b/docs/claude.md @@ -309,7 +309,7 @@ Model-scoped weekly-window proof (synthetic data, no real accounts or credential - Report memo: `~/Library/Caches/CodexBar/cost-usage/claude-v6.report-memo.json` stores source stamps and the daily report across launches. It is reused only while transcript inventory, cache/pricing artifacts, requested window, and report-semantics revision still match. - The Claude/Vertex cache artifact retains source file identities independently of the shared Codex parser fingerprint. Replacing a transcript rebuilds its rows rather than merging an old prefix into a new suffix; genuine appends still use the saved parse offset. Older entries without identity are rebuilt once before reuse, including during the normal refresh debounce. - Older Claude/Vertex native caches and report memos are rebuilt once from unchanged transcripts to apply the corrected response deduplication. Corrected reports remain eligible for memo reuse across launches. - - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v8.json` + - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v9.json`. Version 8 rebuilds once from transcripts to establish source scope and completeness. Enabling the standalone [Pi provider](pi.md) keeps Claude history native-only in combined views. ## Key files - OAuth: `Sources/CodexBarCore/Providers/Claude/ClaudeOAuth/*` diff --git a/docs/cli.md b/docs/cli.md index d27c670bea..304bca2ec8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -69,8 +69,9 @@ See `docs/configuration.md` for the schema. no denormalization — intended for agents that want a token-cheaper alternative to parsing JSON. `usage --format toon` is the only command that supports it; every other command still advertises and accepts only `--format text|json`, and treats `toon` like any other unrecognized value. -- `codexbar cost` prints token cost usage for Claude, Codex, Cursor, Antigravity, and Muse Code. +- `codexbar cost` prints token cost usage for Claude, Codex, Cursor, Antigravity, Muse Code, and Pi. - Claude and Codex are scanned from local session logs without web/CLI access. + - [Pi](pi.md) reads supported Pi/OMP local assistant history. Selecting Pi alongside Claude/Codex keeps those providers native-only so combined totals count each source once. - Muse Code reads bounded local session logs and reports recorded token history without credentials, provider requests, or invented dollar costs. Partial and unavailable history remain distinct from measured zero (see [Muse Code](muse.md)). - Antigravity reads supported local token history without web, provider CLI, or credential access. It does not estimate dollar costs; unsupported timestamps and incomplete histories remain unavailable (see `docs/antigravity.md`). The same provider selection applies to `serve /cost` and dashboard cost collection. Text output labels this as token history and distinguishes unavailable or incomplete history from a complete period with no recorded usage. diff --git a/docs/codex.md b/docs/codex.md index cdcd4498a4..58d5f7fe8b 100644 --- a/docs/codex.md +++ b/docs/codex.md @@ -259,8 +259,10 @@ the local result and returns a nonzero exit code. See [CLI host reporting](cli.m when pi-compatible usage joins the aggregate because the native-only rows would not reconcile with the merged total. - Cache: - Native session store: `~/Library/Caches/CodexBar/cost-usage/cost-usage.sqlite` - - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v8.json` + - pi-compatible session cache: `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v9.json` is replaced atomically on macOS and Linux, retaining complete cached scan state across refreshes. + Version 8 rebuilds once from transcripts to establish source scope and completeness. Enabling the standalone + [Pi provider](pi.md) keeps Codex history native-only in combined views and completed catch-up publication. - Catch-up status reads progress metadata without loading historical usage JSON or replay bodies. Cached token activity reads scoped daily aggregates without decoding individual usage events, retaining account, time zone, coverage, and incomplete-scan checks. Cached reports diff --git a/docs/index.html b/docs/index.html index 084552f70e..ef94adf4db 100644 --- a/docs/index.html +++ b/docs/index.html @@ -6,7 +6,7 @@ CodexBar — every AI coding limit, in your menu bar @@ -37,7 +37,7 @@ @@ -293,7 +293,7 @@

- 74 providers,{mobileBreak}one menu bar + 75 providers,{mobileBreak}one menu bar

Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus. diff --git a/docs/llms.txt b/docs/llms.txt index e3843df69e..b100d2c577 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,9 +1,9 @@ # CodexBar -A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 74 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 75 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Canonical documentation: -- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 74 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. +- CodexBar — every AI coding limit, in your menu bar: https://codexbar.app/ - A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 75 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more. Source: https://github.com/steipete/CodexBar diff --git a/docs/pi.md b/docs/pi.md new file mode 100644 index 0000000000..fe4c225cfa --- /dev/null +++ b/docs/pi.md @@ -0,0 +1,42 @@ +--- +summary: "Pi and OMP local token history, source selection, and cost accounting." +read_when: + - Configuring the Pi provider + - Debugging Pi or OMP session discovery and incomplete history +--- + +# Pi + +Enable Pi in Settings → Providers to show Pi and OMP token history as a separate local source in the menu, Usage & Spend, Overview, and widgets. Pi has no subscription quota or account balance. Costs are API-rate estimates from recorded assistant usage, not a billing statement. No provider login or credential is required to read the transcripts. + +The scanner currently supports the `openai-codex` and `anthropic` backends. Other backends keep standalone Pi history incomplete; they do not become measured zero or invalidate the supported Codex and Claude partitions. Supported usage with an unknown model retains its recorded tokens and is marked unpriced. Mixed priced and unpriced history preserves the known subtotal without presenting it as a complete cost. + +Cost collection can refresh the public [models.dev pricing catalog](model-pricing.md). Transcript contents stay local; the catalog request needs no credential. Existing cached prices and bundled rates remain available when a pricing refresh fails. + +## Session discovery + +Default session roots include `~/.pi/agent/sessions` and the supported OMP agent/profile stores. Discovery honors `PI_CODING_AGENT_DIR`, `PI_CODING_AGENT_SESSION_DIR`, OMP configuration/XDG roots, and `OMP_PROFILE` (or `PI_PROFILE` when absent). A named profile limits discovery to that profile. Invalid or unresolved explicit selectors produce incomplete history. + +Running Pi/OMP processes also contribute their environment, profile, `--session-dir`, and project settings. Relative paths resolve against that process's working directory. A missing working directory cannot turn an unresolved relative selector into a successful empty scan. Retained roots from explicit command-line or settings selectors survive process exit; settings are revalidated before reuse. Removing a setting from an accessible project drops its former root, while an inaccessible project or broken settings symlink preserves the previous scoped report and its original age. + +Assistant turns are bucketed by their own timestamp in the selected cost time zone. Matching entry IDs within the same session count once across overlapping roots. Distinct turns remain separate. The scanner retains per-message prices and token classes rather than repricing a daily aggregate. + +## Count each source once + +With Pi disabled, unscoped Claude and Codex history can include their supported Pi/OMP backend partitions. With Pi and local cost tracking enabled, the app shows native Claude/Codex history alongside standalone Pi. Combined CLI selections follow the same rule. Account-scoped Codex history always remains native because machine-local Pi history does not establish account ownership. Overview and Usage & Spend use the same source accounting, including during cached hydration and completed Codex catch-up. + +```bash +codexbar cost --provider pi --format json --pretty +codexbar cost --provider both --format json --pretty +codexbar cost --provider codex --provider-native-only +``` + +For a combined report, enable Claude, Codex, and Pi in the config, then run `codexbar cost --format json --pretty` without a provider override. Selecting Pi with Claude/Codex excludes mirrored Pi rows from those native providers. A standalone Claude/Codex selection (including `--provider both`) retains its existing inclusive behavior unless `--provider-native-only` is supplied. The same selection rule applies to dashboard and HTTP cost collection. + +## Cache and incomplete history + +The cache is `~/Library/Caches/CodexBar/cost-usage/pi-sessions-v9.json` on macOS. It records source scope, coverage, and unsupported-history evidence, and is replaced atomically on macOS and Linux. Version 8 is rebuilt once from transcripts because it did not record sufficient scope and completeness evidence for safe reuse. An unavailable source during this upgrade leaves history unavailable until a valid scan can complete; it does not borrow an old cache's timestamp or totals. + +Incomplete refreshes can preserve a previously valid report with its original source scope and scan time. Malformed records, truncated tails, inaccessible roots, and unrepresentable aggregate totals cannot advance cache freshness. Cached and debounced reads check the recorded file inventory and metadata before declaring coverage complete, without reparsing transcripts. Failed root transitions never combine old and new datasets. Missing optional Pi history leaves available native history explicitly incomplete and immediately eligible for another refresh. A verified empty source remains distinct from an uninspected source. + +Files replaced while being read leave the refresh incomplete. A pricing-catalog change requires a complete reparse before new estimates replace the prior report. An explicit refresh reparses Pi history even when file size and modification time are unchanged, including Pi usage shown under Claude or Codex. diff --git a/docs/provider-ids.md b/docs/provider-ids.md index 862210f4ea..d26574ca69 100644 --- a/docs/provider-ids.md +++ b/docs/provider-ids.md @@ -2,4 +2,4 @@ # Provider IDs -`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`, `nous`, `muse`, `coderabbit`, `replicate`, `huggingface`. +`codex`, `openai`, `azureopenai`, `claude`, `clinepass`, `cursor`, `opencode`, `opencodego`, `alibaba`, `alibabatokenplan`, `qwencloud`, `factory`, `fireworks`, `gemini`, `antigravity`, `copilot`, `devin`, `zai`, `minimax`, `manus`, `kimi`, `kilo`, `kiro`, `vertexai`, `augment`, `jetbrains`, `moonshot`, `amp`, `t3chat`, `ollama`, `synthetic`, `openrouter`, `elevenlabs`, `warp`, `windsurf`, `zed`, `perplexity`, `mimo`, `doubao`, `sakana`, `abacus`, `mistral`, `deepseek`, `deepinfra`, `codebuff`, `crof`, `venice`, `commandcode`, `qoder`, `stepfun`, `bedrock`, `grok`, `groq`, `llmproxy`, `litellm`, `deepgram`, `poe`, `chutes`, `neuralwatt`, `clawrouter`, `longcat`, `sub2api`, `wayfinder`, `zenmux`, `aiand`, `zoommate`, `xai`, `notion`, `ibmbob`, `nous`, `muse`, `coderabbit`, `replicate`, `huggingface`, `pi`. diff --git a/docs/providers.md b/docs/providers.md index 20383fd65a..96fe2baa14 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -8,7 +8,7 @@ read_when: # Providers -CodexBar currently registers 74 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or +CodexBar currently registers 75 provider IDs. Some companies expose multiple surfaces, such as Codex vs OpenAI API or OpenCode vs OpenCode Go, because the auth source and quota shape differ. ## Fetch strategies (current) @@ -134,6 +134,7 @@ complete when the available scan window covers fewer days. | Zed | Zed editor Keychain session → `cloud.zed.dev/client/users/me` for plan and quota data (`local`). | | Notion AI | Browser cookies → workspace resolution and the AI usage allowance API (`web`). | | IBM Bob | API key from config/env → profile and per-team Bobcoin budget APIs (`api`). | +| [Pi](pi.md) | Local Pi/OMP assistant transcripts → token history and API-rate cost estimates (`local`); no subscription quota. | ## Codex - App Auto: OAuth API first; falls back to CLI only when OAuth credentials are missing or auth/refresh is invalid. diff --git a/docs/site-locales.mjs b/docs/site-locales.mjs index 5365c2196b..15f1ce0f87 100644 --- a/docs/site-locales.mjs +++ b/docs/site-locales.mjs @@ -98,8 +98,8 @@ export const localeCatalog = [ export const localeMessages = { "en": { "meta.title": "CodexBar — every AI coding limit in your menu bar", - "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 74 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", - "meta.ogDescription": "Track usage windows, credits, and resets across 74 AI coding providers from your macOS menu bar.", + "meta.description": "A tiny macOS menu bar app that tracks AI coding-provider usage windows, credits, costs, and resets across 75 providers — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, and more.", + "meta.ogDescription": "Track usage windows, credits, and resets across 75 AI coding providers from your macOS menu bar.", "nav.primary": "Primary", "nav.language": "Language", "nav.docs": "docs", @@ -112,7 +112,7 @@ export const localeMessages = { "hero.description": "CodexBar tracks usage windows, credit balances, and reset countdowns across the providers you actually pay for — one status item each, or merge them into one.", "hero.download": "Download for macOS", "hero.fineprint": "Free & open source · macOS 14+ · Universal via GitHub Releases and Homebrew", - "providers.title": "74 providers,{mobileBreak}one menu bar", + "providers.title": "75 providers,{mobileBreak}one menu bar", "providers.description": "Popular providers become status items with their own usage windows, reset countdowns, charts, and provider menus.", "providers.yourProvider": "Your provider", "providers.authoringGuide": "Authoring guide", @@ -238,8 +238,8 @@ export const localeMessages = { }, "zh-CN": { "meta.title": "CodexBar — 菜单栏中的每个 AI 编码限制", - "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 74 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", - "meta.ogDescription": "从您的 macOS 菜单栏跟踪 74 个 AI 编码提供商的使用窗口、积分和重置。", + "meta.description": "一个微小的 macOS 菜单栏应用程序,可跟踪 75 个提供商(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 编码提供商使用窗口、积分、成本和重置。", + "meta.ogDescription": "从您的 macOS 菜单栏跟踪 75 个 AI 编码提供商的使用窗口、积分和重置。", "nav.primary": "基本的", "nav.language": "语言", "nav.docs": "文档", @@ -252,7 +252,7 @@ export const localeMessages = { "hero.description": "CodexBar 跟踪您实际付费的提供商的使用窗口、信用余额和重置倒计时 - 每个状态项一项,或将它们合并为一项。", "hero.download": "下载macOS", "hero.fineprint": "免费开源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "74 个提供商,{mobileBreak}一个菜单栏", + "providers.title": "75 个提供商,{mobileBreak}一个菜单栏", "providers.description": "受欢迎的提供商成为状态项目,具有自己的使用窗口、重置倒计时、图表和提供商菜单。", "providers.yourProvider": "您的提供商", "providers.authoringGuide": "创作指南", @@ -378,8 +378,8 @@ export const localeMessages = { }, "zh-TW": { "meta.title": "CodexBar — 功能表列中的每個 AI 編碼限制", - "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 74 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", - "meta.ogDescription": "從您的 macOS 功能表列追蹤 74 個 AI 編碼提供者的使用視窗、積分和重設。", + "meta.description": "一個微小的 macOS 功能表列應用程序,可追蹤 75 個提供者(Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM 等)的 AI 編碼提供者使用視窗、積分、成本和重設。", + "meta.ogDescription": "從您的 macOS 功能表列追蹤 75 個 AI 編碼提供者的使用視窗、積分和重設。", "nav.primary": "基本的", "nav.language": "語言", "nav.docs": "文件", @@ -392,7 +392,7 @@ export const localeMessages = { "hero.description": "CodexBar 追蹤您實際付費的提供者的使用視窗、信用餘額和重設倒數計時 - 每個狀態項一項,或將它們合併為一項。", "hero.download": "下載macOS", "hero.fineprint": "免費開源 · macOS 14+ · GitHub Releases 和 Homebrew 均提供通用版本", - "providers.title": "74 個提供者,{mobileBreak}一個選單列", + "providers.title": "75 個提供者,{mobileBreak}一個選單列", "providers.description": "受歡迎的提供者成為狀態項目,具有自己的使用視窗、重置倒數計時、圖表和提供者選單。", "providers.yourProvider": "您的提供者", "providers.authoringGuide": "創作指南", @@ -518,8 +518,8 @@ export const localeMessages = { }, "ja-JP": { "meta.title": "CodexBar — メニュー バーのすべての AI コーディング制限", - "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、74 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", - "meta.ogDescription": "macOS メニュー バーから、74 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", + "meta.description": "小さな macOS メニュー バー アプリ。Codex、OpenAI、Claude、Cursor、Gemini、Copilot、LiteLLM など、75 のプロバイダーにわたる AI コーディング プロバイダーの使用期間、クレジット、コスト、リセットを追跡します。", + "meta.ogDescription": "macOS メニュー バーから、75 の AI コーディング プロバイダーにわたる使用期間、クレジット、リセットを追跡します。", "nav.primary": "主要な", "nav.language": "言語", "nav.docs": "ドキュメント", @@ -532,7 +532,7 @@ export const localeMessages = { "hero.description": "CodexBar は、実際に料金を支払っているプロバイダー全体の使用期間、クレジット残高、リセット カウントダウンを追跡します。ステータス項目を 1 つずつ、または 1 つに統合します。", "hero.download": "macOS のダウンロード", "hero.fineprint": "無料・オープンソース · macOS 14+ · GitHub Releases と Homebrew でユニバーサル版を提供", - "providers.title": "74 プロバイダー、{mobileBreak}1つのメニューバー", + "providers.title": "75 プロバイダー、{mobileBreak}1つのメニューバー", "providers.description": "人気のあるプロバイダーは、独自の使用期間、リセット カウントダウン、グラフ、プロバイダー メニューを備えたステータス アイテムになります。", "providers.yourProvider": "あなたのプロバイダー", "providers.authoringGuide": "オーサリングガイド", @@ -658,8 +658,8 @@ export const localeMessages = { }, "es": { "meta.title": "CodexBar: cada límite de codificación de IA en tu barra de menú", - "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 74 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", - "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 74 proveedores de codificación de IA desde su barra de menú macOS.", + "meta.description": "Una pequeña aplicación de barra de menú macOS que rastrea las ventanas de uso, los créditos, los costos y los restablecimientos de los proveedores de codificación de IA en 75 proveedores: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM y más.", + "meta.ogDescription": "Realice un seguimiento de las ventanas de uso, los créditos y los restablecimientos en 75 proveedores de codificación de IA desde su barra de menú macOS.", "nav.primary": "Primario", "nav.language": "Idioma", "nav.docs": "Documentos", @@ -672,7 +672,7 @@ export const localeMessages = { "hero.description": "CodexBar realiza un seguimiento de los períodos de uso, los saldos de crédito y restablece las cuentas regresivas de los proveedores por los que realmente paga: un elemento de estado para cada uno o los fusiona en uno solo.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratis y de código abierto · macOS 14+ · Universal mediante GitHub Releases y Homebrew", - "providers.title": "74 proveedores,{mobileBreak}una barra de menús", + "providers.title": "75 proveedores,{mobileBreak}una barra de menús", "providers.description": "Los proveedores populares se convierten en elementos de estado con sus propias ventanas de uso, restablecen cuentas regresivas, gráficos y menús de proveedores.", "providers.yourProvider": "Tu proveedor", "providers.authoringGuide": "guía de autoría", @@ -798,8 +798,8 @@ export const localeMessages = { }, "pt-BR": { "meta.title": "CodexBar — todos os limites de codificação de IA na sua barra de menu", - "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 74 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", - "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 74 provedores de codificação de IA na barra de menu macOS.", + "meta.description": "Um pequeno aplicativo de barra de menu macOS que rastreia janelas de uso, créditos, custos e redefinições do provedor de codificação de IA em 75 provedores — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e muito mais.", + "meta.ogDescription": "Rastreie janelas de uso, créditos e redefinições em 75 provedores de codificação de IA na barra de menu macOS.", "nav.primary": "Primário", "nav.language": "Linguagem", "nav.docs": "Documentos", @@ -812,7 +812,7 @@ export const localeMessages = { "hero.description": "CodexBar rastreia janelas de uso, saldos de crédito e reinicia contagens regressivas nos provedores pelos quais você realmente paga - um item de status cada, ou mescla-os em um.", "hero.download": "Baixar para macOS", "hero.fineprint": "Gratuito e de código aberto · macOS 14+ · Universal via GitHub Releases e Homebrew", - "providers.title": "74 provedores,{mobileBreak}uma barra de menu", + "providers.title": "75 provedores,{mobileBreak}uma barra de menu", "providers.description": "Provedores populares tornam-se itens de status com suas próprias janelas de uso, reiniciam contagens regressivas, gráficos e menus de provedores.", "providers.yourProvider": "Seu provedor", "providers.authoringGuide": "Guia de autoria", @@ -938,8 +938,8 @@ export const localeMessages = { }, "ko": { "meta.title": "CodexBar — 메뉴 표시줄의 모든 AI 코딩 제한", - "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 74개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", - "meta.ogDescription": "macOS 메뉴 표시줄에서 74개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", + "meta.description": "Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM 등 75개 제공자 전체에서 AI 코딩 제공자 사용 창, 크레딧, 비용 및 재설정을 추적하는 작은 macOS 메뉴 표시줄 앱입니다.", + "meta.ogDescription": "macOS 메뉴 표시줄에서 75개 AI 코딩 제공업체의 사용 기간, 크레딧 및 재설정을 추적하세요.", "nav.primary": "주요한", "nav.language": "언어", "nav.docs": "문서", @@ -952,7 +952,7 @@ export const localeMessages = { "hero.description": "CodexBar는 귀하가 실제로 비용을 지불한 제공업체 전반에 걸쳐 사용 기간, 크레딧 잔액 및 재설정 카운트다운을 추적합니다. 즉, 각각 하나의 상태 항목 또는 하나로 병합됩니다.", "hero.download": "macOS 동안 다운로드", "hero.fineprint": "무료 오픈 소스 · macOS 14+ · GitHub Releases와 Homebrew에서 유니버설 제공", - "providers.title": "74개 제공자,{mobileBreak}하나의 메뉴 막대", + "providers.title": "75개 제공자,{mobileBreak}하나의 메뉴 막대", "providers.description": "인기 있는 제공업체는 자체 사용 창, 재설정 카운트다운, 차트 및 제공업체 메뉴를 갖춘 상태 항목이 됩니다.", "providers.yourProvider": "귀하의 제공자", "providers.authoringGuide": "저작 가이드", @@ -1078,8 +1078,8 @@ export const localeMessages = { }, "de": { "meta.title": "CodexBar – alle Limits Ihrer KI-Coding-Tools in der Menüleiste", - "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 74 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", - "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 74 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", + "meta.description": "Eine kleine macOS-Menüleisten-App, die Nutzungslimits, Guthaben, Kosten und Resets von 75 KI-Coding-Anbietern im Blick behält – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM und mehr.", + "meta.ogDescription": "Nutzungslimits, Guthaben und Resets von 75 KI-Coding-Anbietern direkt in Ihrer macOS-Menüleiste.", "nav.primary": "Primär", "nav.language": "Sprache", "nav.docs": "Dokumente", @@ -1092,7 +1092,7 @@ export const localeMessages = { "hero.description": "CodexBar verfolgt Nutzungsfenster, Guthaben und Reset-Countdowns bei den Anbietern, für die Sie tatsächlich bezahlen – jeweils ein Statuselement oder sie werden zu einem zusammengeführt.", "hero.download": "Herunterladen für macOS", "hero.fineprint": "Kostenlos und Open Source · macOS 14+ · Universal über GitHub Releases und Homebrew", - "providers.title": "74 Provider,{mobileBreak}eine Menüleiste", + "providers.title": "75 Provider,{mobileBreak}eine Menüleiste", "providers.description": "Beliebte Anbieter werden zu Statuselementen mit eigenen Nutzungsfenstern, Reset-Countdowns, Diagrammen und Anbietermenüs.", "providers.yourProvider": "Ihr Anbieter", "providers.authoringGuide": "Autorenleitfaden", @@ -1218,8 +1218,8 @@ export const localeMessages = { }, "fr": { "meta.title": "CodexBar — chaque limite de codage IA dans votre barre de menus", - "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 74 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", - "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 74 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", + "meta.description": "Une petite application de barre de menus macOS qui suit les fenêtres d'utilisation, les crédits, les coûts et les réinitialisations des fournisseurs de codage d'IA sur 75 fournisseurs : Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, et plus encore.", + "meta.ogDescription": "Suivez les fenêtres d'utilisation, les crédits et les réinitialisations auprès de 75 fournisseurs de codage d'IA à partir de votre barre de menus macOS.", "nav.primary": "Primaire", "nav.language": "Langue", "nav.docs": "Documents", @@ -1232,7 +1232,7 @@ export const localeMessages = { "hero.description": "CodexBar suit les fenêtres d'utilisation, les soldes créditeurs et réinitialise les comptes à rebours pour les fournisseurs pour lesquels vous payez réellement - un élément de statut chacun, ou les fusionne en un seul.", "hero.download": "Télécharger pour macOS", "hero.fineprint": "Gratuit et open source · macOS 14+ · Universel via GitHub Releases et Homebrew", - "providers.title": "74 fournisseurs,{mobileBreak}une barre des menus", + "providers.title": "75 fournisseurs,{mobileBreak}une barre des menus", "providers.description": "Les fournisseurs populaires deviennent des éléments de statut avec leurs propres fenêtres d'utilisation, réinitialisent les comptes à rebours, les graphiques et les menus des fournisseurs.", "providers.yourProvider": "Votre fournisseur", "providers.authoringGuide": "Guide de création", @@ -1358,8 +1358,8 @@ export const localeMessages = { }, "ar": { "meta.title": "CodexBar — كل حد لترميز الذكاء الاصطناعي في شريط القائمة", - "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 74 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", - "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 74 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", + "meta.description": "تطبيق شريط قوائم macOS صغير الحجم يتتبع نوافذ استخدام موفر ترميز الذكاء الاصطناعي، والائتمانات، والتكاليف، وعمليات إعادة التعيين عبر 75 موفرًا - Codex، وOpenAI، وClaude، وCursor، وGemini، وCopilot، وLiteLLM، والمزيد.", + "meta.ogDescription": "تتبع نوافذ الاستخدام والأرصدة وعمليات إعادة التعيين عبر 75 موفرًا لترميز الذكاء الاصطناعي من شريط القائمة macOS.", "nav.primary": "أساسي", "nav.language": "لغة", "nav.docs": "المستندات", @@ -1372,7 +1372,7 @@ export const localeMessages = { "hero.description": "يتتبع CodexBar فترات الاستخدام والأرصدة الائتمانية وإعادة تعيين العد التنازلي عبر مقدمي الخدمة الذين تدفع مقابلهم فعليًا — عنصر حالة واحد لكل منهم، أو دمجهم في عنصر واحد.", "hero.download": "التنزيل لمدة macOS", "hero.fineprint": "مجاني ومفتوح المصدر · macOS 14+ · إصدار شامل عبر GitHub Releases وHomebrew", - "providers.title": "74 مزودًا،{mobileBreak}شريط قوائم واحد", + "providers.title": "75 مزودًا،{mobileBreak}شريط قوائم واحد", "providers.description": "يصبح الموفرون المشهورون عناصر حالة مع نوافذ الاستخدام الخاصة بهم، وعمليات إعادة تعيين العد التنازلي، والمخططات، وقوائم الموفر.", "providers.yourProvider": "المزود الخاص بك", "providers.authoringGuide": "دليل التأليف", @@ -1498,8 +1498,8 @@ export const localeMessages = { }, "it": { "meta.title": "CodexBar: ogni limite di codifica AI nella barra dei menu", - "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 74 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", - "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 74 fornitori di codifica AI dalla barra dei menu macOS.", + "meta.description": "Una piccola app della barra dei menu macOS che tiene traccia delle finestre di utilizzo, dei crediti, dei costi e dei ripristini del fornitore di codifica AI tra 75 fornitori: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e altri.", + "meta.ogDescription": "Tieni traccia delle finestre di utilizzo, dei crediti e dei ripristini tra 75 fornitori di codifica AI dalla barra dei menu macOS.", "nav.primary": "Primario", "nav.language": "Lingua", "nav.docs": "Documenti", @@ -1512,7 +1512,7 @@ export const localeMessages = { "hero.description": "CodexBar tiene traccia delle finestre di utilizzo, dei saldi del credito e reimposta i conti alla rovescia tra i fornitori per cui paghi effettivamente: un elemento di stato ciascuno o uniscili in uno solo.", "hero.download": "Scarica per macOS", "hero.fineprint": "Gratuito e open source · macOS 14+ · Universale tramite GitHub Releases e Homebrew", - "providers.title": "74 provider,{mobileBreak}una barra dei menu", + "providers.title": "75 provider,{mobileBreak}una barra dei menu", "providers.description": "I fornitori più popolari diventano elementi di stato con le proprie finestre di utilizzo, reimpostano i conti alla rovescia, i grafici e i menu dei fornitori.", "providers.yourProvider": "Il tuo fornitore", "providers.authoringGuide": "Guida all'autore", @@ -1638,8 +1638,8 @@ export const localeMessages = { }, "vi": { "meta.title": "CodexBar — mọi giới hạn mã hóa AI trong thanh menu của bạn", - "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 74 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", - "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 74 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", + "meta.description": "Một ứng dụng thanh menu macOS nhỏ theo dõi khoảng thời gian sử dụng, tín dụng, chi phí và đặt lại của nhà cung cấp mã hóa AI trên 75 nhà cung cấp — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, v.v.", + "meta.ogDescription": "Theo dõi khoảng thời gian sử dụng, tín dụng và đặt lại trên 75 nhà cung cấp mã hóa AI từ thanh menu macOS của bạn.", "nav.primary": "Sơ đẳng", "nav.language": "Ngôn ngữ", "nav.docs": "Tài liệu", @@ -1652,7 +1652,7 @@ export const localeMessages = { "hero.description": "CodexBar theo dõi khoảng thời gian sử dụng, số dư tín dụng và đếm ngược đặt lại trên các nhà cung cấp mà bạn thực sự thanh toán — mỗi mục một trạng thái hoặc hợp nhất chúng thành một.", "hero.download": "Tải xuống cho macOS", "hero.fineprint": "Miễn phí và nguồn mở · macOS 14+ · Bản universal qua GitHub Releases và Homebrew", - "providers.title": "74 nhà cung cấp,{mobileBreak}một thanh menu", + "providers.title": "75 nhà cung cấp,{mobileBreak}một thanh menu", "providers.description": "Các nhà cung cấp phổ biến trở thành các mục trạng thái với cửa sổ sử dụng của riêng họ, đặt lại bộ đếm ngược, biểu đồ và menu nhà cung cấp.", "providers.yourProvider": "Nhà cung cấp của bạn", "providers.authoringGuide": "Hướng dẫn soạn thảo", @@ -1778,8 +1778,8 @@ export const localeMessages = { }, "nl": { "meta.title": "CodexBar — elke AI-coderingslimiet in uw menubalk", - "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 74 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", - "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 74 leveranciers van AI-codering via uw macOS-menubalk.", + "meta.description": "Een kleine macOS menubalk-app die gebruiksperioden, tegoeden, kosten en resets van AI-coderingsproviders bijhoudt bij 75 providers: Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM en meer.", + "meta.ogDescription": "Houd gebruiksvensters, tegoeden en resets bij van 75 leveranciers van AI-codering via uw macOS-menubalk.", "nav.primary": "Primair", "nav.language": "Taal", "nav.docs": "Documenten", @@ -1792,7 +1792,7 @@ export const localeMessages = { "hero.description": "CodexBar houdt gebruiksperioden, tegoeden en reset-countdowns bij voor de providers waarvoor u daadwerkelijk betaalt: elk één statusitem, of u kunt ze samenvoegen tot één statusitem.", "hero.download": "Downloaden voor macOS", "hero.fineprint": "Gratis en open source · macOS 14+ · Universeel via GitHub Releases en Homebrew", - "providers.title": "74 providers,{mobileBreak}één menubalk", + "providers.title": "75 providers,{mobileBreak}één menubalk", "providers.description": "Populaire providers worden statusitems met hun eigen gebruiksvensters, resetcountdowns, grafieken en providermenu's.", "providers.yourProvider": "Uw aanbieder", "providers.authoringGuide": "Handleiding voor het schrijven", @@ -1918,8 +1918,8 @@ export const localeMessages = { }, "tr": { "meta.title": "CodexBar — menü çubuğunuzdaki tüm AI kodlama limitleri", - "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 74 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", - "meta.ogDescription": "macOS menü çubuğunu kullanarak 74 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", + "meta.description": "AI kodlama sağlayıcısı kullanım pencerelerini, kredilerini, maliyetlerini izleyen ve Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM ve daha fazlası olmak üzere 75 sağlayıcı genelinde sıfırlamaları izleyen küçük bir macOS menü çubuğu uygulaması.", + "meta.ogDescription": "macOS menü çubuğunu kullanarak 75 AI kodlama sağlayıcısındaki kullanım pencerelerini, kredileri ve sıfırlamaları izleyin.", "nav.primary": "Öncelik", "nav.language": "Dil", "nav.docs": "Dokümanlar", @@ -1932,7 +1932,7 @@ export const localeMessages = { "hero.description": "CodexBar, gerçekte ödeme yaptığınız sağlayıcılar genelinde kullanım pencerelerini, kredi bakiyelerini ve geri sayımları sıfırlar (her biri bir durum öğesi olacak şekilde) izler veya bunları tek bir öğede birleştirir.", "hero.download": "macOS için indirin", "hero.fineprint": "Ücretsiz ve açık kaynak · macOS 14+ · GitHub Releases ve Homebrew ile evrensel sürüm", - "providers.title": "74 sağlayıcı,{mobileBreak}bir menü çubuğu", + "providers.title": "75 sağlayıcı,{mobileBreak}bir menü çubuğu", "providers.description": "Popüler sağlayıcılar, kendi kullanım pencereleri, sıfırlama geri sayımları, çizelgeleri ve sağlayıcı menüleriyle durum öğeleri haline gelir.", "providers.yourProvider": "Sağlayıcınız", "providers.authoringGuide": "Yazma kılavuzu", @@ -2058,8 +2058,8 @@ export const localeMessages = { }, "uk": { "meta.title": "CodexBar — кожне обмеження кодування AI у вашій панелі меню", - "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 74 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", - "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 74 постачальників кодування ШІ за допомогою панелі меню macOS.", + "meta.description": "Маленький додаток macOS на панелі меню, який відстежує вікна використання постачальників кодування штучного інтелекту, кредити, витрати та скидання 75 постачальників — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM тощо.", + "meta.ogDescription": "Відстежуйте вікна використання, кредити та скидання 75 постачальників кодування ШІ за допомогою панелі меню macOS.", "nav.primary": "Первинний", "nav.language": "Мова", "nav.docs": "документи", @@ -2072,7 +2072,7 @@ export const localeMessages = { "hero.description": "CodexBar відстежує вікна використання, кредитні баланси та скидає зворотний відлік для постачальників, за яких ви фактично платите, — по одному статусу для кожного або об’єднує їх в один.", "hero.download": "Завантажити для macOS", "hero.fineprint": "Безкоштовний і відкритий код · macOS 14+ · Універсальна версія через GitHub Releases і Homebrew", - "providers.title": "74 провайдерів,{mobileBreak}одна панель меню", + "providers.title": "75 провайдерів,{mobileBreak}одна панель меню", "providers.description": "Популярні постачальники стають елементами статусу з власними вікнами використання, скиданням зворотного відліку, діаграмами та меню постачальників.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Авторський посібник", @@ -2198,8 +2198,8 @@ export const localeMessages = { }, "ru": { "meta.title": "CodexBar — все лимиты AI-кодинга в вашей строке меню", - "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 74 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", - "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 74 AI-провайдеров для кодинга прямо из строки меню macOS.", + "meta.description": "Небольшое приложение для строки меню macOS, которое отслеживает окна использования, кредиты, расходы и сбросы лимитов у 75 AI-провайдеров для кодинга — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM и других.", + "meta.ogDescription": "Отслеживайте окна использования, кредиты и сбросы лимитов у 75 AI-провайдеров для кодинга прямо из строки меню macOS.", "nav.primary": "Основная навигация", "nav.language": "Язык", "nav.docs": "Документация", @@ -2212,7 +2212,7 @@ export const localeMessages = { "hero.description": "CodexBar отслеживает окна использования, балансы кредитов и обратный отсчет до сброса у провайдеров, за которых вы действительно платите, — по одному элементу статуса на каждого или все вместе в одном.", "hero.download": "Скачать для macOS", "hero.fineprint": "Бесплатно и с открытым исходным кодом · macOS 14+ · универсальная сборка через GitHub Releases и Homebrew", - "providers.title": "74 провайдеров,{mobileBreak}одна строка меню", + "providers.title": "75 провайдеров,{mobileBreak}одна строка меню", "providers.description": "Популярные провайдеры становятся элементами статуса со своими окнами использования, обратным отсчетом до сброса, графиками и меню провайдера.", "providers.yourProvider": "Ваш провайдер", "providers.authoringGuide": "Руководство по добавлению", @@ -2338,8 +2338,8 @@ export const localeMessages = { }, "id": { "meta.title": "CodexBar — setiap batas pengkodean AI di bilah menu Anda", - "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 74 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", - "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 74 penyedia pengkodean AI dari bilah menu macOS Anda.", + "meta.description": "Aplikasi bilah menu macOS kecil yang melacak periode penggunaan, kredit, biaya, dan penyetelan ulang penyedia pengkodean AI di 75 penyedia — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM, dan banyak lagi.", + "meta.ogDescription": "Lacak jangka waktu penggunaan, kredit, dan penyetelan ulang di 75 penyedia pengkodean AI dari bilah menu macOS Anda.", "nav.primary": "Utama", "nav.language": "Bahasa", "nav.docs": "dokumen", @@ -2352,7 +2352,7 @@ export const localeMessages = { "hero.description": "CodexBar melacak jangka waktu penggunaan, saldo kredit, dan hitungan mundur penyetelan ulang di seluruh penyedia yang sebenarnya Anda bayar — masing-masing satu item status, atau gabungkan menjadi satu.", "hero.download": "Unduh untuk macOS", "hero.fineprint": "Gratis dan sumber terbuka · macOS 14+ · Universal melalui GitHub Releases dan Homebrew", - "providers.title": "74 penyedia,{mobileBreak}satu bilah menu", + "providers.title": "75 penyedia,{mobileBreak}satu bilah menu", "providers.description": "Penyedia populer menjadi item status dengan jendela penggunaannya sendiri, hitung mundur pengaturan ulang, bagan, dan menu penyedia.", "providers.yourProvider": "Penyedia Anda", "providers.authoringGuide": "Panduan penulisan", @@ -2478,8 +2478,8 @@ export const localeMessages = { }, "pl": { "meta.title": "CodexBar — każdy limit kodowania AI na pasku menu", - "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 74 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", - "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 74 dostawców kodowania AI za pomocą paska menu macOS.", + "meta.description": "Mała aplikacja z paskiem menu macOS, która śledzi okna użycia dostawcy kodowania AI, kredyty, koszty i resety u 75 dostawców — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i nie tylko.", + "meta.ogDescription": "Śledź okresy użytkowania, kredyty i resety u 75 dostawców kodowania AI za pomocą paska menu macOS.", "nav.primary": "Podstawowy", "nav.language": "Język", "nav.docs": "Dokumenty", @@ -2492,7 +2492,7 @@ export const localeMessages = { "hero.description": "CodexBar śledzi okna użytkowania, salda kredytów i resetuje odliczanie u dostawców, za których faktycznie płacisz — po jednym statusie dla każdego lub połącz je w jeden.", "hero.download": "Pobierz dla macOS", "hero.fineprint": "Darmowe i otwarte oprogramowanie · macOS 14+ · Wersja uniwersalna przez GitHub Releases i Homebrew", - "providers.title": "74 dostawców,{mobileBreak}jeden pasek menu", + "providers.title": "75 dostawców,{mobileBreak}jeden pasek menu", "providers.description": "Popularni dostawcy stają się elementami statusu z własnymi oknami użytkowania, resetowaniem odliczania, wykresami i menu dostawców.", "providers.yourProvider": "Twój dostawca", "providers.authoringGuide": "Przewodnik autorski", @@ -2618,8 +2618,8 @@ export const localeMessages = { }, "fa": { "meta.title": "CodexBar - هر محدودیت کدنویسی هوش مصنوعی در نوار منو شما", - "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 74 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", - "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 74 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", + "meta.description": "یک برنامه نوار منو کوچک macOS که پنجره‌های استفاده از ارائه‌دهنده کدنویسی هوش مصنوعی، اعتبارات، هزینه‌ها، و بازنشانی را در بین 75 ارائه‌دهنده - Codex، OpenAI، Claude، Cursor، Gemini، Copilot، LiteLLM و موارد دیگر بازنشانی می‌کند.", + "meta.ogDescription": "پنجره‌های استفاده، اعتبارات و بازنشانی‌ها را در بین 75 ارائه‌دهنده کدنویسی هوش مصنوعی از نوار منوی macOS خود ردیابی کنید.", "nav.primary": "اولیه", "nav.language": "زبان", "nav.docs": "اسناد", @@ -2632,7 +2632,7 @@ export const localeMessages = { "hero.description": "CodexBar پنجره‌های استفاده، مانده اعتبار و بازنشانی شمارش معکوس را در سراسر ارائه‌دهندگانی که واقعاً برایشان پول پرداخت می‌کنید ردیابی می‌کند - هر کدام یک مورد وضعیت، یا آنها را در یکی ادغام کنید.", "hero.download": "دانلود برای macOS", "hero.fineprint": "رایگان و متن‌باز · macOS 14+ · نسخهٔ یونیورسال از GitHub Releases و Homebrew", - "providers.title": "74 ارائه دهنده،{mobileBreak}یک نوار منو", + "providers.title": "75 ارائه دهنده،{mobileBreak}یک نوار منو", "providers.description": "ارائه‌دهندگان محبوب با پنجره‌های استفاده خاص خود، شمارش معکوس، نمودارها و منوهای ارائه‌دهنده را بازنشانی می‌کنند.", "providers.yourProvider": "ارائه دهنده شما", "providers.authoringGuide": "راهنمای نگارش", @@ -2758,8 +2758,8 @@ export const localeMessages = { }, "th": { "meta.title": "CodexBar — ทุกขีดจำกัดการเข้ารหัส AI ในแถบเมนูของคุณ", - "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 74 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", - "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 74 รายจากแถบเมนู macOS", + "meta.description": "แอปแถบเมนู macOS ขนาดเล็กที่ติดตามกรอบเวลาการใช้งานของผู้ให้บริการเข้ารหัส AI เครดิต ต้นทุน และการรีเซ็ตในผู้ให้บริการ 75 ราย — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM และอีกมากมาย", + "meta.ogDescription": "ติดตามกรอบเวลาการใช้งาน เครดิต และการรีเซ็ตในผู้ให้บริการการเข้ารหัส AI 75 รายจากแถบเมนู macOS", "nav.primary": "หลัก", "nav.language": "ภาษา", "nav.docs": "เอกสาร", @@ -2772,7 +2772,7 @@ export const localeMessages = { "hero.description": "CodexBar ติดตามกรอบเวลาการใช้งาน ยอดเครดิต และรีเซ็ตการนับถอยหลังของผู้ให้บริการที่คุณชำระเงินจริง — รายการสถานะแต่ละรายการ หรือรวมรายการเหล่านั้นเป็นรายการเดียว", "hero.download": "ดาวน์โหลดสำหรับ macOS", "hero.fineprint": "ฟรีและโอเพ่นซอร์ส · macOS 14+ · รุ่น Universal ผ่าน GitHub Releases และ Homebrew", - "providers.title": "ผู้ให้บริการ 74 ราย{mobileBreak}หนึ่งแถบเมนู", + "providers.title": "ผู้ให้บริการ 75 ราย{mobileBreak}หนึ่งแถบเมนู", "providers.description": "ผู้ให้บริการยอดนิยมจะกลายเป็นรายการสถานะที่มีหน้าต่างการใช้งานของตนเอง รีเซ็ตการนับถอยหลัง แผนภูมิ และเมนูของผู้ให้บริการ", "providers.yourProvider": "ผู้ให้บริการของคุณ", "providers.authoringGuide": "คู่มือการเขียน", @@ -2898,8 +2898,8 @@ export const localeMessages = { }, "gl": { "meta.title": "CodexBar — todos os límites de programación con IA na túa barra de menús", - "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 74 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", - "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 74 provedores de programación con IA desde a barra de menús de macOS.", + "meta.description": "Unha pequena aplicación de barra de menús para macOS que controla as xanelas de uso, os créditos, os custos e os restablecementos de 75 provedores de programación con IA — Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM e máis.", + "meta.ogDescription": "Controla as xanelas de uso, os créditos e os restablecementos de 75 provedores de programación con IA desde a barra de menús de macOS.", "nav.primary": "Principal", "nav.language": "Idioma", "nav.docs": "Documentación", @@ -2912,7 +2912,7 @@ export const localeMessages = { "hero.description": "CodexBar controla as xanelas de uso, os saldos de crédito e as contas atrás ata o restablecemento dos provedores polos que realmente pagas — un elemento de estado para cada un ou todos combinados nun só.", "hero.download": "Descargar para macOS", "hero.fineprint": "Gratuíto e de código aberto · macOS 14+ · Universal mediante GitHub Releases e Homebrew", - "providers.title": "74 provedores,{mobileBreak}unha barra de menús", + "providers.title": "75 provedores,{mobileBreak}unha barra de menús", "providers.description": "Os provedores populares convértense en elementos de estado coas súas propias xanelas de uso, contas atrás de restablecemento, gráficas e menús.", "providers.yourProvider": "O teu provedor", "providers.authoringGuide": "Guía de creación", @@ -3038,8 +3038,8 @@ export const localeMessages = { }, "ca": { "meta.title": "CodexBar: tots els límits de la programació amb IA a la barra de menús", - "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 74 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", - "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 74 proveïdors de programació amb IA des de la barra de menús del macOS.", + "meta.description": "Una petita aplicació de barra de menús per a macOS que fa un seguiment de les finestres d'ús, els crèdits, els costos i els restabliments dels proveïdors de programació amb IA: 75 proveïdors, entre els quals Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM i més.", + "meta.ogDescription": "Feu el seguiment de les finestres d'ús, els crèdits i els restabliments de 75 proveïdors de programació amb IA des de la barra de menús del macOS.", "nav.primary": "Navegació principal", "nav.language": "Llengua", "nav.docs": "Documentació", @@ -3052,7 +3052,7 @@ export const localeMessages = { "hero.description": "El CodexBar fa el seguiment de les finestres d'ús, els saldos de crèdit i els comptes enrere fins al restabliment dels proveïdors pels quals realment pagueu: un element d'estat per a cadascun, o combineu-los tots en un de sol.", "hero.download": "Baixeu per al macOS", "hero.fineprint": "Gratuït i de codi obert · macOS 14+ · Universal mitjançant GitHub Releases i Homebrew", - "providers.title": "74 proveïdors,{mobileBreak}una barra de menús", + "providers.title": "75 proveïdors,{mobileBreak}una barra de menús", "providers.description": "Els proveïdors populars es converteixen en elements d'estat amb les seves pròpies finestres d'ús, comptes enrere de restabliment, gràfics i menús de proveïdor.", "providers.yourProvider": "El vostre proveïdor", "providers.authoringGuide": "Guia de creació", @@ -3178,8 +3178,8 @@ export const localeMessages = { }, "sv": { "meta.title": "CodexBar — varje AI-kodningsgräns i din menyrad", - "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 74 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", - "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 74 AI-kodningsleverantörer från din macOS-menyrad.", + "meta.description": "En liten macOS menyradsapp som spårar AI-kodningsleverantörers användningsfönster, krediter, kostnader och återställningar hos 75 leverantörer – Codex, OpenAI, Claude, Cursor, Gemini, Copilot, LiteLLM och mer.", + "meta.ogDescription": "Spåra användningsfönster, krediter och återställningar hos 75 AI-kodningsleverantörer från din macOS-menyrad.", "nav.primary": "Primär", "nav.language": "Språk", "nav.docs": "Dokument", @@ -3192,7 +3192,7 @@ export const localeMessages = { "hero.description": "CodexBar spårar användningsfönster, kreditsaldon och återställningsnedräkningar för de leverantörer du faktiskt betalar för – en statuspost var, eller slå samman dem till en.", "hero.download": "Ladda ner för macOS", "hero.fineprint": "Gratis och öppen källkod · macOS 14+ · Universal via GitHub Releases och Homebrew", - "providers.title": "74 leverantörer,{mobileBreak}en menyrad", + "providers.title": "75 leverantörer,{mobileBreak}en menyrad", "providers.description": "Populära leverantörer blir statusobjekt med sina egna användningsfönster, återställer nedräkningar, diagram och leverantörsmenyer.", "providers.yourProvider": "Din leverantör", "providers.authoringGuide": "Författarguide", diff --git a/docs/social.html b/docs/social.html index 0782c7415a..de76117479 100644 --- a/docs/social.html +++ b/docs/social.html @@ -199,7 +199,7 @@

Every AI coding limit, in your menu bar.

-

74 providers·usage windows, credits, resets·one status item each, or merged.

+

75 providers·usage windows, credits, resets·one status item each, or merged.

    diff --git a/docs/widgets.md b/docs/widgets.md index 91d108063b..ccf4d72f5e 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -111,7 +111,7 @@ identifiers, signing team, and app group: ## Provider picker support The configurable provider widgets currently expose: Codex, Claude, Gemini, Alibaba, Alibaba Token Plan, Qwen Cloud, Antigravity, Cursor, z.ai / GLM, -Copilot, Devin, MiniMax, Kilo, OpenCode, OpenCode Go, Mistral, Kimi Code, DeepSeek, and OpenRouter. +Copilot, Devin, MiniMax, Kilo, OpenCode, OpenCode Go, Mistral, Kimi Code, DeepSeek, OpenRouter, and Pi. DeepSeek shows its credit balance without a quota bar because it reports no quota denominator. OpenRouter shows its remaining credits alongside a configured API-key limit, or as the headline when