Files
2026-07-13 12:22:33 +08:00

1354 lines
58 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 34 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 its 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")
}
}