diff --git a/README.md b/README.md index 2727f6bc..2115dc51 100644 --- a/README.md +++ b/README.md @@ -143,8 +143,9 @@ global selector; project configs do not pin specific accounts. Account display-n rerolls are also global Decodex state under `[codex.account_names.offsets]` in `~/.codex/decodex/config.toml` so the operator dashboard and Decodex App show the same privacy-preserving names. Client-only presentation preferences such as theme, sorting, -and whether identities are hidden remain local to each UI. Usage probes keep bounded -seven-day account usage estimates in +and whether identities are hidden remain local to each UI. Usage probes also read +Codex profile token stats for local Accounts displays. Bounded seven-day account +usage estimates are kept in `~/.codex/decodex/account-usage-history.jsonl`; the file stores daily percentage snapshots plus non-secret capacity weights for local display and no token material. To switch the account used by the Codex CLI itself, run diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index a73b0202..665bdd5b 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -273,6 +273,10 @@ struct AccountPanelView: View { header accountSummary + if let profileAggregate = accountProfileAggregate { + AccountProfileOverviewView(aggregate: profileAggregate) + } + if let usageEstimate = store.accountList?.usageEstimate { AccountPoolUsageEstimateView(estimate: usageEstimate, accounts: store.accounts) } @@ -403,7 +407,7 @@ struct AccountPanelView: View { } private var accountSummary: some View { - HStack(spacing: 7) { + HStack(alignment: .firstTextBaseline, spacing: 7) { SummaryTileView( title: "Codex", value: codexAuthLabel, @@ -414,6 +418,9 @@ struct AccountPanelView: View { Rectangle() .fill(PanelPalette.separator(colorScheme)) .frame(width: 0.5, height: 16) + .alignmentGuide(.firstTextBaseline) { dimensions in + dimensions[VerticalAlignment.center] + 4 + } SummaryTileView( title: "Runs", @@ -562,11 +569,7 @@ struct AccountPanelView: View { return "To \(AccountDisplay.compactIdentity(selector))" } - if control.mode == "balanced" { - return "Balanced" - } - - return control.mode.replacingOccurrences(of: "_", with: " ").capitalized + return control.mode } private var hasFixedSelection: Bool { @@ -612,6 +615,9 @@ struct AccountPanelView: View { if store.accountList?.usageEstimate != nil { height += AccountPanelLayout.sectionSpacing + AccountPanelLayout.poolUsageHeight } + if accountProfileAggregate != nil { + height += AccountPanelLayout.sectionSpacing + AccountPanelLayout.profileOverviewHeight + } if let snapshot = store.operatorSnapshot, snapshot.shouldDisplayInPanel { height += AccountPanelLayout.sectionSpacing + (snapshot.warningSummary == nil @@ -648,6 +654,10 @@ struct AccountPanelView: View { accountPrivacy != AccountPrivacy.visibleValue } + private var accountProfileAggregate: AccountProfileAggregate? { + AccountProfileAggregate.make(accounts: store.accounts) + } + private func displayName(for account: CodexAccount) -> String { if emailsHidden { return AccountDisplay.aliases(for: store.accounts)[account.id] @@ -669,8 +679,11 @@ struct AccountPanelView: View { base = 48 } let runSignal: CGFloat = operatorRuns(for: account).isEmpty ? 0 : 22 + let profileSignal: CGFloat = account.hasProfileSummary + ? (account.recentProfileDailyUsage.isEmpty ? 19 : 35) + : 0 - return base + runSignal + return base + runSignal + profileSignal } private func account(matching selector: String) -> CodexAccount? { @@ -896,6 +909,7 @@ private enum AccountPanelLayout { static let sectionSpacing: CGFloat = 6 static let headerHeight: CGFloat = 28 static let accountSummaryHeight: CGFloat = 31 + static let profileOverviewHeight: CGFloat = 62 static let poolUsageHeight: CGFloat = 58 static let operatorStatusHeight: CGFloat = 42 static let operatorStatusHeightWithWarning: CGFloat = 63 @@ -1025,6 +1039,167 @@ struct AccountRunChipView: View { } } +private struct AccountProfileAggregate: Equatable { + let accountCount: Int + let lifetimeTokens: Int? + let peakDailyTokens: Int? + let longestTaskSeconds: Int? + let currentStreakDays: Int? + let longestStreakDays: Int? + let dailyUsage: [AccountProfileDailyUsage] + + static func make(accounts: [CodexAccount]) -> AccountProfileAggregate? { + var lifetimeTokens: Int? + var peakFallbackTokens: Int? + var longestTaskSeconds: Int? + var currentStreakDays: Int? + var longestStreakDays: Int? + var usageByDate: [String: Int] = [:] + + for account in accounts { + if let value = account.profileLifetimeTokens { + lifetimeTokens = (lifetimeTokens ?? 0) + value + } + if let value = account.profilePeakDailyTokens { + peakFallbackTokens = (peakFallbackTokens ?? 0) + value + } + if let value = account.profileLongestTaskSeconds { + longestTaskSeconds = max(longestTaskSeconds ?? 0, value) + } + if let value = account.profileCurrentStreakDays { + currentStreakDays = max(currentStreakDays ?? 0, value) + } + if let value = account.profileLongestStreakDays { + longestStreakDays = max(longestStreakDays ?? 0, value) + } + for record in account.recentProfileDailyUsage { + usageByDate[record.date, default: 0] += record.tokens + } + } + + let dailyUsage = usageByDate + .map { AccountProfileDailyUsage(date: $0.key, tokens: $0.value) } + .sorted { $0.date < $1.date } + let peakDailyTokens = dailyUsage.map(\.tokens).max() ?? peakFallbackTokens + let aggregate = AccountProfileAggregate( + accountCount: accounts.count, + lifetimeTokens: lifetimeTokens, + peakDailyTokens: peakDailyTokens, + longestTaskSeconds: longestTaskSeconds, + currentStreakDays: currentStreakDays, + longestStreakDays: longestStreakDays, + dailyUsage: dailyUsage + ) + + return aggregate.hasProfileSummary ? aggregate : nil + } + + var hasProfileSummary: Bool { + lifetimeTokens != nil + || peakDailyTokens != nil + || longestTaskSeconds != nil + || currentStreakDays != nil + || longestStreakDays != nil + || dailyUsage.isEmpty == false + } +} + +private struct AccountProfileOverviewView: View { + let aggregate: AccountProfileAggregate + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5) { + Image(systemName: "sum") + .font(PanelFont.summaryIcon) + .foregroundStyle(PanelPalette.usageCyan(colorScheme).opacity(0.9)) + .frame(width: 11) + + Text("All accounts") + .font(PanelFont.metricLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + + Spacer(minLength: 6) + + Text("\(aggregate.accountCount) accounts") + .font(PanelFont.tertiary) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.72)) + .lineLimit(1) + } + + HStack(spacing: 5) { + ForEach(Array(metrics.enumerated()), id: \.offset) { index, metric in + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(metric.label) + .font(PanelFont.usageLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .lineLimit(1) + + Text(metric.value) + .font(PanelFont.usageValue) + .foregroundStyle(index == 0 ? primaryMetricColor : PanelPalette.secondaryText(colorScheme)) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.72) + } + + if index < metrics.count - 1 { + Spacer(minLength: 3) + } + } + } + .frame(height: 16) + + if aggregate.dailyUsage.isEmpty == false { + AccountProfileDailyUsageStripView(records: aggregate.dailyUsage) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 5) + .frame(maxWidth: .infinity, alignment: .leading) + .modernGlassSurface(cornerRadius: 10, depth: .section) + .accessibilityLabel(accessibilityLabel) + } + + private var metrics: [(label: String, value: String)] { + [ + aggregate.lifetimeTokens.map { ("tok", formatCompactCount($0)) }, + aggregate.peakDailyTokens.map { ("peak", formatCompactCount($0)) }, + streakText.map { ("streak", $0) }, + aggregate.longestTaskSeconds + .flatMap(formatActivityDuration) + .map { ("task", $0) }, + ] + .compactMap { $0 } + } + + private var streakText: String? { + if let current = aggregate.currentStreakDays, + let longest = aggregate.longestStreakDays + { + return "\(current)/\(longest)d" + } + if let current = aggregate.currentStreakDays { + return "\(current)d" + } + if let longest = aggregate.longestStreakDays { + return "\(longest)d" + } + + return nil + } + + private var primaryMetricColor: Color { + PanelPalette.primaryText(colorScheme).opacity(colorScheme == .dark ? 0.92 : 0.86) + } + + private var accessibilityLabel: String { + "All account profile totals, " + metrics.map { "\($0.label) \($0.value)" }.joined(separator: ", ") + } +} + struct AccountPoolUsageEstimateView: View { let estimate: AccountUsageEstimate let accounts: [CodexAccount] @@ -1215,6 +1390,10 @@ struct AccountUsageSummaryView: View { var body: some View { VStack(spacing: 5) { + if account.hasProfileSummary { + AccountProfileSummaryView(account: account) + } + if account.hasPrimaryUsageData { AccountUsageMeterView( label: account.windowLabel(seconds: account.primaryWindowSeconds), @@ -1245,6 +1424,119 @@ struct AccountUsageSummaryView: View { } } +struct AccountProfileSummaryView: View { + let account: CodexAccount + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 4) { + if metrics.isEmpty == false { + HStack(spacing: 5) { + Image(systemName: "chart.bar.xaxis") + .font(PanelFont.summaryIcon) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .frame(width: 10) + + ForEach(Array(metrics.enumerated()), id: \.offset) { index, metric in + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(metric.label) + .font(PanelFont.usageLabel) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .lineLimit(1) + + Text(metric.value) + .font(PanelFont.usageValue) + .foregroundStyle(valueColor(index: index)) + .monospacedDigit() + .lineLimit(1) + } + + if index < metrics.count - 1 { + Spacer(minLength: 3) + } + } + } + .frame(height: 16) + } + + if account.recentProfileDailyUsage.isEmpty == false { + AccountProfileDailyUsageStripView(records: account.recentProfileDailyUsage) + } + } + .accessibilityLabel(accessibilityLabel) + } + + private var metrics: [(label: String, value: String)] { + [ + account.profileLifetimeTokens.map { ("tok", formatCompactCount($0)) }, + account.profilePeakDailyTokens.map { ("peak", formatCompactCount($0)) }, + streakText.map { ("streak", $0) }, + account.profileLongestTaskSeconds + .flatMap(formatActivityDuration) + .map { ("task", $0) }, + ] + .compactMap { $0 } + } + + private var streakText: String? { + if let current = account.profileCurrentStreakDays, + let longest = account.profileLongestStreakDays + { + return "\(current)/\(longest)d" + } + if let current = account.profileCurrentStreakDays { + return "\(current)d" + } + if let longest = account.profileLongestStreakDays { + return "\(longest)d" + } + + return nil + } + + private var accessibilityLabel: String { + metrics.map { "\($0.label) \($0.value)" }.joined(separator: ", ") + } + + private func valueColor(index: Int) -> Color { + index == 0 + ? PanelPalette.primaryText(colorScheme).opacity(colorScheme == .dark ? 0.92 : 0.86) + : PanelPalette.secondaryText(colorScheme) + } +} + +struct AccountProfileDailyUsageStripView: View { + let records: [AccountProfileDailyUsage] + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 2) { + ForEach(Array(displayRecords.enumerated()), id: \.offset) { _, record in + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(tileColor(tokens: record.tokens)) + .frame(width: 6, height: 9) + .help("\(compactUsageDate(record.date)): \(formatCompactCount(record.tokens)) tokens") + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: 11) + .accessibilityHidden(true) + } + + private var displayRecords: [AccountProfileDailyUsage] { + Array(records.sorted { $0.date < $1.date }.suffix(36)) + } + + private var peakTokens: Int { + max(1, displayRecords.map(\.tokens).max() ?? 1) + } + + private func tileColor(tokens: Int) -> Color { + let intensity = max(0.16, min(1, Double(tokens) / Double(peakTokens))) + return PanelPalette.usageCyan(colorScheme).opacity(0.24 + 0.62 * intensity) + } +} + struct AccountUsageMeterView: View { let label: String let remainingPercent: Int? @@ -1731,7 +2023,7 @@ struct OperatorLanePopoverView: View { } ForEach(detailBuckets) { bucket in - OperatorLaneReadoutRow(title: humanizedPanelToken(bucket.name), items: bucketReadoutItems(bucket)) + OperatorLaneReadoutRow(title: rawPanelToken(bucket.name), items: bucketReadoutItems(bucket)) } if contextReadoutItems.isEmpty == false { @@ -1765,13 +2057,13 @@ struct OperatorLanePopoverView: View { } let label = panelTrimmed(activity.currentDetail) - ?? panelTrimmed(activity.currentBucket).map(humanizedPanelToken) + ?? panelTrimmed(activity.currentBucket).map(rawPanelToken) ?? "Active" if let elapsed = formatActivityDuration(activity.currentElapsedSeconds) { - return "\(humanizedPanelToken(label)) · \(elapsed)" + return "\(rawPanelToken(label)) · \(elapsed)" } - return humanizedPanelToken(label) + return rawPanelToken(label) } private var header: some View { @@ -2199,11 +2491,14 @@ struct SummaryTileView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Image(systemName: symbol) .font(PanelFont.summaryIcon) .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.78 : 0.82)) .frame(width: 11) + .alignmentGuide(.firstTextBaseline) { dimensions in + dimensions[VerticalAlignment.center] + 3 + } Text(title) .font(PanelFont.metricLabel) @@ -2580,6 +2875,18 @@ private func formatDailyUsageRate(_ value: Double) -> String { return "\(percent)/d" } +private func compactUsageDate(_ value: String) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + guard let date = formatter.date(from: value) else { + return value + } + + formatter.dateFormat = "MMM d" + return formatter.string(from: date) +} + private func formatPercentagePointDelta(_ value: Double) -> String { guard value.isFinite else { return "-" @@ -2599,21 +2906,8 @@ private func panelTrimmed(_ value: String?) -> String? { value?.trimmingCharacters(in: .whitespacesAndNewlines) } -private func humanizedPanelToken(_ value: String) -> String { - let words = value - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: "_", with: " ") - .split(separator: " ") - .map { word in - let text = String(word) - guard let first = text.first else { - return text - } - - return first.uppercased() + String(text.dropFirst()) - } - - return words.joined(separator: " ") +private func rawPanelToken(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) } private func formatLaneDuration(_ seconds: Int?) -> String? { @@ -2971,7 +3265,7 @@ private extension CodexAccount { } var hasUsageSummary: Bool { - hasUsageWindowSummary + hasUsageWindowSummary || hasProfileSummary } var hasUsageWindowSummary: Bool { @@ -2992,38 +3286,26 @@ private extension CodexAccount { var compactHealthLabel: String? { if isUsageLimited { - return "Limited" + return compactLimitStatusToken } - switch recoveryActionKind { - case .login: - return "Re-login" - case .refresh: - return "Refresh needed" - case .retryProbe: - return "Probe failed" - case .none: - break + if let token = recoveryAction?.trimmingCharacters(in: .whitespacesAndNewlines), + token.isEmpty == false + { + return token } - switch status { - case "available": - return nil - case "usage_limited": - return "Limited" - case "probe_failed": - return "Probe failed" - case "expired": - return "Refresh needed" - case "disabled": - return "Disabled" - case "cooldown": - return "Cooling" - case "unusable": - return "Needs attention" - default: - let label = status.replacingOccurrences(of: "_", with: " ").capitalized - return label.isEmpty ? nil : label + let label = status.trimmingCharacters(in: .whitespacesAndNewlines) + return label.isEmpty || label == "available" ? nil : label + } + + private var compactLimitStatusToken: String { + let reached = rateLimitReachedType?.trimmingCharacters(in: .whitespacesAndNewlines) + if let reached, reached.isEmpty == false, reached != "none" { + return reached } + + let token = status.trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty || token == "available" ? "usage_limited" : token } } diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index 19d7025d..5db28fb8 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -16,7 +16,7 @@ final class AccountStore: ObservableObject { private let bridge = DecodexAppBridge() private var automaticRefreshTask: Task? private var operatorSnapshotStreamTask: Task? - private var pendingRunActivity: [OperatorRunStatus]? + private var pendingRunActivity: OperatorRunActivitySnapshot? deinit { automaticRefreshTask?.cancel() @@ -157,6 +157,7 @@ final class AccountStore: ObservableObject { } catch { operatorSnapshot = nil operatorSnapshotUpdatedAt = nil + pendingRunActivity = nil } do { @@ -216,22 +217,38 @@ final class AccountStore: ObservableObject { return } - if let pendingRunActivity { - operatorSnapshot = snapshot.mergingRunActivity(pendingRunActivity) + let snapshotPublishedAt = payload.snapshotPublishedAt ?? Date() + if let pendingRunActivity, + pendingRunActivity.shouldOverlay(snapshotPublishedAt: snapshotPublishedAt) + { + operatorSnapshot = pendingRunActivity.merging(into: snapshot) } else { operatorSnapshot = snapshot + pendingRunActivity = nil } - operatorSnapshotUpdatedAt = payload.snapshotPublishedAt ?? Date() + operatorSnapshotUpdatedAt = snapshotPublishedAt case "runActivity": guard let activeRuns = payload.activeRuns else { return } - pendingRunActivity = activeRuns + let activity = OperatorRunActivitySnapshot( + activeRuns: activeRuns, + emittedAt: payload.emittedAt ?? Date() + ) + if let operatorSnapshotUpdatedAt, + activity.shouldOverlay(snapshotPublishedAt: operatorSnapshotUpdatedAt) == false + { + pendingRunActivity = nil + + return + } + + pendingRunActivity = activity if let operatorSnapshot { - self.operatorSnapshot = operatorSnapshot.mergingRunActivity(activeRuns) + self.operatorSnapshot = activity.merging(into: operatorSnapshot) } - operatorSnapshotUpdatedAt = payload.emittedAt ?? Date() + operatorSnapshotUpdatedAt = activity.emittedAt default: break } diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift index 0546a89f..6c4b7e90 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexAppBridge.swift @@ -200,7 +200,7 @@ struct DecodexAppBridge: Sendable { as type: T.Type, onOutput: (@MainActor @Sendable (String) -> Void)? ) async throws -> T { - if request.requiresHelper == false, onOutput == nil, try request.serverRoute() != nil { + if onOutput == nil, try request.serverRoute() != nil { return try await DecodexServerBridge.shared.run(request, as: type) } guard request.requiresHelper else { @@ -294,13 +294,8 @@ private extension AppBridgeRequest { var requiresHelper: Bool { switch operation { case - "account_clear", "account_import", - "account_list", "account_login", - "account_logout", - "account_select", - "account_use", "codex_fast_mode_status", "codex_fast_mode_set": return true diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index 2a5ed442..6721cdf3 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -108,6 +108,15 @@ struct AccountUsageRecord: Decodable, Identifiable, Equatable { } } +struct AccountProfileDailyUsage: Decodable, Identifiable, Equatable { + let date: String + let tokens: Int + + var id: String { + date + } +} + struct CodexAccount: Decodable, Identifiable, Equatable { let accountFingerprint: String let email: String? @@ -139,6 +148,15 @@ struct CodexAccount: Decodable, Identifiable, Equatable { let creditsUnlimited: Bool? let creditsBalance: String? let rateLimitReachedType: String? + let profileDisplayName: String? + let profileUsername: String? + let profileCheckedAtUnixEpoch: Int? + let profileLifetimeTokens: Int? + let profilePeakDailyTokens: Int? + let profileLongestTaskSeconds: Int? + let profileCurrentStreakDays: Int? + let profileLongestStreakDays: Int? + let profileDailyUsage: [AccountProfileDailyUsage]? let sevenDayUsedPercent: Int? let sevenDayDailyAveragePercent: Double? let usageRecords: [AccountUsageRecord]? @@ -173,33 +191,20 @@ struct CodexAccount: Decodable, Identifiable, Equatable { var statusLabel: String { if isUsageLimited { - return "Limited" + return rawLimitStatusToken } if codexActive { - return "Codex active" + return "codex_active" } if selected { - return "Runs routed" + return "selected" } - switch recoveryActionKind { - case .login: - return "Re-login required" - case .retryProbe: - return "Probe failed" - case .refresh, .none: - break + if let action = rawRecoveryActionToken { + return action } - switch status { - case "available": return "Ready" - case "usage_limited": return "Limited" - case "probe_failed": return "-" - case "expired": return "Refresh needed" - case "disabled": return "Disabled" - case "cooldown": return "Cooling" - case "unusable": return recoveryActionKind == .login ? "Re-login required" : "Needs attention" - default: return status.replacingOccurrences(of: "_", with: " ").capitalized - } + let token = status.trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty ? "unknown" : token } var statusTone: AccountTone { @@ -254,6 +259,19 @@ struct CodexAccount: Decodable, Identifiable, Equatable { primaryRemainingPercent != nil || secondaryRemainingPercent != nil } + var hasProfileSummary: Bool { + profileLifetimeTokens != nil + || profilePeakDailyTokens != nil + || profileLongestTaskSeconds != nil + || profileCurrentStreakDays != nil + || profileLongestStreakDays != nil + || recentProfileDailyUsage.isEmpty == false + } + + var recentProfileDailyUsage: [AccountProfileDailyUsage] { + profileDailyUsage ?? [] + } + var isUsageLimited: Bool { if let reached = rateLimitReachedType, !reached.isEmpty { return true @@ -263,6 +281,21 @@ struct CodexAccount: Decodable, Identifiable, Equatable { || secondaryRemainingPercent == 0 } + private var rawLimitStatusToken: String { + let reached = rateLimitReachedType?.trimmingCharacters(in: .whitespacesAndNewlines) + if let reached, reached.isEmpty == false, reached != "none" { + return reached + } + + let token = status.trimmingCharacters(in: .whitespacesAndNewlines) + return token.isEmpty || token == "available" ? "usage_limited" : token + } + + private var rawRecoveryActionToken: String? { + let token = recoveryAction?.trimmingCharacters(in: .whitespacesAndNewlines) + return token?.isEmpty == false ? token : nil + } + func windowLabel(seconds: Int?) -> String { UsageWindowLabel.make(seconds: seconds) } @@ -317,6 +350,15 @@ struct CodexAccount: Decodable, Identifiable, Equatable { creditsUnlimited: creditsUnlimited, creditsBalance: creditsBalance, rateLimitReachedType: rateLimitReachedType, + profileDisplayName: profileDisplayName, + profileUsername: profileUsername, + profileCheckedAtUnixEpoch: profileCheckedAtUnixEpoch, + profileLifetimeTokens: profileLifetimeTokens, + profilePeakDailyTokens: profilePeakDailyTokens, + profileLongestTaskSeconds: profileLongestTaskSeconds, + profileCurrentStreakDays: profileCurrentStreakDays, + profileLongestStreakDays: profileLongestStreakDays, + profileDailyUsage: profileDailyUsage, sevenDayUsedPercent: sevenDayUsedPercent, sevenDayDailyAveragePercent: sevenDayDailyAveragePercent, usageRecords: usageRecords @@ -354,6 +396,15 @@ struct CodexAccount: Decodable, Identifiable, Equatable { case creditsUnlimited = "credits_unlimited" case creditsBalance = "credits_balance" case rateLimitReachedType = "rate_limit_reached_type" + case profileDisplayName = "profile_display_name" + case profileUsername = "profile_username" + case profileCheckedAtUnixEpoch = "profile_checked_at_unix_epoch" + case profileLifetimeTokens = "profile_lifetime_tokens" + case profilePeakDailyTokens = "profile_peak_daily_tokens" + case profileLongestTaskSeconds = "profile_longest_task_seconds" + case profileCurrentStreakDays = "profile_current_streak_days" + case profileLongestStreakDays = "profile_longest_streak_days" + case profileDailyUsage = "profile_daily_usage" case sevenDayUsedPercent = "seven_day_used_percent" case sevenDayDailyAveragePercent = "seven_day_daily_average_percent" case usageRecords = "usage_records" diff --git a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift index 23e51ccc..5c1100ee 100644 --- a/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift +++ b/apps/decodex-app/Sources/DecodexApp/OperatorSnapshotModels.swift @@ -58,7 +58,10 @@ struct OperatorSnapshotResponse: Decodable, Sendable { } var warningSummary: String? { - let labels = warnings.compactMap(warningLabel).filter { $0.isEmpty == false } + let labels = warnings + .filter { $0 != "automation_disabled" } + .map(rawDisplayToken) + .filter { $0.isEmpty == false } guard let first = labels.first else { return nil } @@ -122,60 +125,6 @@ struct OperatorSnapshotResponse: Decodable, Sendable { && projects.allSatisfy { $0.connectorState == "api_only" || $0.connectorState == "dev" } } - private var snapshotBuildFailureProjectIDs: [String] { - let apiOnlyBaseline = warnings.contains("automation_disabled") - - return projects.compactMap { project in - guard project.enabled, let projectID = project.projectID, projectID.isEmpty == false else { - return nil - } - if project.connectorState == "config_error" { - return projectID - } - if apiOnlyBaseline && project.warningCount > 1 { - return projectID - } - if project.connectorState == "degraded" && project.warningCount > 0 { - return projectID - } - - return nil - } - } - - private func snapshotBuildFailureLabel() -> String { - let projectIDs = snapshotBuildFailureProjectIDs - guard let first = projectIDs.first else { - return "Snapshot build failed" - } - if projectIDs.count == 1 { - return "Snapshot build failed: \(first)" - } - - return "Snapshot build failed: \(first) +\(projectIDs.count - 1)" - } - - private func warningLabel(_ value: String) -> String? { - switch value { - case "automation_disabled": - return nil - case "control_plane_tick_context_failed": - return "Control-plane context unavailable" - case "operator_snapshot_build_failed": - return snapshotBuildFailureLabel() - case "control_plane_tick_failed": - return "Control-plane tick failed" - case "tracker_rate_limited": - return "Tracker sync paused" - case "codex_accounts_unavailable": - return "Accounts unavailable" - case "worktree_hygiene_unavailable": - return "Worktree hygiene unavailable" - default: - return readable(value) - } - } - enum CodingKeys: String, CodingKey { case warnings case projects @@ -361,22 +310,22 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { return currentDetail } if let currentBucket = trimmed(childAgentActivity?.currentBucket), currentBucket.isEmpty == false { - return readable(currentBucket) + return rawDisplayToken(currentBucket) } if let waitReason = trimmed(waitReason), waitReason.isEmpty == false { - return readable(waitReason) + return rawDisplayToken(waitReason) } if let operation = trimmed(currentOperation), operation.isEmpty == false, operation != "idle" { - return readable(operation) + return rawDisplayToken(operation) } if let phase = trimmed(phase), phase.isEmpty == false { - return readable(phase) + return rawDisplayToken(phase) } if let threadStatus = trimmed(threadStatus), threadStatus.isEmpty == false { - return readable(threadStatus) + return rawDisplayToken(threadStatus) } if let status = trimmed(status), status.isEmpty == false { - return readable(status) + return rawDisplayToken(status) } return "Active" @@ -445,7 +394,7 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { return false } - return title == readable(currentOperation ?? phase ?? "") + return title == rawDisplayToken(currentOperation ?? phase ?? "") } enum CodingKeys: String, CodingKey { @@ -477,6 +426,8 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { case childAgentActivity = "child_agent_activity" case account case accounts + case codexAccount = "codex_account" + case codexAccounts = "codex_accounts" } init(from decoder: Decoder) throws { @@ -512,7 +463,10 @@ struct OperatorRunStatus: Decodable, Identifiable, Sendable { forKey: .childAgentActivity ) account = try container.decodeIfPresent(OperatorRunAccountSummary.self, forKey: .account) - accounts = try container.decodeIfPresent([OperatorRunAccountSummary].self, forKey: .accounts) ?? [] + ?? container.decodeIfPresent(OperatorRunAccountSummary.self, forKey: .codexAccount) + accounts = try container.decodeIfPresent([OperatorRunAccountSummary].self, forKey: .accounts) + ?? container.decodeIfPresent([OperatorRunAccountSummary].self, forKey: .codexAccounts) + ?? [] } private init( @@ -603,6 +557,23 @@ struct OperatorDashboardSocketPayload: Decodable, Sendable { } } +struct OperatorRunActivitySnapshot: Sendable { + let activeRuns: [OperatorRunStatus] + let emittedAt: Date + + func shouldOverlay(snapshotPublishedAt: Date?) -> Bool { + guard let snapshotPublishedAt else { + return true + } + + return emittedAt > snapshotPublishedAt + } + + func merging(into snapshot: OperatorSnapshotResponse) -> OperatorSnapshotResponse { + snapshot.mergingRunActivity(activeRuns) + } +} + struct OperatorChildAgentActivity: Decodable, Sendable { let currentBucket: String? let currentDetail: String? @@ -707,6 +678,7 @@ struct OperatorRunAccountSummary: Decodable, Sendable { enum CodingKeys: String, CodingKey { case accountFingerprint = "account_fingerprint" case email + case accountEmail = "account_email" } init(from decoder: Decoder) throws { @@ -714,6 +686,7 @@ struct OperatorRunAccountSummary: Decodable, Sendable { accountFingerprint = try container.decodeIfPresent(String.self, forKey: .accountFingerprint) ?? "" email = try container.decodeIfPresent(String.self, forKey: .email) + ?? container.decodeIfPresent(String.self, forKey: .accountEmail) } } @@ -721,21 +694,8 @@ private func trimmed(_ value: String?) -> String? { value?.trimmingCharacters(in: .whitespacesAndNewlines) } -private func readable(_ value: String) -> String { - let words = value - .replacingOccurrences(of: "-", with: " ") - .replacingOccurrences(of: "_", with: " ") - .split(separator: " ") - .map { word in - let text = String(word) - guard let first = text.first else { - return text - } - - return first.uppercased() + String(text.dropFirst()) - } - - return words.joined(separator: " ") +private func rawDisplayToken(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) } private func date(fromUnixEpoch value: Int64?) -> Date? { diff --git a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift index 0849649d..5617ae9b 100644 --- a/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift +++ b/apps/decodex-app/Tests/DecodexAppTests/AccountModelTests.swift @@ -13,7 +13,7 @@ final class AccountModelTests: XCTestCase { XCTAssertTrue(account.needsLogin) XCTAssertFalse(account.canRouteRuns) - XCTAssertEqual(account.statusLabel, "Re-login required") + XCTAssertEqual(account.statusLabel, "login") XCTAssertNil(account.currentCapacityLabel) } @@ -27,7 +27,7 @@ final class AccountModelTests: XCTestCase { XCTAssertFalse(account.needsLogin) XCTAssertTrue(account.canRouteRuns) - XCTAssertEqual(account.statusLabel, "Refresh needed") + XCTAssertEqual(account.statusLabel, "refresh") XCTAssertNil(account.currentCapacityLabel) } @@ -50,8 +50,171 @@ final class AccountModelTests: XCTestCase { XCTAssertEqual(AccountDisplay.compactEmail("xavier.lau@helixbox.ai"), "xav...lau@helixbox.ai") } + func testOperatorSnapshotAssignsCodexAccountRunsToAccountRows() throws { + let assignedAccount = makeAccount( + status: "available", + email: "copy@example.com", + accountFingerprint: "...123456" + ) + let poolOnlyAccount = makeAccount( + status: "available", + email: "pool@example.com", + accountFingerprint: "...654321" + ) + let otherAssignedAccount = makeAccount( + status: "available", + email: "other@example.com", + accountFingerprint: "...abcdef" + ) + let payload = """ + { + "active_runs": [ + { + "run_id": "run-1", + "issue_identifier": "XY-445", + "codex_account": { + "account_email": "copy@example.com", + "account_fingerprint": "...123456" + }, + "codex_accounts": [ + { + "account_email": "copy@example.com", + "account_fingerprint": "...123456" + }, + { + "account_email": "pool@example.com", + "account_fingerprint": "...654321" + } + ] + }, + { + "run_id": "run-2", + "issue_identifier": "PUB-1147", + "codex_account": { + "account_email": "other@example.com", + "account_fingerprint": "...abcdef" + }, + "codex_accounts": [ + { + "account_email": "copy@example.com", + "account_fingerprint": "...123456" + }, + { + "account_email": "other@example.com", + "account_fingerprint": "...abcdef" + }, + { + "account_email": "pool@example.com", + "account_fingerprint": "...654321" + } + ] + } + ] + } + """.data(using: .utf8)! + + let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: payload) + + XCTAssertEqual(snapshot.activeRuns(for: assignedAccount).map(\.runID), ["run-1"]) + XCTAssertEqual(snapshot.activeRuns(for: otherAssignedAccount).map(\.runID), ["run-2"]) + XCTAssertTrue(snapshot.activeRuns(for: poolOnlyAccount).isEmpty) + } + + func testOperatorRunActivityOverlayDoesNotReplaceNewerSnapshot() throws { + let account = makeAccount( + status: "available", + email: "copy@example.com", + accountFingerprint: "...123456" + ) + let snapshotPayload = """ + { + "active_runs": [ + { + "run_id": "run-new", + "issue_identifier": "XY-672", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + """.data(using: .utf8)! + let activityPayload = """ + { + "activeRuns": [ + { + "run_id": "run-old", + "issue_identifier": "PUB-1147", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + """.data(using: .utf8)! + + let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload) + let activity = try JSONDecoder() + .decode(OperatorDashboardSocketPayload.self, from: activityPayload) + .activeRuns ?? [] + let overlay = OperatorRunActivitySnapshot( + activeRuns: activity, + emittedAt: Date(timeIntervalSince1970: 10) + ) + + XCTAssertFalse(overlay.shouldOverlay(snapshotPublishedAt: Date(timeIntervalSince1970: 20))) + XCTAssertEqual(snapshot.activeRuns(for: account).map(\.runID), ["run-new"]) + } + + func testNewerEmptyRunActivityClearsSnapshotRuns() throws { + let account = makeAccount( + status: "available", + email: "copy@example.com", + accountFingerprint: "...123456" + ) + let snapshotPayload = """ + { + "active_runs": [ + { + "run_id": "run-old", + "issue_identifier": "XY-672", + "account": { + "email": "copy@example.com", + "account_fingerprint": "...123456" + } + } + ] + } + """.data(using: .utf8)! + let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: snapshotPayload) + let overlay = OperatorRunActivitySnapshot( + activeRuns: [], + emittedAt: Date(timeIntervalSince1970: 30) + ) + let merged = overlay.merging(into: snapshot) + + XCTAssertTrue(overlay.shouldOverlay(snapshotPublishedAt: Date(timeIntervalSince1970: 20))) + XCTAssertTrue(merged.activeRuns(for: account).isEmpty) + } + + func testOperatorSnapshotWarningSummaryUsesRawWarningToken() throws { + let payload = """ + { + "warnings": ["external_observer_status_skipped"] + } + """.data(using: .utf8)! + + let snapshot = try JSONDecoder().decode(OperatorSnapshotResponse.self, from: payload) + + XCTAssertEqual(snapshot.warningSummary, "external_observer_status_skipped") + } + private func makeAccount( status: String, + email: String = "copy@example.com", + accountFingerprint: String = "...123456", recoveryAction: String? = nil, refreshStatus: String? = nil, planType: String? = nil, @@ -59,9 +222,9 @@ final class AccountModelTests: XCTestCase { primaryRemainingPercent: Int? = nil ) -> CodexAccount { CodexAccount( - accountFingerprint: "...123456", - email: "copy@example.com", - selector: "copy@example.com", + accountFingerprint: accountFingerprint, + email: email, + selector: email, randomName: nil, randomNameKey: nil, randomNameOffset: nil, @@ -89,6 +252,15 @@ final class AccountModelTests: XCTestCase { creditsUnlimited: nil, creditsBalance: nil, rateLimitReachedType: nil, + profileDisplayName: nil, + profileUsername: nil, + profileCheckedAtUnixEpoch: nil, + profileLifetimeTokens: nil, + profilePeakDailyTokens: nil, + profileLongestTaskSeconds: nil, + profileCurrentStreakDays: nil, + profileLongestStreakDays: nil, + profileDailyUsage: nil, sevenDayUsedPercent: nil, sevenDayDailyAveragePercent: nil, usageRecords: nil diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index 5daf8eaa..a00bcea3 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -17,7 +17,7 @@ use crate::{ agent::CodexAccountPool, prelude::{Result, eyre}, runtime, - state::CodexAccountActivitySummary, + state::{CodexAccountActivitySummary, CodexAccountProfileDailyUsageSummary}, }; const USAGE_ESTIMATE_WINDOW_DAYS: i64 = 7; @@ -630,6 +630,24 @@ pub(crate) struct AccountSummary { pub(crate) credits_unlimited: Option, pub(crate) credits_balance: Option, pub(crate) rate_limit_reached_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_checked_at_unix_epoch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_lifetime_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_peak_daily_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_longest_task_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_current_streak_days: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) profile_longest_streak_days: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) profile_daily_usage: Vec, pub(crate) seven_day_used_percent: Option, pub(crate) seven_day_daily_average_percent: Option, pub(crate) usage_records: Vec, @@ -652,6 +670,17 @@ impl AccountSummary { self.credits_balance.clone_from(&summary.credits_balance); self.rate_limit_reached_type.clone_from(&summary.rate_limit_reached_type); + self.profile_display_name.clone_from(&summary.profile_display_name); + self.profile_username.clone_from(&summary.profile_username); + + self.profile_checked_at_unix_epoch = summary.profile_checked_at_unix_epoch; + self.profile_lifetime_tokens = summary.profile_lifetime_tokens; + self.profile_peak_daily_tokens = summary.profile_peak_daily_tokens; + self.profile_longest_task_seconds = summary.profile_longest_task_seconds; + self.profile_current_streak_days = summary.profile_current_streak_days; + self.profile_longest_streak_days = summary.profile_longest_streak_days; + + self.profile_daily_usage.clone_from(&summary.profile_daily_usage); if summary.cooldown_until_unix_epoch.is_some() { self.cooldown_until_unix_epoch = summary.cooldown_until_unix_epoch; @@ -1118,6 +1147,15 @@ impl AccountPoolRecord { credits_unlimited: None, credits_balance: None, rate_limit_reached_type: None, + profile_display_name: None, + profile_username: None, + profile_checked_at_unix_epoch: None, + profile_lifetime_tokens: None, + profile_peak_daily_tokens: None, + profile_longest_task_seconds: None, + profile_current_streak_days: None, + profile_longest_streak_days: None, + profile_daily_usage: Vec::new(), seven_day_used_percent: None, seven_day_daily_average_percent: None, usage_records: Vec::new(), @@ -1881,7 +1919,7 @@ mod tests { use crate::{ accounts::{AccountPoolRecord, AccountStore, AuthDotJson, CodexTokenData}, - state::CodexAccountActivitySummary, + state::{CodexAccountActivitySummary, CodexAccountProfileDailyUsageSummary}, }; #[test] @@ -2122,6 +2160,15 @@ mod tests { credits_unlimited: Some(false), credits_balance: Some(String::from("9.99")), rate_limit_reached_type: None, + profile_lifetime_tokens: Some(47_200_000_000), + profile_peak_daily_tokens: Some(1_500_000_000), + profile_longest_task_seconds: Some(10_080), + profile_current_streak_days: Some(12), + profile_longest_streak_days: Some(68), + profile_daily_usage: vec![CodexAccountProfileDailyUsageSummary { + date: String::from("2026-05-31"), + tokens: 123_456, + }], ..CodexAccountActivitySummary::default() }]); @@ -2131,6 +2178,12 @@ mod tests { assert_eq!(response.accounts[0].secondary_window_seconds, Some(604_800)); assert_eq!(response.accounts[0].secondary_remaining_percent, Some(91)); assert_eq!(response.accounts[0].credits_balance.as_deref(), Some("9.99")); + assert_eq!(response.accounts[0].profile_lifetime_tokens, Some(47_200_000_000)); + assert_eq!(response.accounts[0].profile_peak_daily_tokens, Some(1_500_000_000)); + assert_eq!(response.accounts[0].profile_longest_task_seconds, Some(10_080)); + assert_eq!(response.accounts[0].profile_current_streak_days, Some(12)); + assert_eq!(response.accounts[0].profile_longest_streak_days, Some(68)); + assert_eq!(response.accounts[0].profile_daily_usage[0].date, "2026-05-31"); assert_eq!(response.accounts[0].seven_day_used_percent, Some(9)); assert_eq!(response.accounts[0].capacity_multiplier, 20); assert_eq!(response.accounts[0].recovery_action, None); diff --git a/apps/decodex/src/agent/codex_accounts.rs b/apps/decodex/src/agent/codex_accounts.rs index 3cef48f9..909c7986 100644 --- a/apps/decodex/src/agent/codex_accounts.rs +++ b/apps/decodex/src/agent/codex_accounts.rs @@ -18,10 +18,14 @@ use serde_json::Value; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ - config::ProjectCodexAccountsConfig, prelude::eyre, runtime, state::CodexAccountActivitySummary, + config::ProjectCodexAccountsConfig, + prelude::eyre, + runtime, + state::{CodexAccountActivitySummary, CodexAccountProfileDailyUsageSummary}, }; const DEFAULT_USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/wham/usage"; +const DEFAULT_PROFILE_ENDPOINT: &str = "https://chatgpt.com/backend-api/wham/profiles/me"; const DEFAULT_REFRESH_ENDPOINT: &str = "https://auth.openai.com/oauth/token"; const CODEX_USER_AGENT: &str = "codex-cli"; const CHATGPT_OAUTH_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; @@ -42,6 +46,7 @@ pub(crate) trait CodexAccountProvider { pub(crate) struct CodexAccountPool { path: PathBuf, usage_endpoint: String, + profile_endpoint: Option, refresh_endpoint: String, fixed_account: Option, codex_auth_path: PathBuf, @@ -51,10 +56,12 @@ pub(crate) struct CodexAccountPool { impl CodexAccountPool { pub(crate) fn from_config(config: &ProjectCodexAccountsConfig) -> crate::prelude::Result { let fixed_account = runtime::global_fixed_account_selector()?; + let usage_endpoint = config.usage_endpoint().unwrap_or(DEFAULT_USAGE_ENDPOINT); - Self::new_with_fixed_account( + Self::new_with_fixed_account_and_profile_endpoint( runtime::accounts_path()?, - config.usage_endpoint().unwrap_or(DEFAULT_USAGE_ENDPOINT), + usage_endpoint, + config.profile_endpoint(), config.refresh_endpoint().unwrap_or(DEFAULT_REFRESH_ENDPOINT), fixed_account.as_deref(), ) @@ -70,27 +77,68 @@ impl CodexAccountPool { refresh_endpoint: impl Into, fixed_account: Option<&str>, ) -> crate::prelude::Result { - Self::new_with_fixed_account_and_codex_auth_path( + Self::new_with_fixed_account_and_profile_endpoint( + path, + usage_endpoint, + None, + refresh_endpoint, + fixed_account, + ) + } + + fn new_with_fixed_account_and_profile_endpoint( + path: impl AsRef, + usage_endpoint: impl Into, + profile_endpoint: Option<&str>, + refresh_endpoint: impl Into, + fixed_account: Option<&str>, + ) -> crate::prelude::Result { + Self::new_with_fixed_account_profile_and_codex_auth_path( path, usage_endpoint, + profile_endpoint, refresh_endpoint, fixed_account, default_codex_auth_json_path()?, ) } + #[cfg(test)] fn new_with_fixed_account_and_codex_auth_path( path: impl AsRef, usage_endpoint: impl Into, refresh_endpoint: impl Into, fixed_account: Option<&str>, codex_auth_path: impl Into, + ) -> crate::prelude::Result { + Self::new_with_fixed_account_profile_and_codex_auth_path( + path, + usage_endpoint, + None, + refresh_endpoint, + fixed_account, + codex_auth_path, + ) + } + + fn new_with_fixed_account_profile_and_codex_auth_path( + path: impl AsRef, + usage_endpoint: impl Into, + profile_endpoint: Option<&str>, + refresh_endpoint: impl Into, + fixed_account: Option<&str>, + codex_auth_path: impl Into, ) -> crate::prelude::Result { let client = Client::builder().timeout(HTTP_TIMEOUT).build()?; + let usage_endpoint = usage_endpoint.into(); + let profile_endpoint = profile_endpoint + .and_then(|endpoint| nonblank_string(Some(endpoint))) + .or_else(|| default_profile_endpoint_for_usage_endpoint(&usage_endpoint)); Ok(Self { path: path.as_ref().to_path_buf(), - usage_endpoint: usage_endpoint.into(), + usage_endpoint, + profile_endpoint, refresh_endpoint: refresh_endpoint.into(), fixed_account: fixed_account .map(str::trim) @@ -183,6 +231,7 @@ impl CodexAccountPool { AccountActivityCacheKey { path: self.path.clone(), usage_endpoint: self.usage_endpoint.clone(), + profile_endpoint: self.profile_endpoint.clone(), refresh_endpoint: self.refresh_endpoint.clone(), } } @@ -443,7 +492,11 @@ impl CodexAccountPool { match self.probe_record_usage(record) { Ok(usage) => { - summaries.push(record.activity_summary_from_usage(usage, refresh_status)?); + summaries.push(self.activity_summary_from_usage_probe( + record, + usage, + refresh_status, + )?); }, Err(error) if error.unauthorized && record.refresh_token().is_some() => { match self.refresh_record(record) { @@ -452,9 +505,11 @@ impl CodexAccountPool { match self.probe_record_usage(record) { Ok(usage) => { - summaries.push( - record.activity_summary_from_usage(usage, "succeeded")?, - ); + summaries.push(self.activity_summary_from_usage_probe( + record, + usage, + "succeeded", + )?); }, Err(retry_error) => { summaries.push(record.probe_failed_activity_summary( @@ -491,6 +546,17 @@ impl CodexAccountPool { Ok(summaries) } + fn activity_summary_from_usage_probe( + &self, + record: &AccountPoolRecord, + usage: AccountUsageSnapshot, + refresh_status: &str, + ) -> crate::prelude::Result { + let profile = self.probe_record_profile(record).ok().flatten(); + + record.activity_summary_from_usage_profile(usage, profile, refresh_status) + } + fn proactive_refresh_record( &self, record: &mut AccountPoolRecord, @@ -617,6 +683,43 @@ impl CodexAccountPool { Ok(usage_snapshot_from_payload(&payload, OffsetDateTime::now_utc().unix_timestamp())) } + fn probe_record_profile( + &self, + record: &AccountPoolRecord, + ) -> std::result::Result, UsageProbeError> { + let Some(profile_endpoint) = self.profile_endpoint.as_deref() else { + return Ok(None); + }; + let access_token = record + .access_token() + .ok_or_else(|| UsageProbeError::other("account is missing an access token"))?; + let account_id = record + .account_id() + .ok_or_else(|| UsageProbeError::other("account is missing an account id"))?; + let response = self + .client + .get(profile_endpoint) + .bearer_auth(access_token) + .header("ChatGPT-Account-Id", account_id) + .header("User-Agent", CODEX_USER_AGENT) + .send() + .map_err(|error| UsageProbeError::other(error.to_string()))?; + let status = response.status(); + + if status == StatusCode::UNAUTHORIZED { + return Err(UsageProbeError::unauthorized()); + } + if !status.is_success() { + return Err(UsageProbeError::other(format!("profile endpoint returned {status}"))); + } + + let payload = response.json::().map_err(|error| { + UsageProbeError::other(format!("profile JSON did not parse: {error}")) + })?; + + Ok(profile_snapshot_from_payload(&payload, OffsetDateTime::now_utc().unix_timestamp())) + } + fn refresh_record(&self, record: &mut AccountPoolRecord) -> crate::prelude::Result<()> { let display_name = record.display_name(); let refresh_token = record @@ -772,6 +875,7 @@ impl CodexAccountLogin { struct AccountActivityCacheKey { path: PathBuf, usage_endpoint: String, + profile_endpoint: Option, refresh_endpoint: String, } @@ -964,6 +1068,7 @@ impl AccountPoolRecord { rate_limit_reached_type: usage.rate_limit_reached_type, cooldown_until_unix_epoch: self.cooldown_until_unix_epoch, note: Some(String::from("usage probe ok")), + ..CodexAccountActivitySummary::default() }; Ok(CodexAccountLogin { @@ -984,6 +1089,21 @@ impl AccountPoolRecord { Ok(self.login_from_usage(usage, refresh_status)?.summary) } + fn activity_summary_from_usage_profile( + &self, + usage: AccountUsageSnapshot, + profile: Option, + refresh_status: &str, + ) -> crate::prelude::Result { + let mut summary = self.activity_summary_from_usage(usage, refresh_status)?; + + if let Some(profile) = profile { + profile.apply_to_summary(&mut summary); + } + + Ok(summary) + } + fn probe_failed_activity_summary( &self, now_unix_epoch: i64, @@ -1085,6 +1205,43 @@ impl AccountUsageSnapshot { } } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct AccountProfileSnapshot { + display_name: Option, + username: Option, + lifetime_tokens: Option, + peak_daily_tokens: Option, + longest_task_seconds: Option, + current_streak_days: Option, + longest_streak_days: Option, + daily_usage: Vec, + checked_at_unix_epoch: i64, +} +impl AccountProfileSnapshot { + fn is_empty(&self) -> bool { + self.display_name.is_none() + && self.username.is_none() + && self.lifetime_tokens.is_none() + && self.peak_daily_tokens.is_none() + && self.longest_task_seconds.is_none() + && self.current_streak_days.is_none() + && self.longest_streak_days.is_none() + && self.daily_usage.is_empty() + } + + fn apply_to_summary(self, summary: &mut CodexAccountActivitySummary) { + summary.profile_display_name = self.display_name; + summary.profile_username = self.username; + summary.profile_checked_at_unix_epoch = Some(self.checked_at_unix_epoch); + summary.profile_lifetime_tokens = self.lifetime_tokens; + summary.profile_peak_daily_tokens = self.peak_daily_tokens; + summary.profile_longest_task_seconds = self.longest_task_seconds; + summary.profile_current_streak_days = self.current_streak_days; + summary.profile_longest_streak_days = self.longest_streak_days; + summary.profile_daily_usage = self.daily_usage; + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct UsageWindow { window_seconds: Option, @@ -1261,6 +1418,10 @@ fn default_codex_auth_json_path() -> crate::prelude::Result { Ok(PathBuf::from(home).join(".codex").join("auth.json")) } +fn default_profile_endpoint_for_usage_endpoint(usage_endpoint: &str) -> Option { + (usage_endpoint == DEFAULT_USAGE_ENDPOINT).then(|| DEFAULT_PROFILE_ENDPOINT.to_owned()) +} + fn sync_refreshed_record_to_codex_auth( record: &AccountPoolRecord, path: &Path, @@ -1344,6 +1505,44 @@ fn usage_snapshot_from_payload( } } +fn profile_snapshot_from_payload( + payload: &Value, + checked_at_unix_epoch: i64, +) -> Option { + let profile = payload.get("profile").filter(|value| !value.is_null()); + let stats = payload.get("stats").filter(|value| !value.is_null()); + let daily_usage = stats + .and_then(|value| value.get("daily_usage_buckets")) + .and_then(Value::as_array) + .map(|items| items.iter().filter_map(profile_daily_usage_from_value).collect::>()) + .unwrap_or_default(); + let snapshot = AccountProfileSnapshot { + display_name: profile.and_then(|value| nonblank_json_string(value.get("display_name"))), + username: profile.and_then(|value| nonblank_json_string(value.get("username"))), + lifetime_tokens: stats + .and_then(|value| nonnegative_number_as_i64(value.get("lifetime_tokens"))), + peak_daily_tokens: stats + .and_then(|value| nonnegative_number_as_i64(value.get("peak_daily_tokens"))), + longest_task_seconds: stats + .and_then(|value| nonnegative_number_as_i64(value.get("longest_running_turn_sec"))), + current_streak_days: stats + .and_then(|value| nonnegative_number_as_i64(value.get("current_streak_days"))), + longest_streak_days: stats + .and_then(|value| nonnegative_number_as_i64(value.get("longest_streak_days"))), + daily_usage, + checked_at_unix_epoch, + }; + + (!snapshot.is_empty()).then_some(snapshot) +} + +fn profile_daily_usage_from_value(value: &Value) -> Option { + let date = nonblank_json_string(value.get("start_date"))?; + let tokens = nonnegative_number_as_i64(value.get("tokens"))?; + + Some(CodexAccountProfileDailyUsageSummary { date, tokens }) +} + fn usage_window_from_value(value: Option<&Value>) -> Option { let value = value.filter(|value| !value.is_null())?; let used_percent = number_as_i64(value.get("used_percent")?)?; @@ -1385,6 +1584,10 @@ fn number_as_i64(value: &Value) -> Option { .or_else(|| value.as_f64().map(|number| number.round() as i64)) } +fn nonnegative_number_as_i64(value: Option<&Value>) -> Option { + value.and_then(number_as_i64).map(|number| number.max(0)) +} + fn json_scalar_to_string(value: &Value) -> Option { match value { Value::String(text) if !text.is_empty() => Some(text.clone()), @@ -1394,6 +1597,10 @@ fn json_scalar_to_string(value: &Value) -> Option { } } +fn nonblank_json_string(value: Option<&Value>) -> Option { + value.and_then(json_scalar_to_string).and_then(|value| nonblank_string(Some(&value))) +} + fn first_nonblank_string(left: Option, right: Option) -> Option { left.filter(|value| !value.trim().is_empty()) .or_else(|| right.filter(|value| !value.trim().is_empty())) @@ -1686,6 +1893,40 @@ mod tests { ); } + #[test] + fn profile_summary_parses_codex_profile_payload() { + let payload = serde_json::json!({ + "profile": { + "display_name": " Copy Account ", + "username": "copy" + }, + "stats": { + "lifetime_tokens": 47_200_000_000_i64, + "peak_daily_tokens": 1_500_000_000_i64, + "longest_running_turn_sec": 10_080, + "current_streak_days": 12, + "longest_streak_days": 68, + "daily_usage_buckets": [ + { "start_date": "2026-05-30", "tokens": 123_456 }, + { "start_date": "2026-05-31", "tokens": 789_000 } + ] + } + }); + let summary = codex_accounts::profile_snapshot_from_payload(&payload, 1_800_000_000) + .expect("profile summary should parse"); + + assert_eq!(summary.display_name.as_deref(), Some("Copy Account")); + assert_eq!(summary.username.as_deref(), Some("copy")); + assert_eq!(summary.lifetime_tokens, Some(47_200_000_000)); + assert_eq!(summary.peak_daily_tokens, Some(1_500_000_000)); + assert_eq!(summary.longest_task_seconds, Some(10_080)); + assert_eq!(summary.current_streak_days, Some(12)); + assert_eq!(summary.longest_streak_days, Some(68)); + assert_eq!(summary.daily_usage.len(), 2); + assert_eq!(summary.daily_usage[1].date, "2026-05-31"); + assert_eq!(summary.daily_usage[1].tokens, 789_000); + } + #[test] fn usage_limit_detects_depleted_windows_without_credit_heuristics() { let payload = serde_json::json!({ diff --git a/apps/decodex/src/config.rs b/apps/decodex/src/config.rs index b0567df3..8f19bb27 100644 --- a/apps/decodex/src/config.rs +++ b/apps/decodex/src/config.rs @@ -263,6 +263,7 @@ impl Default for ProjectPrivacyClassifierConfig { #[serde(deny_unknown_fields)] pub struct ProjectCodexAccountsConfig { usage_endpoint: Option, + profile_endpoint: Option, refresh_endpoint: Option, } impl ProjectCodexAccountsConfig { @@ -271,6 +272,11 @@ impl ProjectCodexAccountsConfig { self.usage_endpoint.as_deref() } + /// Override for ChatGPT profile-stat probes. Defaults to Codex `/wham/profiles/me`. + pub fn profile_endpoint(&self) -> Option<&str> { + self.profile_endpoint.as_deref() + } + /// Override for ChatGPT OAuth refresh. Defaults to the Codex auth token endpoint. pub fn refresh_endpoint(&self) -> Option<&str> { self.refresh_endpoint.as_deref() @@ -281,6 +287,10 @@ impl ProjectCodexAccountsConfig { "codex.accounts.usage_endpoint", self.usage_endpoint.as_deref(), )?; + validate_optional_nonempty_string( + "codex.accounts.profile_endpoint", + self.profile_endpoint.as_deref(), + )?; validate_optional_nonempty_string( "codex.accounts.refresh_endpoint", self.refresh_endpoint.as_deref(), @@ -1239,15 +1249,17 @@ mod tests { [github] token_env_var = "HOME" - [codex.accounts] - usage_endpoint = "http://127.0.0.1:1234/wham/usage" - refresh_endpoint = "http://127.0.0.1:1234/oauth/token" - "#, + [codex.accounts] + usage_endpoint = "http://127.0.0.1:1234/wham/usage" + profile_endpoint = "http://127.0.0.1:1234/wham/profiles/me" + refresh_endpoint = "http://127.0.0.1:1234/oauth/token" + "#, ); let config = ServiceConfig::from_path(&config_path).expect("accounts should parse"); let accounts = config.codex().accounts().expect("accounts should be configured"); assert_eq!(accounts.usage_endpoint(), Some("http://127.0.0.1:1234/wham/usage")); + assert_eq!(accounts.profile_endpoint(), Some("http://127.0.0.1:1234/wham/profiles/me")); assert_eq!(accounts.refresh_endpoint(), Some("http://127.0.0.1:1234/oauth/token")); } diff --git a/apps/decodex/src/orchestrator/agent_evidence.rs b/apps/decodex/src/orchestrator/agent_evidence.rs index bf7fc240..e6536518 100644 --- a/apps/decodex/src/orchestrator/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/agent_evidence.rs @@ -1230,7 +1230,7 @@ fn push_run_blockers( reason: run .wait_reason .clone() - .unwrap_or_else(|| reason_code.replace('_', " ")), + .unwrap_or_else(|| reason_code.to_owned()), next_action: agent_run_next_action(run).unwrap_or("Inspect the run capsule.").to_owned(), blocker_snapshot_path: blocker_snapshot_path(blockers_dir, &issue_key) .display() diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index a2d1f05a..6114ebc4 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -1784,7 +1784,8 @@ .account-meta { display: flex; flex-wrap: wrap; - gap: 5px 10px; + align-items: center; + gap: 4px 8px; min-width: 0; font-family: var(--mono); font-size: var(--type-caption); @@ -1792,10 +1793,72 @@ } .account-meta span { + display: inline-flex; + align-items: baseline; + gap: 3px; min-width: 0; overflow-wrap: anywhere; } + .account-meta span strong { + color: var(--muted-strong); + font-weight: var(--weight-label); + } + + .account-pool-activity-strip { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 2px; + width: min(100%, 128px); + min-width: 0; + height: 8px; + margin-top: 2px; + overflow: hidden; + } + + .account-profile-activity-strip { + justify-self: center; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + width: min(100%, 128px); + min-width: 0; + height: 8px; + overflow: hidden; + } + + .account-pool-activity-tile, + .account-profile-activity-tile { + display: block; + flex: 0 0 6px; + width: 6px; + height: 8px; + border-radius: 2px; + background: var(--info); + opacity: var(--account-activity-opacity, 0.28); + } + + #account-mode-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + max-width: min(760px, 72vw); + letter-spacing: 0; + } + + .account-mode-head { + display: inline-flex; + align-items: baseline; + gap: 6px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .account-pool { display: grid; gap: 10px; @@ -1806,7 +1869,7 @@ .account-pool-summary { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 0; min-width: 0; padding: 8px 0 8px var(--space-row-indent); @@ -1820,8 +1883,8 @@ min-width: 0; padding: 0 14px; border-left: 1px solid var(--line); - font-family: var(--mono); font-variant-numeric: tabular-nums; + line-height: 1.15; } .account-pool-metric:first-child { @@ -1832,10 +1895,10 @@ .account-pool-metric-label { overflow: hidden; color: var(--muted); + font-family: var(--sans); font-size: var(--type-caption); font-weight: var(--weight-label); letter-spacing: var(--tracking-caps); - line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } @@ -1843,10 +1906,10 @@ .account-pool-metric-value { overflow: hidden; color: var(--muted-strong); + font-family: var(--mono); font-size: var(--type-row-title); font-weight: var(--weight-label); letter-spacing: 0; - line-height: 1.15; text-overflow: ellipsis; white-space: nowrap; } @@ -1864,7 +1927,7 @@ } .account-pool-metric-value[data-tone="muted"] { - color: var(--muted); + color: var(--muted-strong); } .account-pool-summary-note { @@ -1877,6 +1940,121 @@ line-height: 1.2; } + .account-profile-panel { + --account-accent: var(--tone-muted); + position: relative; + isolation: isolate; + overflow: hidden; + min-width: 0; + max-height: 0; + padding: 0 28px 0 var(--space-row-indent); + border-bottom: 0 solid transparent; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--account-accent) 7%, transparent), + transparent 72% + ); + color: var(--muted); + opacity: 0; + pointer-events: none; + transform: translateY(-4px); + transition: + max-height var(--medium) var(--ease), + padding-bottom var(--medium) var(--ease), + border-width var(--medium) var(--ease), + border-color var(--medium) var(--ease), + opacity var(--medium) var(--ease), + transform var(--medium) var(--ease); + } + + .account-row.is-profile-open { + border-bottom-color: color-mix(in srgb, var(--line) 34%, transparent); + } + + .account-profile-panel.is-selected, + .account-profile-panel.is-ready { + --account-accent: var(--success); + } + + .account-profile-panel.is-fixed { + --account-accent: var(--info); + } + + .account-profile-panel.is-warn { + --account-accent: var(--warning); + } + + .account-profile-panel.is-danger { + --account-accent: var(--danger); + } + + .account-profile-panel.is-open { + max-height: 88px; + padding-bottom: 10px; + border-bottom-width: 1px; + border-bottom-color: var(--line); + opacity: 1; + pointer-events: auto; + transform: translateY(0); + } + + .account-profile-panel-inner { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + align-items: center; + gap: 8px var(--space-row-y); + min-width: 0; + padding: 8px 0 0; + } + + .account-profile-fact, + .account-profile-activity { + display: grid; + gap: 3px; + min-width: 0; + font-family: var(--mono); + font-size: var(--type-caption); + font-variant-numeric: tabular-nums; + line-height: 1.15; + } + + .account-profile-fact { + justify-items: center; + text-align: center; + } + + .account-profile-fact span, + .account-profile-activity-label { + max-width: 100%; + overflow: hidden; + color: var(--muted); + font-family: var(--sans); + font-size: var(--type-caption); + font-weight: var(--weight-label); + letter-spacing: var(--tracking-caps); + text-overflow: ellipsis; + white-space: nowrap; + } + + .account-profile-fact strong, + .account-profile-empty { + max-width: 100%; + overflow: hidden; + color: var(--muted-strong); + font-weight: var(--weight-label); + text-overflow: ellipsis; + white-space: nowrap; + } + + .account-profile-activity { + justify-items: center; + text-align: center; + } + + .account-profile-panel:last-child { + border-bottom: 0; + } + .account-pool-list { --account-grid: minmax(220px, 1.12fr) minmax(56px, 0.42fr) repeat(4, minmax(0, 1fr)); --account-gap: var(--space-row-y); @@ -1980,7 +2158,7 @@ gap: 4px var(--account-gap); min-width: 0; min-height: 42px; - padding: var(--space-account-row-y) 0 var(--space-account-row-y) var(--space-row-indent); + padding: var(--space-account-row-y) 28px var(--space-account-row-y) var(--space-row-indent); border-bottom: 1px solid var(--line); background: transparent; transition: @@ -1988,6 +2166,10 @@ color var(--fast) var(--ease); } + .account-row.is-profile-toggleable { + cursor: pointer; + } + .account-pool-list > .account-row:last-child { border-bottom: 0; } @@ -2144,7 +2326,7 @@ .account-row-id { grid-area: id; - display: flex; + display: inline-flex; align-items: center; justify-content: center; justify-self: stretch; @@ -2277,23 +2459,62 @@ color var(--fast) var(--ease); } + .account-profile-toggle { + position: absolute; + z-index: 3; + top: 50%; + right: 5px; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 24px; + padding: 0; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--muted); + cursor: pointer; + opacity: 0.58; + transform: translateY(-50%); + transition: + background-color var(--fast) var(--ease), + color var(--fast) var(--ease), + opacity var(--fast) var(--ease); + } + .account-name-button + .account-name-reroll { margin-left: 8px; } - .account-name-reroll svg { + .account-name-reroll svg, + .account-profile-toggle svg { display: block; width: 12px; height: 12px; stroke-width: 2.1; } - .account-name-reroll:hover { + .account-profile-toggle svg { + transition: transform var(--medium) var(--ease); + } + + .account-profile-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); + } + + .account-row:hover .account-profile-toggle, + .account-row:focus-within .account-profile-toggle, + .account-name-reroll:hover, + .account-profile-toggle:hover, + .account-profile-toggle[aria-expanded="true"] { background: var(--surface-muted); color: var(--text); + opacity: 1; } - .account-name-reroll:focus-visible { + .account-name-reroll:focus-visible, + .account-profile-toggle:focus-visible { color: var(--text); outline: 2px solid var(--info); outline-offset: 2px; @@ -3127,6 +3348,11 @@ font-size: var(--type-micro); } + .account-profile-panel-inner { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + } + .account-row { grid-template-areas: "id plan primary secondary credit state" @@ -3635,6 +3861,7 @@

Run History

let accountApiRefreshedAt = 0; let accountPoolSort = loadAccountPoolSort(); let accountSelectionConfirmation = null; + let expandedAccountProfileKeys = new Set(); let projectFilterMode = loadProjectFilterMode(); let projectLocationsHidden = loadProjectLocationPrivacy(); let projectSort = loadProjectSort(); @@ -3774,18 +4001,9 @@

Run History

node.innerHTML = renderMetricText(text); } - function humanizeToken(value) { - if (!value) { - return "none"; - } - - return String(value) - .replace(/_/g, " ") - .replace(/\b\w/g, (character) => character.toUpperCase()); - } - - function sentenceToken(value) { - return humanizeToken(value).toLowerCase(); + function displayToken(value) { + const token = String(value ?? "").trim(); + return token || "none"; } function normalizedDisplayText(value) { @@ -4012,22 +4230,25 @@

Run History

return parts.join(" "); } - function formatCompactCount(value) { - if (value == null) { - return "none"; - } + function formatCompactCount(value) { + if (value == null) { + return "none"; + } - const number = Math.max(0, Number(value)); + const number = Math.max(0, Number(value)); - if (number >= 1_000_000) { - return `${(number / 1_000_000).toFixed(2)}M`; - } - if (number >= 1_000) { - return `${(number / 1_000).toFixed(1)}k`; - } + if (number >= 1_000_000_000) { + return `${(number / 1_000_000_000).toFixed(1)}B`; + } + if (number >= 1_000_000) { + return `${(number / 1_000_000).toFixed(2)}M`; + } + if (number >= 1_000) { + return `${(number / 1_000).toFixed(1)}k`; + } - return String(Math.floor(number)); - } + return String(Math.floor(number)); + } function formatCompactBytes(value) { if (value == null) { @@ -4110,29 +4331,16 @@

Run History

function runStageLabel(run) { if (runStoppedProcessNeedsAttention(run)) { - return "Stopped"; + return run.process_liveness_reason || "process_stopped"; } if (runProcessStoppedWhileActive(run)) { - return "Finalizing"; + return run.current_operation || run.phase || "process_stopped"; } if (run.phase === "executing") { - switch (run.current_operation) { - case "git_credentials": - return "Preparing"; - case "repo_gate": - return "Verifying"; - case "review_writeback": - return "Finalizing"; - case "reconciliation": - return "Reconciling"; - case "waiting_external": - return "Waiting"; - default: - return humanizeToken(run.phase); - } + return displayToken(run.current_operation || run.phase); } - return humanizeToken(run.phase || run.status); + return displayToken(run.phase || run.status); } function runStaleWithoutKnownProcessAgeSeconds(run) { @@ -4245,13 +4453,7 @@

Run History

} function postReviewBlockerTitle(lane) { - if (lane.classification === "cleanup_blocked") { - return "Cleanup blocked"; - } - if (lane.classification === "closeout_blocked") { - return "Closeout blocked"; - } - return humanizeToken(lane.classification); + return displayToken(lane.classification); } function postReviewBlockerStatus(lane, blockerScope) { @@ -4259,7 +4461,7 @@

Run History

return `review ${compactStateToken(lane.review_decision)}`; } - return "needs attention"; + return "needs_attention"; } function toneForQueuedCandidate(candidate) { @@ -4331,28 +4533,12 @@

Run History

case "normal_dispatch": case "eligible_for_dispatch": return ""; - case "shared_claim_present": - return "Claimed; no live local lane visible."; - case "open_tracker_blockers": - return candidate.blocker_identifiers.length - ? `Blocked by ${candidate.blocker_identifiers.join(", ")}.` - : "Blocked by tracker dependencies."; - case "missing_dispatch_briefing": - return "Missing dispatch brief."; case "global_concurrency_exhausted": return ""; - case "issue_opted_out": - return "Automation is disabled for this issue."; case "issue_needs_attention": return ""; - case "linear_active_label_present": - return "Active ownership is still present; reconcile before dispatch."; - case "non_startable_state": - return `${candidate.state} cannot start.`; - case "terminal_state": - return "Closed in tracker; automation label remains."; default: - return humanizeToken(candidate.reason); + return displayToken(candidate.reason); } } @@ -4366,10 +4552,10 @@

Run History

return COPY.runningInline; } if (candidate.reason === "global_concurrency_exhausted") { - return "At Capacity"; + return displayToken(candidate.reason); } - return humanizeToken(candidate.classification); + return displayToken(candidate.classification); } function queuedCandidateReasonText(candidate) { @@ -4380,25 +4566,19 @@

Run History

candidate.attention?.worktree_has_tracked_changes && candidate.attention?.retry_budget_attempt_count != null ) { - return "partial patch retained"; + return "worktree_has_tracked_changes"; } if (candidate.attention?.thread_status === "systemError") { - return "app-server error"; + return displayToken(candidate.attention.thread_status); } if (candidate.attention?.last_event_type === "item/tool/call") { - return "tool call stalled"; + return displayToken(candidate.attention.last_event_type); } if (candidate.attention?.attention_error_class) { - return sentenceToken(candidate.attention.attention_error_class); - } - if (candidate.reason === "issue_needs_attention") { - return "needs attention"; - } - if (candidate.reason === "linear_active_label_present") { - return "active label present"; + return displayToken(candidate.attention.attention_error_class); } if (candidate.attention?.retry_budget_attempt_count != null) { - return "auto retry paused"; + return "retry_budget_attempt_count"; } if (candidate.reason === "global_concurrency_exhausted") { return ""; @@ -4407,7 +4587,7 @@

Run History

return ""; } - return sentenceToken(candidate.reason); + return displayToken(candidate.reason); } function queuedCandidateInlineReason(candidate) { @@ -4418,13 +4598,13 @@

Run History

if ( candidate.attention?.attention_error_class && - displayTextRepeats(reason, sentenceToken(candidate.attention.attention_error_class)) + displayTextRepeats(reason, displayToken(candidate.attention.attention_error_class)) ) { return ""; } if ( candidate.attention?.worktree_has_tracked_changes && - displayTextRepeats(reason, "patch retained") + displayTextRepeats(reason, "worktree_has_tracked_changes") ) { return ""; } @@ -4900,13 +5080,13 @@

Run History

facts.push(["Run", `${attention.run_id}${attempt}`]); } if (attention.current_operation && attention.current_operation !== "agent_run") { - facts.push(["Op", humanizeToken(attention.current_operation)]); + facts.push(["Op", displayToken(attention.current_operation)]); } if (attention.thread_status && attention.thread_status !== "systemError") { - facts.push(["Thread", humanizeToken(attention.thread_status)]); + facts.push(["Thread", displayToken(attention.thread_status)]); } if (attention.attempt_status) { - facts.push(["Attempt status", humanizeToken(attention.attempt_status)]); + facts.push(["Attempt status", displayToken(attention.attempt_status)]); } if (attention.retry_budget_attempt_count != null) { const retryMax = @@ -4919,7 +5099,7 @@

Run History

facts.push(["Auto retry", autoRetryBlockedReasonText(attention.auto_retry_blocked_reason)]); } if (attention.attention_error_class) { - facts.push(["Cause", sentenceToken(attention.attention_error_class)]); + facts.push(["Cause", displayToken(attention.attention_error_class)]); } if (attention.worktree_has_tracked_changes) { facts.push(["Patch", "retained"]); @@ -4956,18 +5136,11 @@

Run History

} function autoRetryBlockedReasonText(reason) { - if (reason === "needs_attention_label") { - return "needs-attention label set"; - } - if (reason === "linear_active_label_present") { - return "active label present"; - } - - return `blocked by ${sentenceToken(reason)}`; + return displayToken(reason); } function statusLabel(label, tone) { - return `${escapeHtml(titleCaseLabel(label))}`; + return `${escapeHtml(label)}`; } function inlineStatusFact(label, value) { @@ -5389,7 +5562,7 @@

Run History

if (outcome.final_outcome === "execution_ledger_missing") { return "Execution ledger missing"; } - return humanizeToken(outcome.final_outcome || outcome.ledger_status); + return displayToken(outcome.final_outcome || outcome.ledger_status); } return recentRunTitle(lane.latest_run); @@ -5399,7 +5572,7 @@

Run History

const outcome = historyLedgerOutcome(lane); if (historyLedgerWasLoaded(outcome)) { - return outcome.summary || `Latest recorded run event is ${humanizeToken(outcome.final_outcome)}.`; + return outcome.summary || `Latest recorded run event is ${displayToken(outcome.final_outcome)}.`; } return recentRunSummary(lane.latest_run, lane); @@ -5410,10 +5583,10 @@

Run History

const run = lane.latest_run; if (!historyLedgerWasLoaded(outcome)) { - const bits = [statusLabel(humanizeToken(run.status), tone)]; + const bits = [statusLabel(displayToken(run.status), tone)]; if (run.wait_reason) { - const waitReason = sentenceToken(run.wait_reason); + const waitReason = displayToken(run.wait_reason); if (!displayTextRepeats(recentRunSummary(run, lane), waitReason)) { bits.push(inlineStatusFact("Wait", waitReason)); } @@ -5422,17 +5595,17 @@

Run History

bits.push(inlineStatusFact("Continuation", "Pending")); } if (run.retry_kind) { - bits.push(inlineStatusFact("Retry", humanizeToken(run.retry_kind))); + bits.push(inlineStatusFact("Retry", displayToken(run.retry_kind))); } return bits; } - const bits = [statusLabel(humanizeToken(outcome.final_outcome), tone)]; + const bits = [statusLabel(displayToken(outcome.final_outcome), tone)]; - bits.push(inlineStatusFact("History", humanizeToken(outcome.ledger_status))); + bits.push(inlineStatusFact("History", displayToken(outcome.ledger_status))); if (outcome.closeout_status) { - bits.push(inlineStatusFact("Closeout", humanizeToken(outcome.closeout_status))); + bits.push(inlineStatusFact("Closeout", displayToken(outcome.closeout_status))); } if (outcome.needs_attention_reason) { bits.push(inlineStatusFact("Attention", "Recorded")); @@ -5508,70 +5681,21 @@

Run History

} function processLivenessReasonLabel(reason) { - switch (reason) { - case "process_alive": - return "alive"; - case "process_stopped": - return "stopped"; - case "host_boot_id_missing": - return "boot identity missing"; - case "host_boot_id_unavailable": - return "boot identity unavailable"; - case "host_boot_id_mismatch": - return "previous boot"; - case "process_start_identity_missing": - return "start identity missing"; - case "process_start_identity_unavailable": - return "start identity unavailable"; - case "process_start_identity_mismatch": - return "process identity changed"; - case "process_identity_mismatch": - return "process identity changed"; - default: - return reason ? reason.replaceAll("_", " ") : "unknown"; - } + return displayToken(reason || "unknown"); } function runExecutionLivenessSummary(run) { - switch (run.execution_liveness) { - case "process_alive": - return "process alive"; - case "thread_active": - return "thread active"; - case "protocol_observed": - return "protocol active"; - case "process_stopped": - return "process stopped"; - case "process_identity_mismatch": - return "process identity mismatch"; - case "not_running": - return "not running"; - default: - return "liveness unknown"; - } + return displayToken(run.execution_liveness || "liveness_unknown"); } function runQueueLeaseSummary(run) { const leaseState = run.queue_lease_state || (run.active_lease ? "held" : "not_held"); if (leaseState === "held") { - return "lease held"; - } - - switch (run.execution_liveness) { - case "process_alive": - return "no lease; live process"; - case "thread_active": - return "no lease; active thread"; - case "protocol_observed": - return "no lease; protocol active"; - case "process_stopped": - return "no lease; stopped process"; - case "process_identity_mismatch": - return "no lease; process identity mismatch"; - default: - return "no lease"; + return "held"; } + + return `${leaseState}; ${displayToken(run.execution_liveness || "liveness_unknown")}`; } function runApprovalSummary(run) { @@ -5748,7 +5872,7 @@

Run History

return [ ["Updated", formatTimestampCompact(run.updated_at)], ["Attempt", String(run.attempt_number ?? "none")], - ["Status", humanizeToken(run.status)], + ["Status", displayToken(run.status)], ["Events", String(run.event_count ?? 0)], ]; } @@ -6253,21 +6377,11 @@

Run History

function codexAccountTokenLabel(refreshStatus) { const status = String(refreshStatus || "").toLowerCase(); - if (status === "not_needed" || status === "none") { - return "token ok"; - } - if (status === "succeeded" || status === "refreshed") { - return "token refreshed"; - } - if (status === "failed") { - return "refresh failed"; - } - - return status ? humanizeToken(status) : "token unknown"; + return status || "token_unknown"; } function codexAccountTokenValue(refreshStatus) { - return codexAccountTokenLabel(refreshStatus).replace(/^token /, ""); + return codexAccountTokenLabel(refreshStatus); } function codexAccountRefreshStatusNeedsAttention(refreshStatus) { @@ -6387,6 +6501,17 @@

Run History

: []; } + function codexAccountProfileDailyUsage(account) { + return Array.isArray(account?.profile_daily_usage) + ? account.profile_daily_usage + .filter((record) => record?.date && codexAccountNumber(record?.tokens) != null) + .map((record) => ({ + date: String(record.date), + tokens: Math.max(0, codexAccountNumber(record.tokens) || 0), + })) + : []; + } + function previousUsageDate(value) { const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) { @@ -6527,6 +6652,199 @@

Run History

return parts.join(" ") || "<1m"; } + function formatCodexAccountProfileDuration(seconds) { + const value = codexAccountNumber(seconds); + if (value == null) { + return ""; + } + + return formatCodexAccountResetDuration(value); + } + + function codexAccountProfileMetaFacts(account) { + if (!account) { + return []; + } + const currentStreak = codexAccountNumber(account.profile_current_streak_days); + const longestStreak = codexAccountNumber(account.profile_longest_streak_days); + const streak = + currentStreak != null && longestStreak != null + ? `${currentStreak}/${longestStreak}d` + : currentStreak != null + ? `${currentStreak}d` + : longestStreak != null + ? `${longestStreak}d` + : ""; + const task = formatCodexAccountProfileDuration(account.profile_longest_task_seconds); + const facts = [ + codexAccountNumber(account.profile_lifetime_tokens) != null + ? ["tok", formatCompactCount(account.profile_lifetime_tokens)] + : null, + codexAccountNumber(account.profile_peak_daily_tokens) != null + ? ["peak", formatCompactCount(account.profile_peak_daily_tokens)] + : null, + streak ? ["streak", streak] : null, + task ? ["task", task] : null, + ]; + + return facts.filter(Boolean); + } + + function codexAccountProfileAggregate(accounts) { + const dailyUsageByDate = new Map(); + let lifetimeTokens = null; + let peakTokensFallback = null; + let longestTaskSeconds = null; + let currentStreakDays = null; + let longestStreakDays = null; + + for (const account of accounts) { + const lifetime = codexAccountNumber(account?.profile_lifetime_tokens); + if (lifetime != null) { + lifetimeTokens = (lifetimeTokens || 0) + lifetime; + } + const peak = codexAccountNumber(account?.profile_peak_daily_tokens); + if (peak != null) { + peakTokensFallback = (peakTokensFallback || 0) + peak; + } + const task = codexAccountNumber(account?.profile_longest_task_seconds); + if (task != null) { + longestTaskSeconds = Math.max(longestTaskSeconds || 0, task); + } + const currentStreak = codexAccountNumber(account?.profile_current_streak_days); + if (currentStreak != null) { + currentStreakDays = Math.max(currentStreakDays || 0, currentStreak); + } + const longestStreak = codexAccountNumber(account?.profile_longest_streak_days); + if (longestStreak != null) { + longestStreakDays = Math.max(longestStreakDays || 0, longestStreak); + } + for (const record of codexAccountProfileDailyUsage(account)) { + dailyUsageByDate.set(record.date, (dailyUsageByDate.get(record.date) || 0) + record.tokens); + } + } + + const dailyUsage = Array.from(dailyUsageByDate, ([date, tokens]) => ({ date, tokens })) + .sort((left, right) => String(left.date).localeCompare(String(right.date))); + const peakFromDailyUsage = dailyUsage.reduce( + (peak, record) => Math.max(peak, record.tokens), + 0, + ); + const peakDailyTokens = peakFromDailyUsage > 0 ? peakFromDailyUsage : peakTokensFallback; + const aggregate = { + profile_lifetime_tokens: lifetimeTokens, + profile_peak_daily_tokens: peakDailyTokens, + profile_longest_task_seconds: longestTaskSeconds, + profile_current_streak_days: currentStreakDays, + profile_longest_streak_days: longestStreakDays, + profile_daily_usage: dailyUsage, + }; + const hasMetric = codexAccountProfileMetaFacts(aggregate).length > 0; + + return hasMetric || dailyUsage.length ? aggregate : null; + } + + function renderCodexAccountActivityStrip( + account, + labelPrefix, + stripClass, + tileClass, + ) { + const records = codexAccountProfileDailyUsage(account).slice(-35); + if (!records.length) { + return ""; + } + const peak = records.reduce((max, record) => Math.max(max, record.tokens), 0); + const title = `${labelPrefix}: ${records.length} days, peak ${formatCompactCount(peak)} tokens`; + + return ` +
+ ${records + .map((record) => { + const ratio = peak > 0 ? record.tokens / peak : 0; + const opacity = record.tokens > 0 ? 0.24 + ratio * 0.76 : 0.12; + const tileTitle = `${record.date}: ${formatCompactCount(record.tokens)} tokens`; + return ``; + }) + .join("")} +
+ `; + } + + function renderCodexAccountPoolActivityStrip(account) { + return renderCodexAccountActivityStrip( + account, + "Pool token activity", + "account-pool-activity-strip", + "account-pool-activity-tile", + ); + } + + function renderCodexAccountProfileActivityStrip(account) { + return renderCodexAccountActivityStrip( + account, + "Account token activity", + "account-profile-activity-strip", + "account-profile-activity-tile", + ); + } + + function codexAccountProfileExpansionKey(account) { + return codexAccountRandomNameKey(account); + } + + function codexAccountHasProfileDetails(account) { + return ( + codexAccountProfileMetaFacts(account).length > 0 || + codexAccountProfileDailyUsage(account).length > 0 + ); + } + + function codexAccountProfileExpanded(account) { + const key = codexAccountProfileExpansionKey(account); + return Boolean(key && expandedAccountProfileKeys.has(key)); + } + + function toggleCodexAccountProfileKey(key) { + if (!key) { + return false; + } + + expandedAccountProfileKeys = new Set(expandedAccountProfileKeys); + if (expandedAccountProfileKeys.has(key)) { + expandedAccountProfileKeys.delete(key); + } else { + expandedAccountProfileKeys.add(key); + } + if (lastDashboardRender) { + renderDashboardState(lastDashboardRender); + } + + return true; + } + + function accountProfileRowClickIsSuppressed(target) { + return Boolean( + target.closest( + [ + "button", + "a", + "input", + "select", + "textarea", + "summary", + "details", + "[contenteditable='true']", + "[data-account-sort-key]", + "[data-account-privacy-toggle]", + "[data-account-confirm-action]", + "[data-account-name-reroll]", + "[data-account-profile-toggle]", + ].join(","), + ), + ); + } + function codexAccountResetDistance(value) { const seconds = codexAccountNumber(value); if (seconds == null || seconds <= 0) { @@ -6662,27 +6980,17 @@

Run History

const reached = codexAccountReachedType(account); const status = reached || account.status || "selected"; const refresh = String(account.refresh_status || "").toLowerCase(); - const normalizedStatus = String(status).toLowerCase(); if (codexAccountUsageLimited(account)) { - return "Limited"; + return reached || (String(status).trim() && status !== "available" ? status : "usage_limited"); } if (refresh.includes("failed")) { - return "Refresh failed"; + return refresh; } if (refresh && !["not_needed", "refreshed", "succeeded", "none"].includes(refresh)) { return codexAccountTokenValue(account.refresh_status); } - if (normalizedStatus === "selected") { - return "Active"; - } - if (normalizedStatus === "available") { - return "Ready"; - } - if (normalizedStatus.includes("limit")) { - return "Limited"; - } - return humanizeToken(status); + return displayToken(status); } function codexAccountCreditsSummary(account) { @@ -6796,6 +7104,22 @@

Run History

`; } + function renderCodexAccountProfileToggle(account, expanded) { + const key = codexAccountProfileExpansionKey(account); + const label = expanded ? "Hide account profile" : "Show account profile"; + if (!key) { + return ""; + } + + return ` + + `; + } + function renderCodexAccountPoolRow(account, snapshot) { const weight = codexAccountCapacityLabel(account); const statusTone = codexAccountStatusTone(account); @@ -6808,42 +7132,106 @@

Run History

selector && accountSelectionConfirmationMatches(action, selector) ? " is-armed" : ""; const selectedClass = String(account.status || "").toLowerCase() === "selected" ? " is-selected" : ""; - const metaFacts = codexAccountMetaFacts(account); - const credits = codexAccountCreditsSummary(account); - const creditTone = codexAccountCreditsTone(account); - const creditClass = creditTone ? ` is-${creditTone}` : ""; - const identityClass = codexAccountShowsEmail(account) ? " is-machine" : ""; + const metaFacts = codexAccountMetaFacts(account); + const credits = codexAccountCreditsSummary(account); + const creditTone = codexAccountCreditsTone(account); + const creditClass = creditTone ? ` is-${creditTone}` : ""; + const identityClass = codexAccountShowsEmail(account) ? " is-machine" : ""; + const profileKey = codexAccountProfileExpansionKey(account); + const hasProfileDetails = codexAccountHasProfileDetails(account); + const profileExpanded = hasProfileDetails && codexAccountProfileExpanded(account); + const profileOpenClass = profileExpanded ? " is-profile-open" : ""; + const profileToggleableClass = hasProfileDetails ? " is-profile-toggleable" : ""; + const profileRowToggleAttribute = hasProfileDetails + ? ` data-account-profile-row-toggle="${escapeHtml(profileKey)}"` + : ""; - return ` -