项目文件夹

文件
2026-07-13 12:22:33 +08:00

1354 行
58 KiB
Swift

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<T>(
_ override: FetchOverride?,
operation: () async throws -> T) async rethrows -> T
{
try await self.$fetchOverride.withValue(override) {
try await operation()
}
}
public static func withFetchOverrideForTesting<T>(
_ 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..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, options: [], range: range),
match.numberOfRanges >= 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
match.numberOfRanges >= 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..<line.endIndex, in: line)
guard let match = regex.firstMatch(in: line, options: [], range: range),
match.numberOfRanges >= 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..<text.endIndex, in: text)
var results: [String] = []
regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in
guard let match,
let r = Range(match.range(at: 0), in: text) else { return }
let raw = String(text[r]).trimmingCharacters(in: .whitespacesAndNewlines)
results.append(self.cleanResetLine(raw))
}
return results
}
private static func cleanResetLine(_ raw: String) -> 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..<text.endIndex, in: text)
regex.enumerateMatches(in: text, options: [], range: nsrange) { match, _, _ in
guard let match,
match.numberOfRanges >= 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..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: range),
match.numberOfRanges >= 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")
}
}