import Foundation public struct ClaudeStatusSnapshot: Sendable { public let sessionPercentLeft: Int? public let weeklyPercentLeft: Int? public let opusPercentLeft: Int? public let extraRateWindows: [NamedRateWindow] public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public let primaryResetDescription: String? public let secondaryResetDescription: String? public let opusResetDescription: String? public let rawText: String public init( sessionPercentLeft: Int?, weeklyPercentLeft: Int?, opusPercentLeft: Int?, accountEmail: String?, accountOrganization: String?, loginMethod: String?, primaryResetDescription: String?, secondaryResetDescription: String?, opusResetDescription: String?, rawText: String, extraRateWindows: [NamedRateWindow] = []) { self.sessionPercentLeft = sessionPercentLeft self.weeklyPercentLeft = weeklyPercentLeft self.opusPercentLeft = opusPercentLeft self.extraRateWindows = extraRateWindows self.accountEmail = accountEmail self.accountOrganization = accountOrganization self.loginMethod = loginMethod self.primaryResetDescription = primaryResetDescription self.secondaryResetDescription = secondaryResetDescription self.opusResetDescription = opusResetDescription self.rawText = rawText } } public struct ClaudeAccountIdentity: Sendable { public let accountEmail: String? public let accountOrganization: String? public let loginMethod: String? public init(accountEmail: String?, accountOrganization: String?, loginMethod: String?) { self.accountEmail = accountEmail self.accountOrganization = accountOrganization self.loginMethod = loginMethod } } public enum ClaudeStatusProbeError: LocalizedError, Sendable { case claudeNotInstalled case parseFailed(String) case timedOut public var errorDescription: String? { switch self { case .claudeNotInstalled: "Claude CLI is not installed or not on PATH." case let .parseFailed(msg): "Could not parse Claude usage: \(msg)" case .timedOut: "Claude usage probe timed out." } } } /// Runs `claude` inside a PTY, sends `/usage`, and parses the rendered text panel. public struct ClaudeStatusProbe: Sendable { public static let subscriptionQuotaUnavailableDescription = "Claude CLI /usage returned a subscription notice without session quota data. " + "Local cost and token history remain available." public var claudeBinary: String = "claude" public var timeout: TimeInterval = 20.0 public var keepCLISessionsAlive: Bool = false private static let log = CodexBarLog.logger(LogCategories.claudeProbe) #if DEBUG public typealias FetchOverride = @Sendable (String, TimeInterval, Bool) async throws -> ClaudeStatusSnapshot @TaskLocal static var fetchOverride: FetchOverride? #endif public init(claudeBinary: String = "claude", timeout: TimeInterval = 20.0, keepCLISessionsAlive: Bool = false) { self.claudeBinary = claudeBinary self.timeout = timeout self.keepCLISessionsAlive = keepCLISessionsAlive } #if DEBUG public static var currentFetchOverrideForTesting: FetchOverride? { self.fetchOverride } public static func withFetchOverrideForTesting( _ override: FetchOverride?, operation: () async throws -> T) async rethrows -> T { try await self.$fetchOverride.withValue(override) { try await operation() } } public static func withFetchOverrideForTesting( _ override: FetchOverride?, operation: () async -> T) async -> T { await self.$fetchOverride.withValue(override) { await operation() } } #endif public func fetch() async throws -> ClaudeStatusSnapshot { let resolved = Self.resolvedBinaryPath(binaryName: self.claudeBinary) guard let resolved, Self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } // Run commands sequentially through a shared Claude session to avoid warm-up churn. let timeout = self.timeout let keepAlive = self.keepCLISessionsAlive #if DEBUG if let override = Self.fetchOverride { return try await override(resolved, timeout, keepAlive) } #endif do { var usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: timeout) if !Self.usageOutputLooksRelevant(usage) { Self.log.debug("Claude CLI /usage looked like startup output; retrying once") usage = try await Self.capture(subcommand: "/usage", binary: resolved, timeout: max(timeout, 14)) } // `/status` only enriches a valid usage snapshot with identity. Terminal usage errors and loading stalls // cannot be repaired by it, so fail now instead of paying for another interactive CLI round trip. try Self.validateUsageBeforeStatusProbe(usage) let status = try? await Self.capture(subcommand: "/status", binary: resolved, timeout: min(timeout, 12)) let snap = try Self.parse(text: usage, statusText: status) Self.log.info("Claude CLI scrape ok", metadata: [ "sessionPercentLeft": "\(snap.sessionPercentLeft ?? -1)", "weeklyPercentLeft": "\(snap.weeklyPercentLeft ?? -1)", "opusPercentLeft": "\(snap.opusPercentLeft ?? -1)", ]) if !keepAlive { await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } return snap } catch { if !keepAlive { await Self.resetTransientCLISessionAndCleanupProbeArtifacts() } throw error } } private static func resetTransientCLISessionAndCleanupProbeArtifacts() async { await ClaudeCLISession.current.reset() let removed = ClaudeProbeSessionArtifactCleaner.cleanupProbeSessionArtifacts() guard !removed.isEmpty else { return } Self.log.debug("Claude probe session artifacts removed", metadata: ["count": "\(removed.count)"]) } } extension ClaudeStatusProbe { // MARK: - Parsing helpers private struct LabelSearchContext { let lines: [String] let normalizedLines: [String] let normalizedData: Data init(text: String) { self.lines = text.components(separatedBy: .newlines) self.normalizedLines = self.lines.map { ClaudeStatusProbe.normalizedForLabelSearch($0) } let normalized = ClaudeStatusProbe.normalizedForLabelSearch(text) self.normalizedData = Data(normalized.utf8) } func contains(_ needle: String) -> Bool { self.normalizedData.range(of: Data(needle.utf8)) != nil } } public static func parse(text: String, statusText: String? = nil) throws -> ClaudeStatusSnapshot { let clean = TextParsing.stripANSICodes(text) let statusClean = statusText.map(TextParsing.stripANSICodes) guard !clean.isEmpty else { throw ClaudeStatusProbeError.timedOut } let shouldDump = ProcessInfo.processInfo.environment["DEBUG_CLAUDE_DUMP"] == "1" if let usageError = self.extractUsageError(text: clean) { Self.dumpIfNeeded( enabled: shouldDump, reason: "usageError: \(usageError)", usage: clean, status: statusText) throw ClaudeStatusProbeError.parseFailed(usageError) } let latestUsagePanel = self.trimToLatestUsagePanel(clean) if self.isUsageStillLoading(text: latestUsagePanel ?? clean) { Self.dumpIfNeeded( enabled: shouldDump, reason: "usage still loading", usage: clean, status: statusText) throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") } // Claude CLI renders /usage as a TUI. Our PTY capture includes earlier screen fragments (including a status // line // with a "0%" context meter) before the usage panel is drawn. To keep parsing stable, trim to the last // Settings/Usage panel when present. let usagePanelText = latestUsagePanel ?? clean let labelContext = LabelSearchContext(text: usagePanelText) var sessionPct = self.extractPercent(labelSubstring: "Current session", context: labelContext) let weeklyPct = self.extractPercent(labelSubstring: "Current week (all models)", context: labelContext) let opusLabels = [ "Current week (Opus)", "Current week (Sonnet only)", "Current week (Sonnet)", ] let opusPct = self.extractPercent(labelSubstrings: opusLabels, context: labelContext) // Fallback: order-based percent scraping when labels are present but the surrounding layout moved. // Only apply the fallback when the corresponding label exists in the rendered panel; enterprise accounts // may omit the weekly panel entirely, and we should treat that as "unavailable" rather than guessing. let weeklyModels = Set(labelContext.lines.compactMap(self.weeklyModelName).map(self.normalizedForLabelSearch)) let hasAllModelsWeeklyLabel = weeklyModels.contains("allmodels") let opusModels = Set(opusLabels.compactMap(self.weeklyModelName).map(self.normalizedForLabelSearch)) let hasOpusLabel = !weeklyModels.isDisjoint(with: opusModels) if sessionPct == nil { let ordered = self.allPercents(usagePanelText) if sessionPct == nil, ordered.indices.contains(0) { sessionPct = ordered[0] } } let identity = Self.parseIdentity(usageText: clean, statusText: statusClean) guard let sessionPct else { Self.dumpIfNeeded( enabled: shouldDump, reason: "missing session label", usage: clean, status: statusText) if shouldDump { let tail = usagePanelText.suffix(1800) let snippet = tail.isEmpty ? "(empty)" : String(tail) throw ClaudeStatusProbeError.parseFailed( "Missing Current session.\n\n--- Clean usage tail ---\n\(snippet)") } throw ClaudeStatusProbeError.parseFailed("Missing Current session.") } let sessionReset = self.extractReset(labelSubstring: "Current session", context: labelContext) let weeklyReset = hasAllModelsWeeklyLabel ? self.extractReset(labelSubstring: "Current week (all models)", context: labelContext) : nil let opusReset = hasOpusLabel ? self.extractReset(labelSubstrings: opusLabels, context: labelContext) : nil let scopedWeeklyUsages = self.extractScopedWeeklyUsages(context: labelContext) let extraRateWindows = self.extraRateWindows( fromScopedWeeklyUsages: scopedWeeklyUsages, fallbackResetDescription: weeklyReset) return ClaudeStatusSnapshot( sessionPercentLeft: sessionPct, weeklyPercentLeft: weeklyPct, opusPercentLeft: opusPct, accountEmail: identity.accountEmail, accountOrganization: identity.accountOrganization, loginMethod: identity.loginMethod, primaryResetDescription: sessionReset, secondaryResetDescription: weeklyReset, opusResetDescription: opusReset, rawText: text + (statusText ?? ""), extraRateWindows: extraRateWindows) } public static func parseIdentity(usageText: String?, statusText: String?) -> ClaudeAccountIdentity { let usageClean = usageText.map(TextParsing.stripANSICodes) ?? "" let statusClean = statusText.map(TextParsing.stripANSICodes) return self.extractIdentity(usageText: usageClean, statusText: statusClean) } public static func fetchIdentity( timeout: TimeInterval = 12.0, environment: [String: String] = ProcessInfo.processInfo.environment) async throws -> ClaudeAccountIdentity { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } let statusText = try await Self.capture(subcommand: "/status", binary: resolved, timeout: timeout) return Self.parseIdentity(usageText: nil, statusText: statusText) } public static func touchOAuthAuthPath( timeout: TimeInterval = 8, environment: [String: String] = ProcessInfo.processInfo.environment) async throws { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) guard let resolved, self.isBinaryAvailable(resolved) else { throw ClaudeStatusProbeError.claudeNotInstalled } do { // Use a more robust capture configuration than the standard `/status` scrape: // - Avoid the short idle-timeout which can terminate the session while CLI auth checks are still running. // - We intentionally do not parse output here; success is "the command ran without timing out". _ = try await ClaudeCLISession.shared.capture( subcommand: "/status", binary: resolved, timeout: timeout, idleTimeout: nil, stopOnSubstrings: [], settleAfterStop: 0.8, sendEnterEvery: 0.8) await ClaudeCLISession.shared.reset() } catch { await ClaudeCLISession.shared.reset() throw error } } public static func isClaudeBinaryAvailable( environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { let resolved = self.resolvedBinaryPath(binaryName: "claude", environment: environment) return self.isBinaryAvailable(resolved) } private static func extractPercent(labelSubstring: String, context: LabelSearchContext) -> Int? { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, line) in lines.enumerated() { let normalizedLine = context.normalizedLines[idx] guard self.matchesLabel( line: line, normalizedLine: normalizedLine, labelSubstring: labelSubstring, normalizedLabel: label) else { continue } // Claude's usage panel can take a moment to render percentages (especially on enterprise accounts), // so scan a larger window than the original 3–4 lines. let window = lines.dropFirst(idx).prefix(12) for candidate in window { if self.crossesLabelBoundary( line: candidate, labelSubstring: labelSubstring, normalizedLabel: label) { break } if let pct = self.percentFromLine(candidate) { return pct } } } return nil } private static func usageOutputLooksRelevant(_ text: String) -> Bool { let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } return normalized.contains("currentsession") || normalized.contains("currentweek") || normalized.contains("loadingusage") || normalized.contains("failedtoloadusagedata") || self.usageCaptureHasSubscriptionNotice(normalized) } private static func validateUsageBeforeStatusProbe(_ text: String) throws { let clean = TextParsing.stripANSICodes(text) if let usageError = self.extractUsageError(text: clean) { throw ClaudeStatusProbeError.parseFailed(usageError) } let latestUsagePanel = self.trimToLatestUsagePanel(clean) if self.isUsageStillLoading(text: latestUsagePanel ?? clean) { throw ClaudeStatusProbeError.parseFailed("Claude CLI /usage is still loading usage data.") } } private static func extractPercent(labelSubstrings: [String], context: LabelSearchContext) -> Int? { for label in labelSubstrings { if let value = self.extractPercent(labelSubstring: label, context: context) { return value } } return nil } private struct ScopedWeeklyUsage { let modelName: String let percentLeft: Int let resetDescription: String? } private static func extractScopedWeeklyUsages(context: LabelSearchContext) -> [ScopedWeeklyUsage] { var usageIndexByModel: [String: Int] = [:] var usages: [ScopedWeeklyUsage] = [] for (index, line) in context.lines.enumerated() { guard let modelName = self.weeklyModelName(from: line) else { continue } let normalizedModel = self.normalizedForLabelSearch(modelName) guard normalizedModel != "allmodels", !normalizedModel.isEmpty else { continue } let window = context.lines.dropFirst(index).prefix(14) var percentLeft: Int? var resetDescription: String? for candidate in window { if self.crossesLabelBoundary( line: candidate, labelSubstring: line, normalizedLabel: self.normalizedForLabelSearch(line)) { break } if percentLeft == nil { percentLeft = self.percentFromLine(candidate) } if resetDescription == nil { resetDescription = self.resetFromLine(candidate) } } guard let percentLeft else { continue } let usage = ScopedWeeklyUsage( modelName: modelName, percentLeft: percentLeft, resetDescription: resetDescription) if let existingIndex = usageIndexByModel[normalizedModel] { usages[existingIndex] = usage } else { usageIndexByModel[normalizedModel] = usages.endIndex usages.append(usage) } } return usages } private static func extraRateWindows( fromScopedWeeklyUsages usages: [ScopedWeeklyUsage], fallbackResetDescription: String?) -> [NamedRateWindow] { usages.compactMap { usage in let normalizedModel = self.normalizedForLabelSearch(usage.modelName) guard normalizedModel != "opus", normalizedModel != "sonnet", normalizedModel != "sonnetonly" else { return nil } let usedPercent = max(0, min(100, 100 - Double(usage.percentLeft))) let modelTitle = normalizedModel.hasSuffix("only") ? usage.modelName : "\(usage.modelName) only" let resetDescription = usage.resetDescription ?? fallbackResetDescription return NamedRateWindow( id: "claude-weekly-scoped-\(self.slug(usage.modelName))", title: modelTitle, window: RateWindow( usedPercent: usedPercent, windowMinutes: 7 * 24 * 60, resetsAt: self.parseResetDate( from: resetDescription, expectedWindow: 7 * 24 * 60 * 60), resetDescription: resetDescription)) } } private static func slug(_ value: String) -> String { var result = "" var lastWasDash = false for scalar in value.lowercased().unicodeScalars { if CharacterSet.alphanumerics.contains(scalar) { result.unicodeScalars.append(scalar) lastWasDash = false } else if !lastWasDash { result.append("-") lastWasDash = true } } return result.trimmingCharacters(in: CharacterSet(charactersIn: "-")) } private static func percentFromLine(_ line: String, assumeRemainingWhenUnclear: Bool = false) -> Int? { if self.isLikelyStatusContextLine(line) { return nil } // Allow optional Unicode whitespace before % to handle CLI formatting changes. let pattern = #"([0-9]{1,3}(?:\.[0-9]+)?)\p{Zs}*%"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(line.startIndex..= 2, let valRange = Range(match.range(at: 1), in: line) else { return nil } let rawVal = Double(line[valRange]) ?? 0 let clamped = max(0, min(100, rawVal)) let lower = line.lowercased() let usedKeywords = ["used", "spent", "consumed"] let remainingKeywords = ["left", "remaining", "available"] if usedKeywords.contains(where: lower.contains) { return Int(max(0, min(100, 100 - clamped)).rounded()) } if remainingKeywords.contains(where: lower.contains) { return Int(clamped.rounded()) } return assumeRemainingWhenUnclear ? Int(clamped.rounded()) : nil } private static func isLikelyStatusContextLine(_ line: String) -> Bool { guard line.contains("|") else { return false } let lower = line.lowercased() let modelTokens = ["opus", "sonnet", "haiku", "default"] return modelTokens.contains(where: lower.contains) } private static func extractFirst(pattern: String, text: String) -> String? { guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } let range = NSRange(text.startIndex..= 2, let r = Range(match.range(at: 1), in: text) else { return nil } return String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines) } private static func extractIdentity(usageText: String, statusText: String?) -> ClaudeAccountIdentity { let emailPatterns = [ #"(?i)Account:\s+([^\s@]+@[^\s@]+)"#, #"(?i)Email:\s+([^\s@]+@[^\s@]+)"#, ] let looseEmailPatterns = [ #"(?i)Account:\s+(\S+)"#, #"(?i)Email:\s+(\S+)"#, ] let email = emailPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? emailPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first ?? looseEmailPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? looseEmailPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first ?? self.extractFirst( pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, text: usageText) ?? self.extractFirst( pattern: #"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}"#, text: statusText ?? "") let orgPatterns = [ #"(?i)Org:\s*(.+)"#, #"(?i)Organization:\s*(.+)"#, ] let orgRaw = orgPatterns .compactMap { self.extractFirst(pattern: $0, text: usageText) } .first ?? orgPatterns .compactMap { self.extractFirst(pattern: $0, text: statusText ?? "") } .first let org: String? = { guard let orgText = orgRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !orgText.isEmpty else { return nil } // Suppress org if it’s just the email prefix (common in CLI panels). if let email, orgText.lowercased().hasPrefix(email.lowercased()) { return nil } return orgText }() // Prefer explicit login method from /status, then fall back to /usage header heuristics. let login = self.extractLoginMethod(text: statusText ?? "") ?? self.extractLoginMethod(text: usageText) return ClaudeAccountIdentity(accountEmail: email, accountOrganization: org, loginMethod: login) } private static func extractUsageError(text: String) -> String? { if let jsonHint = self.extractUsageErrorJSON(text: text) { return jsonHint } let lower = text.lowercased() let compact = lower.filter { !$0.isWhitespace } if lower.contains("do you trust the files in this folder?"), !lower.contains("current session") { let folder = self.extractFirst( pattern: #"Do you trust the files in this folder\?\s*(?:\r?\n)+\s*([^\r\n]+)"#, text: text) let folderHint = folder.flatMap { value in let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } if let folderHint { return """ Claude CLI is waiting for a folder trust prompt (\(folderHint)). CodexBar tries to auto-accept this, \ but if it keeps appearing run: `cd "\(folderHint)" && claude` and choose “Yes, proceed”, then retry. """ } return """ Claude CLI is waiting for a folder trust prompt. CodexBar tries to auto-accept this, but if it keeps \ appearing open `claude` once, choose “Yes, proceed”, then retry. """ } if lower.contains("token_expired") || lower.contains("token has expired") { return "Claude CLI token expired. Run `claude login` to refresh." } if lower.contains("authentication_error") { return "Claude CLI authentication error. Run `claude login`." } if lower.contains("rate_limit_error") || lower.contains("rate limited") || compact.contains("ratelimited") { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } if self.isSubscriptionNoticeOnly(text: text) { return self.subscriptionQuotaUnavailableDescription } if lower.contains("failed to load usage data") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } if compact.contains("failedtoloadusagedata") { return "Claude CLI could not load usage data. Open the CLI and retry `/usage`." } return nil } private static func isUsageStillLoading(text: String) -> Bool { let normalized = TextParsing.stripANSICodes(text).lowercased().filter { !$0.isWhitespace } guard normalized.contains("loadingusage") else { return false } return !self.usageCaptureHasSessionValue(normalized) && self.allPercents(text).isEmpty } /// Returns true when the text contains only a subscription notice with no session/weekly quota data. /// CLI 2.1+ can return "You are currently using your subscription to power your Claude Code usage" /// which lacks the "Current session" / "Current week" labels and percentage values needed for quota display. /// A PTY capture may contain both an intermediate "Loading usage data…" panel and the final subscription /// notice; `loadingusage` is not treated as quota data in this check so mixed captures surface correctly. private static func isSubscriptionNoticeOnly(text: String) -> Bool { let normalized = text.lowercased().filter { !$0.isWhitespace } guard normalized.contains("currentlyusingyoursubscription") else { return false } guard normalized.contains("claudecodeusage") else { return false } // Only real session/week labels and actual percentage values count as quota data. // `loadingusage` is not quota data — a mixed loading+subscription PTY capture should // surface the subscription error, not a still-loading stall. let hasQuotaData = normalized.contains("currentsession") || normalized.contains("currentweek") || normalized.contains("%used") || normalized.contains("%left") || normalized.contains("%remaining") || normalized.contains("%available") return !hasQuotaData } public static func isSubscriptionQuotaUnavailableDescription(_ text: String?) -> Bool { text?.localizedCaseInsensitiveContains("subscription notice without session quota data") == true } /// Collect remaining percentages in the order they appear; used as a backup when labels move/rename. private static func allPercents(_ text: String) -> [Int] { let lines = text.components(separatedBy: .newlines) let normalized = text.lowercased().filter { !$0.isWhitespace } let hasUsageWindows = normalized.contains("currentsession") || normalized.contains("currentweek") let hasLoading = normalized.contains("loadingusage") let hasUsagePercentKeywords = normalized.contains("used") || normalized.contains("left") || normalized.contains("remaining") || normalized.contains("available") let loadingOnly = hasLoading && !hasUsageWindows guard hasUsageWindows || hasLoading else { return [] } if loadingOnly { return [] } guard hasUsagePercentKeywords else { return [] } // Keep this strict to avoid matching Claude's status-line context meter (e.g. "0%") as session usage when the // /usage panel is still rendering. return lines.compactMap { self.percentFromLine($0, assumeRemainingWhenUnclear: false) } } /// Attempts to isolate the most recent /usage panel output from a PTY capture. /// The Claude TUI draws a "Settings: … Usage …" header; we slice from its last occurrence to avoid earlier screen /// fragments (like the status bar) contaminating percent scraping. private static func trimToLatestUsagePanel(_ text: String) -> String? { guard let settingsRange = text.range(of: "Settings:", options: [.caseInsensitive, .backwards]) else { return nil } let tail = text[settingsRange.lowerBound...] guard tail.range(of: "Usage", options: .caseInsensitive) != nil else { return nil } let lower = tail.lowercased() let hasPercent = lower.contains("%") let hasUsageWords = lower.contains("used") || lower.contains("left") || lower.contains("remaining") || lower.contains("available") let hasLoading = lower.contains("loading usage") guard (hasPercent && hasUsageWords) || hasLoading else { return nil } return String(tail) } private static func extractReset(labelSubstring: String, context: LabelSearchContext) -> String? { let lines = context.lines let label = self.normalizedForLabelSearch(labelSubstring) for (idx, line) in lines.enumerated() { let normalizedLine = context.normalizedLines[idx] guard self.matchesLabel( line: line, normalizedLine: normalizedLine, labelSubstring: labelSubstring, normalizedLabel: label) else { continue } let window = lines.dropFirst(idx).prefix(14) for candidate in window { if self.crossesLabelBoundary( line: candidate, labelSubstring: labelSubstring, normalizedLabel: label) { break } if let reset = self.resetFromLine(candidate) { return reset } } } return nil } private static func matchesLabel( line: String, normalizedLine: String, labelSubstring: String, normalizedLabel: String) -> Bool { guard let expectedModel = self.weeklyModelName(from: labelSubstring) else { return normalizedLine.contains(normalizedLabel) } guard let actualModel = self.weeklyModelName(from: line) else { return false } return self.normalizedForLabelSearch(actualModel) == self.normalizedForLabelSearch(expectedModel) } private static func crossesLabelBoundary( line: String, labelSubstring: String, normalizedLabel: String) -> Bool { let normalizedLine = self.normalizedForLabelSearch(line) guard normalizedLine.hasPrefix("current") else { return false } guard let expectedModel = self.weeklyModelName(from: labelSubstring) else { return !normalizedLine.contains(normalizedLabel) } guard let actualModel = self.weeklyModelName(from: line) else { return true } return self.normalizedForLabelSearch(actualModel) != self.normalizedForLabelSearch(expectedModel) } private static func weeklyModelName(from line: String) -> String? { guard let regex = try? NSRegularExpression( pattern: #"current\s*week\s*\(([^)]+)\)"#, options: [.caseInsensitive]) else { return nil } let range = NSRange(line.startIndex..= 2, let modelRange = Range(match.range(at: 1), in: line) else { return nil } return String(line[modelRange]).trimmingCharacters(in: .whitespacesAndNewlines) } private static func extractReset(labelSubstrings: [String], context: LabelSearchContext) -> String? { for label in labelSubstrings { if let value = self.extractReset(labelSubstring: label, context: context) { return value } } return nil } private static func resetFromLine(_ line: String) -> String? { let range = line.range(of: #"(?i)\bresets?\b"#, options: .regularExpression) ?? line.range(of: #"\b(?:Reset|Resets)(?=[A-Z0-9])"#, options: .regularExpression) guard let range else { return nil } let raw = String(line[range.lowerBound...]).trimmingCharacters(in: .whitespacesAndNewlines) return self.cleanResetLine(raw) } private static func normalizedForLabelSearch(_ text: String) -> String { String(text.lowercased().unicodeScalars.filter(CharacterSet.alphanumerics.contains)) } /// Capture all "Reset"/"Resets" strings to surface in the menu. private static func allResets(_ text: String) -> [String] { let pat = #"\bResets?\b[^\r\n]*"# guard let regex = try? NSRegularExpression(pattern: pat, options: [.caseInsensitive]) else { return [] } let nsrange = NSRange(text.startIndex.. String { // TTY capture sometimes appends a stray ")" at line ends; trim it to keep snapshots stable. var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines) cleaned = cleaned.trimmingCharacters(in: CharacterSet(charactersIn: " )")) cleaned = cleaned.replacingOccurrences( of: #"(?i)\b([A-Za-z]{3}\s+\d{1,2})\s+t\s+(\d)"#, with: "$1 at $2", options: .regularExpression) let openCount = cleaned.count(where: { $0 == "(" }) let closeCount = cleaned.count(where: { $0 == ")" }) if openCount > closeCount { cleaned.append(")") } return cleaned } /// Parses explicit-year resets at their stated occurrence; recurring forms resolve forward in any explicit /// timezone. public static func parseResetDate(from text: String?, now: Date = .init()) -> Date? { self.parseResetDate(from: text, now: now, expectedWindow: nil) } /// Quota surfaces may accept a recently stale occurrence when the next occurrence cannot belong to that window. package static func parseResetDate( from text: String?, now: Date = .init(), expectedWindow: TimeInterval?) -> Date? { guard let normalized = self.normalizeResetInput(text) else { return nil } let (raw, timeZone) = normalized let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = timeZone ?? TimeZone.current var calendar = Calendar(identifier: .gregorian) calendar.timeZone = formatter.timeZone // Parse yearless Feb 29 independently of the current year, then resolve validated calendar occurrences. formatter.defaultDate = calendar.date(from: DateComponents(year: 2000, month: 1, day: 1)) if let date = self.parseDate(raw, formats: Self.resetDateTimeWithExplicitYear, formatter: formatter) { return self.resolveOccurrence( self.explicitOccurrences(for: date, calendar: calendar), now: now, expectedWindow: expectedWindow) } if let date = self.parseDate(raw, formats: Self.resetDateTimeWithMinutes, formatter: formatter) { let comps = calendar.dateComponents([.month, .day, .hour, .minute], from: date) return self.resolveOccurrence( self.yearlyOccurrences(for: comps, now: now, calendar: calendar), now: now, expectedWindow: expectedWindow) } if let date = self.parseDate(raw, formats: Self.resetDateTimeHourOnly, formatter: formatter) { let comps = calendar.dateComponents([.month, .day, .hour], from: date) return self.resolveOccurrence( self.yearlyOccurrences(for: comps, now: now, calendar: calendar), now: now, expectedWindow: expectedWindow) } if let time = self.parseDate(raw, formats: Self.resetTimeWithMinutes, formatter: formatter) { let comps = calendar.dateComponents([.hour, .minute], from: time) return self.resolveOccurrence( self.dailyOccurrences(for: comps, now: now, calendar: calendar), now: now, expectedWindow: expectedWindow) } guard let time = self.parseDate(raw, formats: Self.resetTimeHourOnly, formatter: formatter) else { return nil } let comps = calendar.dateComponents([.hour], from: time) return self.resolveOccurrence( self.dailyOccurrences(for: comps, now: now, calendar: calendar), now: now, expectedWindow: expectedWindow) } private static func explicitOccurrences(for date: Date, calendar: Calendar) -> [Date] { let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: date) guard let year = components.year, let month = components.month, let day = components.day, let hour = components.hour, let minute = components.minute else { return [] } return self.exactLocalOccurrences( DateComponents( timeZone: calendar.timeZone, year: year, month: month, day: day, hour: hour, minute: minute, second: 0), calendar: calendar) } private static func resolveOccurrence( _ candidates: [Date], now: Date, expectedWindow: TimeInterval?) -> Date? { let sortedCandidates = candidates.sorted() guard let future = sortedCandidates.first(where: { $0 >= now }) else { return sortedCandidates.last } guard let expectedWindow else { return future } let horizon = max(0, expectedWindow) let past = sortedCandidates.last(where: { $0 < now }) let pastIsPlausible = past.map { now.timeIntervalSince($0) <= horizon } ?? false let futureIsPlausible = future.timeIntervalSince(now) <= horizon return pastIsPlausible && !futureIsPlausible ? past : future } private static func yearlyOccurrences( for components: DateComponents, now: Date, calendar: Calendar) -> [Date] { guard let month = components.month, let day = components.day, let hour = components.hour else { return [] } let currentYear = calendar.component(.year, from: now) // Eight years covers the Gregorian century gap between leap days (for example, 2096 to 2104). return ((currentYear - 8)...(currentYear + 8)).flatMap { year in self.exactLocalOccurrences( DateComponents( timeZone: calendar.timeZone, year: year, month: month, day: day, hour: hour, minute: components.minute ?? 0, second: 0), calendar: calendar) } } private static func dailyOccurrences( for components: DateComponents, now: Date, calendar: Calendar) -> [Date] { guard let hour = components.hour else { return [] } let startOfToday = calendar.startOfDay(for: now) return (-1...1).flatMap { offset -> [Date] in guard let day = calendar.date(byAdding: .day, value: offset, to: startOfToday) else { return [] } let dayComponents = calendar.dateComponents([.year, .month, .day], from: day) guard let year = dayComponents.year, let month = dayComponents.month, let dayOfMonth = dayComponents.day else { return [] } return self.exactLocalOccurrences( DateComponents( timeZone: calendar.timeZone, year: year, month: month, day: dayOfMonth, hour: hour, minute: components.minute ?? 0, second: 0), calendar: calendar) } } /// Returns both instants when a local wall time repeats during a daylight-saving transition. private static func exactLocalOccurrences( _ target: DateComponents, calendar: Calendar) -> [Date] { guard let year = target.year, let month = target.month, let day = target.day, let hour = target.hour, let minute = target.minute else { return [] } guard let firstApproximation = calendar.date(from: target) else { return [] } let approximationComponents = calendar.dateComponents( [.year, .month, .day, .hour, .minute, .second], from: firstApproximation) guard approximationComponents.year == year, approximationComponents.month == month, approximationComponents.day == day, approximationComponents.hour == hour, approximationComponents.minute == minute, approximationComponents.second == 0 else { return [] } let startOfDay = calendar.startOfDay(for: firstApproximation) guard let searchStart = calendar.date(byAdding: .second, value: -1, to: startOfDay) else { return [] } var results: [Date] = [] for policy in [Calendar.RepeatedTimePolicy.first, .last] { guard let date = calendar.nextDate( after: searchStart, matching: target, matchingPolicy: .strict, repeatedTimePolicy: policy, direction: .forward) else { continue } let resolved = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date) guard resolved.year == year, resolved.month == month, resolved.day == day, resolved.hour == hour, resolved.minute == minute, resolved.second == 0, !results.contains(date) else { continue } results.append(date) } return results } private static let resetTimeWithMinutes = ["h:mma", "h:mm a", "HH:mm", "H:mm"] private static let resetTimeHourOnly = ["ha", "h a"] private static let resetDateTimeWithExplicitYear = [ "MMM d, yyyy, h:mma", "MMM d, yyyy, h:mm a", "MMM d, yyyy, HH:mm", "MMM d, yyyy h:mma", "MMM d, yyyy h:mm a", "MMM d, yyyy HH:mm", "MMM d yyyy h:mma", "MMM d yyyy h:mm a", "MMM d yyyy HH:mm", "MMM d, yyyy, ha", "MMM d, yyyy, h a", "MMM d, yyyy, HH", "MMM d, yyyy ha", "MMM d, yyyy h a", "MMM d, yyyy HH", "MMM d yyyy ha", "MMM d yyyy h a", "MMM d yyyy HH", ] private static let resetDateTimeWithMinutes = [ "MMM d, h:mma", "MMM d, h:mm a", "MMM d h:mma", "MMM d h:mm a", "MMM d, HH:mm", "MMM d HH:mm", ] private static let resetDateTimeHourOnly = [ "MMM d, ha", "MMM d, h a", "MMM d ha", "MMM d h a", ] private static func normalizeResetInput(_ text: String?) -> (String, TimeZone?)? { guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } raw = raw.replacingOccurrences(of: #"(?i)^resets?:?\s*"#, with: "", options: .regularExpression) raw = raw.replacingOccurrences(of: " at ", with: " ", options: .caseInsensitive) raw = raw.replacingOccurrences(of: #"(?i)\b([A-Za-z]{3})(\d)"#, with: "$1 $2", options: .regularExpression) raw = raw.replacingOccurrences(of: #",(\d)"#, with: ", $1", options: .regularExpression) raw = raw.replacingOccurrences(of: #"(?i)(\d)at(?=\d)"#, with: "$1 ", options: .regularExpression) raw = raw.replacingOccurrences( of: #"(?<=\d)\.(\d{2})\b"#, with: ":$1", options: .regularExpression) let timeZone = self.extractTimeZone(from: &raw) raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) .trimmingCharacters(in: .whitespacesAndNewlines) return raw.isEmpty ? nil : (raw, timeZone) } private static func extractTimeZone(from text: inout String) -> TimeZone? { guard let tzRange = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { return nil } let tzID = String(text[tzRange]).trimmingCharacters(in: CharacterSet(charactersIn: "() ")) text.removeSubrange(tzRange) text = text.trimmingCharacters(in: .whitespacesAndNewlines) return TimeZone(identifier: tzID) } private static func parseDate(_ text: String, formats: [String], formatter: DateFormatter) -> Date? { for pattern in formats { formatter.dateFormat = pattern if let date = formatter.date(from: text) { return date } } return nil } /// Extract login/plan string from CLI output. private static func extractLoginMethod(text: String) -> String? { guard !text.isEmpty else { return nil } if let explicit = self.extractFirst(pattern: #"(?i)login\s+method:\s*(.+)"#, text: text) { return ClaudePlan.cliCompatibilityLoginMethod(self.cleanPlan(explicit)) } // Capture any "Claude <...>" phrase (e.g., Max/Pro/Ultra/Team) to avoid future plan-name churn. // Strip any leading ANSI that may have survived (rare) before matching. // Use horizontal whitespace ([ \t]) rather than \s so the match stays on a single rendered line: // \s spans newlines, which let "…use Claude" bridge into the /usage panel's "d → today" hint and // produced a bogus "Dtoday" plan label after ANSI stripping glued the lines together. let planPattern = #"(?i)(claude[ \t]+[a-z0-9][a-z0-9 \t._-]{0,24})"# var candidates: [String] = [] if let regex = try? NSRegularExpression(pattern: planPattern, options: []) { let nsrange = NSRange(text.startIndex..= 2, let r = Range(match.range(at: 1), in: text) else { return } let raw = String(text[r]) let val = ClaudePlan.cliCompatibilityLoginMethod(Self.cleanPlan(raw)) ?? Self.cleanPlan(raw) candidates.append(val) } } if let plan = candidates.first(where: { cand in let lower = cand.lowercased() return !lower.contains("code v") && !lower.contains("code version") && !lower.contains("code") }) { return plan } return nil } /// Strips ANSI and stray bracketed codes like "[22m" that can survive CLI output. private static func cleanPlan(_ text: String) -> String { UsageFormatter.cleanPlanName(text) } private static func dumpIfNeeded(enabled: Bool, reason: String, usage: String, status: String?) { guard enabled else { return } let stamp = ISO8601DateFormatter().string(from: Date()) var parts = [ "=== Claude parse dump @ \(stamp) ===", "Reason: \(reason)", "", "--- usage (clean) ---", usage, "", ] if let status { parts.append(contentsOf: [ "--- status (raw/optional) ---", status, "", ]) } let body = parts.joined(separator: "\n") Task { @MainActor in self.recordDump(body) } } // MARK: - Dump storage (in-memory ring buffer) @MainActor private static var recentDumps: [String] = [] @MainActor private static func recordDump(_ text: String) { if self.recentDumps.count >= 5 { self.recentDumps.removeFirst() } self.recentDumps.append(text) } public static func latestDumps() async -> String { await MainActor.run { let result = Self.recentDumps.joined(separator: "\n\n---\n\n") return result.isEmpty ? "No Claude parse dumps captured yet." : result } } #if DEBUG public static func _replaceDumpsForTesting(_ dumps: [String]) async { await MainActor.run { self.recentDumps = dumps } } #endif private static func extractUsageErrorJSON(text: String) -> String? { let pattern = #"Failed\s*to\s*load\s*usage\s*data:\s*(\{.*\})"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.dotMatchesLineSeparators]) else { return nil } let range = NSRange(text.startIndex..= 2, let jsonRange = Range(match.range(at: 1), in: text) else { return nil } let jsonString = String(text[jsonRange]) let compactJSON = jsonString.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: "") let data = (compactJSON.isEmpty ? jsonString : compactJSON).data(using: .utf8) guard let data, let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let error = payload["error"] as? [String: Any] else { return nil } let message = (error["message"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let details = error["details"] as? [String: Any] let code = (details?["error_code"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) let type = (error["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() if type == "rate_limit_error" { return "Claude CLI usage endpoint is rate limited right now. Please try again later." } var parts: [String] = [] if let message, !message.isEmpty { parts.append(message) } if let code, !code.isEmpty { parts.append("(\(code))") } guard !parts.isEmpty else { return nil } let hint = parts.joined(separator: " ") if let code, code.lowercased().contains("token") { return "\(hint). Run `claude login` to refresh." } return "Claude CLI error: \(hint)" } // MARK: - Process helpers private static func resolvedBinaryPath( binaryName: String, environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { if binaryName.contains("/") { return binaryName } return ClaudeCLIResolver.resolvedBinaryPath(environment: environment) } private static func isBinaryAvailable(_ binaryPathOrName: String?) -> Bool { guard let binaryPathOrName else { return false } return FileManager.default.isExecutableFile(atPath: binaryPathOrName) || TTYCommandRunner.which(binaryPathOrName) != nil } static func probeWorkingDirectoryURL() -> URL { let fm = FileManager.default let base = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? fm.temporaryDirectory let dir = base .appendingPathComponent("CodexBar", isDirectory: true) .appendingPathComponent("ClaudeProbe", isDirectory: true) do { try fm.createDirectory(at: dir, withIntermediateDirectories: true) return dir } catch { return fm.temporaryDirectory } } static func preparedProbeWorkingDirectoryURL() -> URL { let directory = self.probeWorkingDirectoryURL() do { try self.prepareProbeWorkingDirectory(at: directory) } catch { Self.log.warning( "Claude probe local settings unavailable", metadata: ["error": error.localizedDescription]) } return directory } static func prepareProbeWorkingDirectory(at directory: URL, fileManager fm: FileManager = .default) throws { try fm.createDirectory(at: directory, withIntermediateDirectories: true) let claudeDirectory = directory.appendingPathComponent(".claude", isDirectory: true) try fm.createDirectory(at: claudeDirectory, withIntermediateDirectories: true) let settingsURL = claudeDirectory.appendingPathComponent("settings.local.json") var settings = (try? self.readSettingsObject(from: settingsURL, fileManager: fm)) ?? [:] settings["disableDeepLinkRegistration"] = "disable" let data = try JSONSerialization.data( withJSONObject: settings, options: [.prettyPrinted, .sortedKeys]) try data.write(to: settingsURL, options: .atomic) } private static func readSettingsObject(from url: URL, fileManager fm: FileManager) throws -> [String: Any] { guard fm.fileExists(atPath: url.path) else { return [:] } let data = try Data(contentsOf: url) guard !data.isEmpty else { return [:] } let object = try JSONSerialization.jsonObject(with: data) return object as? [String: Any] ?? [:] } /// Run claude CLI inside a PTY so we can respond to interactive permission prompts. private static func capture(subcommand: String, binary: String, timeout: TimeInterval) async throws -> String { let stopOnSubstrings = subcommand == "/usage" ? [ "Failed to load usage data", "failed to load usage data", "Failedto loadusagedata", "failedtoloadusagedata", ] : [] let idleTimeout: TimeInterval? = subcommand == "/usage" ? nil : 3.0 let sendEnterEvery: TimeInterval? = subcommand == "/usage" ? 0.8 : nil let stopWhenNormalized: (@Sendable (String) -> Bool)? = subcommand == "/usage" ? { @Sendable normalizedScan in Self.usageCaptureHasSessionValue(normalizedScan) || Self.usageCaptureHasSubscriptionNotice(normalizedScan) } : nil do { return try await ClaudeCLISession.current.capture( subcommand: subcommand, binary: binary, timeout: timeout, idleTimeout: idleTimeout, stopOnSubstrings: stopOnSubstrings, stopWhenNormalized: stopWhenNormalized, settleAfterStop: subcommand == "/usage" ? 2.0 : 0.25, sendEnterEvery: sendEnterEvery) } catch ClaudeCLISession.SessionError.processExited { await ClaudeCLISession.current.reset() throw ClaudeStatusProbeError.timedOut } catch ClaudeCLISession.SessionError.timedOut { await ClaudeCLISession.current.reset() throw ClaudeStatusProbeError.timedOut } catch ClaudeCLISession.SessionError.launchFailed(_) { throw ClaudeStatusProbeError.claudeNotInstalled } catch { await ClaudeCLISession.current.reset() throw error } } private static func usageCaptureHasSessionValue(_ normalizedText: String) -> Bool { guard let labelRange = normalizedText.range(of: "currentsession") else { return false } let tail = normalizedText[labelRange.upperBound...] return tail.range(of: #"[0-9]{1,3}(?:\.[0-9]+)?%"#, options: .regularExpression) != nil } private static func usageCaptureHasSubscriptionNotice(_ normalizedText: String) -> Bool { normalizedText.contains("currentlyusingyoursubscription") && normalizedText.contains("claudecodeusage") } }