295 lines
12 KiB
Swift
295 lines
12 KiB
Swift
import Foundation
|
|
|
|
public struct CodexStatusSnapshot: Sendable {
|
|
public let credits: Double?
|
|
public let fiveHourPercentLeft: Int?
|
|
public let weeklyPercentLeft: Int?
|
|
public let fiveHourResetDescription: String?
|
|
public let weeklyResetDescription: String?
|
|
public let fiveHourResetsAt: Date?
|
|
public let weeklyResetsAt: Date?
|
|
public let codexCreditLimit: CodexCreditLimitSnapshot?
|
|
public let rawText: String
|
|
|
|
public init(
|
|
credits: Double?,
|
|
fiveHourPercentLeft: Int?,
|
|
weeklyPercentLeft: Int?,
|
|
fiveHourResetDescription: String?,
|
|
weeklyResetDescription: String?,
|
|
fiveHourResetsAt: Date?,
|
|
weeklyResetsAt: Date?,
|
|
codexCreditLimit: CodexCreditLimitSnapshot? = nil,
|
|
rawText: String)
|
|
{
|
|
self.credits = credits
|
|
self.fiveHourPercentLeft = fiveHourPercentLeft
|
|
self.weeklyPercentLeft = weeklyPercentLeft
|
|
self.fiveHourResetDescription = fiveHourResetDescription
|
|
self.weeklyResetDescription = weeklyResetDescription
|
|
self.fiveHourResetsAt = fiveHourResetsAt
|
|
self.weeklyResetsAt = weeklyResetsAt
|
|
self.codexCreditLimit = codexCreditLimit
|
|
self.rawText = rawText
|
|
}
|
|
}
|
|
|
|
public enum CodexStatusProbeError: LocalizedError, Sendable {
|
|
case codexNotInstalled
|
|
case launchBlocked(String)
|
|
case parseFailed(String)
|
|
case timedOut
|
|
case updateRequired(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .codexNotInstalled:
|
|
"Codex CLI missing. Install via `npm i -g @openai/codex` (or bun install) and restart."
|
|
case let .launchBlocked(message):
|
|
message
|
|
case .parseFailed:
|
|
"Could not parse Codex status; will retry shortly."
|
|
case .timedOut:
|
|
"Codex status probe timed out."
|
|
case let .updateRequired(msg):
|
|
"Codex CLI update needed: \(msg)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs `codex` inside a PTY, sends `/status`, captures text, and parses credits/limits.
|
|
public struct CodexStatusProbe {
|
|
private static let defaultTimeoutSeconds: TimeInterval = 8.0
|
|
private static let parseRetryTimeoutSeconds: TimeInterval = 4.0
|
|
|
|
public var codexBinary: String = "codex"
|
|
public var timeout: TimeInterval = Self.defaultTimeoutSeconds
|
|
public var keepCLISessionsAlive: Bool = false
|
|
public var environment: [String: String] = ProcessInfo.processInfo.environment
|
|
|
|
public init() {}
|
|
|
|
public init(
|
|
codexBinary: String = "codex",
|
|
timeout: TimeInterval = 8.0,
|
|
keepCLISessionsAlive: Bool = false,
|
|
environment: [String: String] = ProcessInfo.processInfo.environment)
|
|
{
|
|
self.codexBinary = codexBinary
|
|
self.timeout = timeout
|
|
self.keepCLISessionsAlive = keepCLISessionsAlive
|
|
self.environment = environment
|
|
}
|
|
|
|
public func fetch() async throws -> CodexStatusSnapshot {
|
|
let env = self.environment
|
|
let resolved = BinaryLocator.resolveCodexBinary(env: env, loginPATH: LoginShellPathCache.shared.current)
|
|
?? self.codexBinary
|
|
guard FileManager.default.isExecutableFile(atPath: resolved) || TTYCommandRunner.which(resolved) != nil else {
|
|
throw CodexStatusProbeError.codexNotInstalled
|
|
}
|
|
if let message = CodexCLILaunchGate.shared.backgroundSkipMessage(binary: resolved) {
|
|
throw CodexStatusProbeError.launchBlocked(message)
|
|
}
|
|
do {
|
|
return try await self.runAndParse(binary: resolved, rows: 60, cols: 200, timeout: self.timeout)
|
|
} catch let error as CodexStatusProbeError {
|
|
// Retry only parser-level flakes with a short second attempt.
|
|
switch error {
|
|
case .parseFailed:
|
|
return try await self.runAndParse(
|
|
binary: resolved,
|
|
rows: 70,
|
|
cols: 220,
|
|
timeout: Self.parseRetryTimeoutSeconds)
|
|
default:
|
|
throw error
|
|
}
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
public static func parse(text: String, now: Date = .init()) throws -> CodexStatusSnapshot {
|
|
let clean = TextParsing.stripANSICodes(text)
|
|
guard !clean.isEmpty else { throw CodexStatusProbeError.timedOut }
|
|
if clean.localizedCaseInsensitiveContains("data not available yet") {
|
|
throw CodexStatusProbeError.parseFailed("data not available yet")
|
|
}
|
|
if self.containsUpdatePrompt(clean) {
|
|
throw CodexStatusProbeError.updateRequired(
|
|
"Run `bun install -g @openai/codex` to continue (update prompt blocking /status).")
|
|
}
|
|
let credits = TextParsing.firstNumber(pattern: #"Credits:\s*([0-9][0-9.,]*)"#, text: clean)
|
|
// Pull reset info from the same lines that contain the percentages.
|
|
let fiveLine = TextParsing.firstLine(matching: #"5h limit[^\n]*"#, text: clean)
|
|
let weekLine = TextParsing.firstLine(matching: #"Weekly limit[^\n]*"#, text: clean)
|
|
let fivePct = fiveLine.flatMap(TextParsing.percentLeft(fromLine:))
|
|
let weekPct = weekLine.flatMap(TextParsing.percentLeft(fromLine:))
|
|
let fiveReset = fiveLine.flatMap(TextParsing.resetString(fromLine:))
|
|
let weekReset = weekLine.flatMap(TextParsing.resetString(fromLine:))
|
|
let monthlyLimit = self.parseMonthlyCreditLimit(text: clean, now: now)
|
|
if credits == nil, fivePct == nil, weekPct == nil, monthlyLimit == nil {
|
|
throw CodexStatusProbeError.parseFailed(clean.prefix(400).description)
|
|
}
|
|
return CodexStatusSnapshot(
|
|
credits: credits,
|
|
fiveHourPercentLeft: fivePct,
|
|
weeklyPercentLeft: weekPct,
|
|
fiveHourResetDescription: fiveReset,
|
|
weeklyResetDescription: weekReset,
|
|
fiveHourResetsAt: self.parseResetDate(from: fiveReset, now: now),
|
|
weeklyResetsAt: self.parseResetDate(from: weekReset, now: now),
|
|
codexCreditLimit: monthlyLimit,
|
|
rawText: clean)
|
|
}
|
|
|
|
private static func parseMonthlyCreditLimit(text: String, now: Date) -> CodexCreditLimitSnapshot? {
|
|
guard let monthlyLine = TextParsing.firstLine(matching: #"Monthly credit limit[^\n]*"#, text: text) else {
|
|
return nil
|
|
}
|
|
guard let limit = TextParsing.firstNumber(
|
|
pattern: #"of\s+([0-9][0-9., ]*)\s+credits used"#,
|
|
text: text),
|
|
limit > 0
|
|
else {
|
|
return nil
|
|
}
|
|
let used = TextParsing.firstNumber(
|
|
pattern: #"([0-9][0-9., ]*)\s+of\s+[0-9][0-9., ]*\s+credits used"#,
|
|
text: text) ?? 0
|
|
let remainingPercent = TextParsing.percentLeft(fromLine: monthlyLine)
|
|
.map(Double.init)
|
|
?? max(0, min(100, 100 - (used / limit * 100)))
|
|
let resetText = TextParsing.resetString(fromLine: monthlyLine)
|
|
return CodexCreditLimitSnapshot(
|
|
used: used,
|
|
limit: limit,
|
|
remainingPercent: remainingPercent,
|
|
resetsAt: self.parseResetDate(from: resetText, now: now),
|
|
updatedAt: now)
|
|
}
|
|
|
|
private static func parseResetDate(from text: String?, now: Date) -> Date? {
|
|
guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil }
|
|
raw = raw.trimmingCharacters(in: CharacterSet(charactersIn: "()"))
|
|
raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
let calendar = Calendar(identifier: .gregorian)
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = TimeZone.current
|
|
formatter.defaultDate = now
|
|
|
|
if let match = raw.firstMatch(of: /^([0-9]{1,2}:[0-9]{2}) on ([0-9]{1,2} [A-Za-z]{3})$/) {
|
|
raw = "\(match.output.2) \(match.output.1)"
|
|
formatter.dateFormat = "d MMM HH:mm"
|
|
if let date = formatter.date(from: raw) {
|
|
return self.bumpYearIfNeeded(date, now: now, calendar: calendar)
|
|
}
|
|
}
|
|
|
|
if let match = raw.firstMatch(of: /^([0-9]{1,2}:[0-9]{2}) on ([A-Za-z]{3} [0-9]{1,2})$/) {
|
|
raw = "\(match.output.2) \(match.output.1)"
|
|
formatter.dateFormat = "MMM d HH:mm"
|
|
if let date = formatter.date(from: raw) {
|
|
return self.bumpYearIfNeeded(date, now: now, calendar: calendar)
|
|
}
|
|
}
|
|
|
|
for format in ["HH:mm", "H:mm"] {
|
|
formatter.dateFormat = format
|
|
if let time = formatter.date(from: raw) {
|
|
let components = calendar.dateComponents([.hour, .minute], from: time)
|
|
guard let anchored = calendar.date(
|
|
bySettingHour: components.hour ?? 0,
|
|
minute: components.minute ?? 0,
|
|
second: 0,
|
|
of: now)
|
|
else {
|
|
return nil
|
|
}
|
|
if anchored >= now {
|
|
return anchored
|
|
}
|
|
return calendar.date(byAdding: .day, value: 1, to: anchored)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func bumpYearIfNeeded(_ date: Date, now: Date, calendar: Calendar) -> Date? {
|
|
if date >= now {
|
|
return date
|
|
}
|
|
return calendar.date(byAdding: .year, value: 1, to: date)
|
|
}
|
|
|
|
private func runAndParse(
|
|
binary: String,
|
|
rows: UInt16,
|
|
cols: UInt16,
|
|
timeout: TimeInterval) async throws -> CodexStatusSnapshot
|
|
{
|
|
let stateHome = try CodexStatusProbeIsolation.supportDirectory(environment: self.environment)
|
|
let extraArgs = CodexStatusProbeIsolation.codexArguments(stateHome: stateHome)
|
|
let workingDirectory = CodexStatusProbeIsolation.workingDirectory(environment: self.environment)
|
|
let text: String
|
|
if self.keepCLISessionsAlive {
|
|
do {
|
|
text = try await CodexCLISession.shared.captureStatus(
|
|
binary: binary,
|
|
options: .init(
|
|
timeout: timeout,
|
|
rows: rows,
|
|
cols: cols,
|
|
environment: self.environment,
|
|
extraArgs: extraArgs,
|
|
workingDirectory: workingDirectory))
|
|
} catch CodexCLISession.SessionError.processExited {
|
|
throw CodexStatusProbeError.timedOut
|
|
} catch CodexCLISession.SessionError.timedOut {
|
|
throw CodexStatusProbeError.timedOut
|
|
} catch let CodexCLISession.SessionError.launchFailed(message) {
|
|
if let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: binary, message: message) {
|
|
throw CodexStatusProbeError.launchBlocked(throttled)
|
|
}
|
|
throw CodexStatusProbeError.codexNotInstalled
|
|
}
|
|
} else {
|
|
let runner = TTYCommandRunner()
|
|
let script = "/status"
|
|
let result: TTYCommandRunner.Result
|
|
do {
|
|
result = try runner.run(
|
|
binary: binary,
|
|
send: script,
|
|
options: .init(
|
|
rows: rows,
|
|
cols: cols,
|
|
timeout: timeout,
|
|
workingDirectory: workingDirectory,
|
|
extraArgs: extraArgs,
|
|
baseEnvironment: self.environment,
|
|
forceCodexStatusMode: true))
|
|
} catch let TTYCommandRunner.Error.launchFailed(message) {
|
|
if let throttled = CodexCLILaunchGate.shared.recordLaunchFailure(binary: binary, message: message) {
|
|
throw CodexStatusProbeError.launchBlocked(throttled)
|
|
}
|
|
throw CodexStatusProbeError.codexNotInstalled
|
|
}
|
|
text = result.text
|
|
}
|
|
return try Self.parse(text: text)
|
|
}
|
|
|
|
private static func containsUpdatePrompt(_ text: String) -> Bool {
|
|
let lower = text.lowercased()
|
|
return lower.contains("update available") && lower.contains("codex")
|
|
}
|
|
}
|