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

1364 lines
50 KiB
Swift

import Foundation
#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#endif
public struct KiroUsageSnapshot: Sendable {
public let planName: String
public let displayPlanName: String
public let accountEmail: String?
public let authMethod: String?
public let creditsUsed: Double
public let creditsTotal: Double
public let creditsPercent: Double
public let bonusCreditsUsed: Double?
public let bonusCreditsTotal: Double?
public let bonusExpiryDays: Int?
public let overagesStatus: String?
public let overageCreditsUsed: Double?
public let estimatedOverageCostUSD: Double?
public let manageURL: String?
public let contextUsage: KiroContextUsageSnapshot?
public let resetsAt: Date?
public let updatedAt: Date
public init(
planName: String,
displayPlanName: String? = nil,
accountEmail: String? = nil,
authMethod: String? = nil,
creditsUsed: Double,
creditsTotal: Double,
creditsPercent: Double,
bonusCreditsUsed: Double?,
bonusCreditsTotal: Double?,
bonusExpiryDays: Int?,
overagesStatus: String? = nil,
overageCreditsUsed: Double? = nil,
estimatedOverageCostUSD: Double? = nil,
manageURL: String? = nil,
contextUsage: KiroContextUsageSnapshot? = nil,
resetsAt: Date?,
updatedAt: Date)
{
self.planName = planName
self.displayPlanName = displayPlanName ?? KiroStatusProbe.displayPlanName(planName)
self.accountEmail = accountEmail
self.authMethod = authMethod
self.creditsUsed = creditsUsed
self.creditsTotal = creditsTotal
self.creditsPercent = creditsPercent
self.bonusCreditsUsed = bonusCreditsUsed
self.bonusCreditsTotal = bonusCreditsTotal
self.bonusExpiryDays = bonusExpiryDays
self.overagesStatus = overagesStatus
self.overageCreditsUsed = overageCreditsUsed
self.estimatedOverageCostUSD = estimatedOverageCostUSD
self.manageURL = manageURL
self.contextUsage = contextUsage
self.resetsAt = resetsAt
self.updatedAt = updatedAt
}
public func toUsageSnapshot() -> UsageSnapshot {
let primary = RateWindow(
usedPercent: self.creditsPercent,
windowMinutes: nil,
resetsAt: self.resetsAt,
resetDescription: nil)
var secondary: RateWindow?
if let bonusUsed = self.bonusCreditsUsed,
let bonusTotal = self.bonusCreditsTotal,
bonusTotal > 0
{
let bonusPercent = (bonusUsed / bonusTotal) * 100.0
var expiryDate: Date?
if let days = self.bonusExpiryDays {
expiryDate = Calendar.current.date(byAdding: .day, value: days, to: Date())
}
secondary = RateWindow(
usedPercent: bonusPercent,
windowMinutes: nil,
resetsAt: expiryDate,
resetDescription: self.bonusExpiryDays.map { "expires in \($0)d" })
}
let identity = ProviderIdentitySnapshot(
providerID: .kiro,
accountEmail: self.accountEmail,
accountOrganization: nil,
loginMethod: self.authMethod)
let kiroUsage = KiroUsageDetails(
planName: self.planName,
displayPlanName: self.displayPlanName,
creditsUsed: self.creditsUsed,
creditsTotal: self.creditsTotal,
creditsRemaining: self.creditsRemaining,
bonusCreditsUsed: self.bonusCreditsUsed,
bonusCreditsTotal: self.bonusCreditsTotal,
bonusCreditsRemaining: self.bonusCreditsRemaining,
bonusExpiryDays: self.bonusExpiryDays,
overagesStatus: self.overagesStatus,
overageCreditsUsed: self.overageCreditsUsed,
estimatedOverageCostUSD: self.estimatedOverageCostUSD,
manageURL: self.manageURL,
contextUsage: self.contextUsage)
return UsageSnapshot(
primary: primary,
secondary: secondary,
tertiary: nil,
kiroUsage: kiroUsage,
providerCost: nil,
zaiUsage: nil,
updatedAt: self.updatedAt,
identity: identity)
}
public var creditsRemaining: Double {
max(0, self.creditsTotal - self.creditsUsed)
}
public var bonusCreditsRemaining: Double? {
guard let bonusCreditsUsed, let bonusCreditsTotal else { return nil }
return max(0, bonusCreditsTotal - bonusCreditsUsed)
}
}
public struct KiroContextUsageSnapshot: Codable, Equatable, Sendable {
public let totalPercentUsed: Double
public let contextFilesPercent: Double?
public let toolsPercent: Double?
public let kiroResponsesPercent: Double?
public let promptsPercent: Double?
public init(
totalPercentUsed: Double,
contextFilesPercent: Double?,
toolsPercent: Double?,
kiroResponsesPercent: Double?,
promptsPercent: Double?)
{
self.totalPercentUsed = totalPercentUsed
self.contextFilesPercent = contextFilesPercent
self.toolsPercent = toolsPercent
self.kiroResponsesPercent = kiroResponsesPercent
self.promptsPercent = promptsPercent
}
}
public struct KiroUsageDetails: Codable, Equatable, Sendable {
public let planName: String
public let displayPlanName: String
public let creditsUsed: Double
public let creditsTotal: Double
public let creditsRemaining: Double
public let bonusCreditsUsed: Double?
public let bonusCreditsTotal: Double?
public let bonusCreditsRemaining: Double?
public let bonusExpiryDays: Int?
public let overagesStatus: String?
public let overageCreditsUsed: Double?
public let estimatedOverageCostUSD: Double?
public let manageURL: String?
public let contextUsage: KiroContextUsageSnapshot?
public init(
planName: String,
displayPlanName: String,
creditsUsed: Double,
creditsTotal: Double,
creditsRemaining: Double,
bonusCreditsUsed: Double?,
bonusCreditsTotal: Double?,
bonusCreditsRemaining: Double?,
bonusExpiryDays: Int?,
overagesStatus: String?,
overageCreditsUsed: Double?,
estimatedOverageCostUSD: Double?,
manageURL: String?,
contextUsage: KiroContextUsageSnapshot?)
{
self.planName = planName
self.displayPlanName = displayPlanName
self.creditsUsed = creditsUsed
self.creditsTotal = creditsTotal
self.creditsRemaining = creditsRemaining
self.bonusCreditsUsed = bonusCreditsUsed
self.bonusCreditsTotal = bonusCreditsTotal
self.bonusCreditsRemaining = bonusCreditsRemaining
self.bonusExpiryDays = bonusExpiryDays
self.overagesStatus = overagesStatus
self.overageCreditsUsed = overageCreditsUsed
self.estimatedOverageCostUSD = estimatedOverageCostUSD
self.manageURL = manageURL
self.contextUsage = contextUsage
}
}
public enum KiroStatusProbeError: LocalizedError, Sendable {
case cliNotFound
case notLoggedIn
case cliFailed(String)
case parseError(String)
case timeout
public var errorDescription: String? {
switch self {
case .cliNotFound:
"kiro-cli not found. Install it from https://kiro.dev"
case .notLoggedIn:
"Not logged in to Kiro. Run 'kiro-cli login' first."
case let .cliFailed(message):
message
case let .parseError(msg):
"Failed to parse Kiro usage: \(msg)"
case .timeout:
"Kiro CLI timed out."
}
}
}
public struct KiroStatusProbe: Sendable {
struct PipeProcessRegistry: Sendable {
let beginLaunch: @Sendable () -> Bool
let endLaunch: @Sendable () -> Void
let register: @Sendable (pid_t, String) -> Bool
let updateProcessGroup: @Sendable (pid_t, pid_t?) -> Void
let unregister: @Sendable (pid_t) -> Void
static let live = Self(
beginLaunch: { TTYCommandRunner.beginActiveProcessLaunchForAppShutdown() },
endLaunch: { TTYCommandRunner.endActiveProcessLaunchForAppShutdown() },
register: { pid, binary in
TTYCommandRunner.registerActiveProcessForAppShutdown(pid: pid, binary: binary)
},
updateProcessGroup: { pid, processGroup in
TTYCommandRunner.updateActiveProcessGroupForAppShutdown(pid: pid, processGroup: processGroup)
},
unregister: { pid in
TTYCommandRunner.unregisterActiveProcessForAppShutdown(pid: pid)
})
}
private let cliBinaryResolver: @Sendable () -> String?
private let accountProbeTimeout: TimeInterval
private let usageProbeTimeout: TimeInterval
private let contextProbeTimeout: TimeInterval
private let pipeTimeoutCap: TimeInterval
private let pipeProcessRegistry: PipeProcessRegistry
public init() {
self.cliBinaryResolver = { TTYCommandRunner.which("kiro-cli") }
self.accountProbeTimeout = 3.0
self.usageProbeTimeout = 20.0
self.contextProbeTimeout = 8.0
self.pipeTimeoutCap = 5.0
self.pipeProcessRegistry = .live
}
init(
cliBinaryResolver: @escaping @Sendable () -> String?,
accountProbeTimeout: TimeInterval = 3.0,
usageProbeTimeout: TimeInterval = 20.0,
contextProbeTimeout: TimeInterval = 8.0,
pipeTimeoutCap: TimeInterval = 5.0,
pipeProcessRegistry: PipeProcessRegistry = .live)
{
self.cliBinaryResolver = cliBinaryResolver
self.accountProbeTimeout = accountProbeTimeout
self.usageProbeTimeout = usageProbeTimeout
self.contextProbeTimeout = contextProbeTimeout
self.pipeTimeoutCap = pipeTimeoutCap
self.pipeProcessRegistry = pipeProcessRegistry
}
private static let logger = CodexBarLog.logger(LogCategories.kiro)
public static func detectVersion() -> String? {
guard let path = TTYCommandRunner.which("kiro-cli"),
let output = ProviderVersionDetector.run(
path: path,
args: ["--version"],
mergeStandardError: true)
else {
self.logger.debug("kiro-cli version detection failed")
return nil
}
// Output is like "kiro-cli 1.23.1"
if output.hasPrefix("kiro-cli ") {
return String(output.dropFirst("kiro-cli ".count))
}
return output
}
public func fetch() async throws -> KiroUsageSnapshot {
let accountTask = Task { await self.fetchAccountStatus() }
let output: String
do {
output = try await self.runUsageCommand()
} catch is CancellationError {
accountTask.cancel()
_ = await accountTask.value
throw CancellationError()
} catch {
if try await self.awaitAccountStatus(accountTask) == .notLoggedIn {
throw KiroStatusProbeError.notLoggedIn
}
throw error
}
var contextUsage: KiroContextUsageSnapshot?
do {
contextUsage = try await self.fetchContextUsage()
} catch is CancellationError {
accountTask.cancel()
_ = await accountTask.value
throw CancellationError()
} catch {
Self.logger.debug("Kiro context usage probe failed: \(error.localizedDescription)")
}
let accountStatus = try await self.awaitAccountStatus(accountTask)
let accountInfo = accountStatus.account
do {
return try self.parse(
output: output,
accountEmail: accountInfo?.email,
authMethod: accountInfo?.authMethod,
contextUsage: contextUsage)
} catch KiroStatusProbeError.parseError where accountStatus == .notLoggedIn {
throw KiroStatusProbeError.notLoggedIn
}
}
struct KiroAccountInfo: Equatable {
let authMethod: String?
let email: String?
}
struct KiroCLIResult: Sendable {
let stdout: String
let stderr: String
let terminationStatus: Int32
let stoppedAfterOutput: Bool
var output: String {
let stdout = self.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
let stderr = self.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
return [stdout, stderr]
.filter { !$0.isEmpty }
.joined(separator: "\n")
}
}
private enum KiroCommandKind: String, Sendable {
case whoAmI = "whoami"
case usage
case context
}
private enum KiroTransportOutcome: Sendable {
case result(KiroCLIResult)
case failure(KiroStatusProbeError)
case cancelled
}
private enum KiroTransportEvent: Sendable {
case pipe(KiroTransportOutcome)
case pty(KiroTransportOutcome)
case fallbackReady
}
private final class KiroPipeActivityState: @unchecked Sendable {
private let lock = NSLock()
private var lastActivity = ContinuousClock.now
private var receivedOutput = false
var lastActivityAt: ContinuousClock.Instant {
self.lock.withLock { self.lastActivity }
}
var hasReceivedOutput: Bool {
self.lock.withLock { self.receivedOutput }
}
func markActivity() {
self.lock.withLock {
self.lastActivity = .now
self.receivedOutput = true
}
}
}
private final class KiroTransportCancellationState: @unchecked Sendable {
private let lock = NSLock()
private var cancelled = false
var isCancelled: Bool {
self.lock.withLock { self.cancelled }
}
func cancel() {
self.lock.withLock { self.cancelled = true }
}
}
private enum KiroAccountProbeStatus: Equatable {
case account(KiroAccountInfo)
case notLoggedIn
case unavailable
var account: KiroAccountInfo? {
guard case let .account(info) = self else { return nil }
return info
}
}
private func fetchAccountStatus() async -> KiroAccountProbeStatus {
do {
return try await .account(self.ensureLoggedIn())
} catch KiroStatusProbeError.notLoggedIn {
return .notLoggedIn
} catch {
Self.logger.debug("Kiro account probe failed: \(error.localizedDescription)")
return .unavailable
}
}
private func awaitAccountStatus(
_ task: Task<KiroAccountProbeStatus, Never>) async throws -> KiroAccountProbeStatus
{
let status = await withTaskCancellationHandler {
await task.value
} onCancel: {
task.cancel()
}
try Task.checkCancellation()
return status
}
private func ensureLoggedIn() async throws -> KiroAccountInfo {
let result = try await self.runCommand(
arguments: ["whoami"],
timeout: self.accountProbeTimeout,
idleTimeout: 1.5,
kind: .whoAmI)
if result.stoppedAfterOutput {
if Self.isLoginRequired(result.output) {
throw KiroStatusProbeError.notLoggedIn
}
throw KiroStatusProbeError.timeout
}
return try self.validateWhoAmIOutput(
stdout: result.stdout,
stderr: result.stderr,
terminationStatus: result.terminationStatus)
}
func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo {
let combined = KiroCLIResult(
stdout: stdout,
stderr: stderr,
terminationStatus: terminationStatus,
stoppedAfterOutput: false).output
if Self.isLoginRequired(combined) {
throw KiroStatusProbeError.notLoggedIn
}
if terminationStatus != 0 {
let message = combined.isEmpty
? "Kiro CLI failed with status \(terminationStatus)."
: combined
throw KiroStatusProbeError.cliFailed(message)
}
if combined.isEmpty {
throw KiroStatusProbeError.cliFailed("Kiro CLI whoami returned no output.")
}
return self.parseWhoAmIOutput(combined)
}
private static func isLoginRequired(_ output: String) -> Bool {
let lowered = Self.stripANSI(output).lowercased()
return lowered.contains("not logged in")
|| lowered.contains("login required")
|| lowered.contains("failed to initialize auth portal")
|| lowered.contains("kiro-cli login")
|| lowered.contains("oauth error")
}
func parseWhoAmIOutput(_ output: String) -> KiroAccountInfo {
let stripped = Self.stripANSI(output)
var authMethod: String?
var email: String?
for rawLine in stripped.components(separatedBy: .newlines) {
let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
guard !line.isEmpty else { continue }
if line.localizedCaseInsensitiveContains("logged in with") {
authMethod = line.replacingOccurrences(
of: #"(?i)^\s*logged in with\s+"#,
with: "",
options: [.regularExpression])
.trimmingCharacters(in: .whitespacesAndNewlines)
} else if line.localizedCaseInsensitiveContains("email:") {
email = line.replacingOccurrences(
of: #"(?i)^\s*email:\s*"#,
with: "",
options: [.regularExpression])
.trimmingCharacters(in: .whitespacesAndNewlines)
} else if email == nil,
!line.contains(" "),
line.contains("@")
{
email = line
}
}
return KiroAccountInfo(
authMethod: authMethod?.nilIfEmpty,
email: email?.nilIfEmpty)
}
private func runUsageCommand() async throws -> String {
let result = try await self.runCommand(
arguments: ["chat", "--no-interactive", "/usage"],
timeout: self.usageProbeTimeout,
idleTimeout: 4.0,
kind: .usage)
let output = result.output
if Self.isLoginRequired(output) {
throw KiroStatusProbeError.notLoggedIn
}
try Self.validateCommandCompletion(
result,
command: "usage",
allowIdleOutput: (try? self.parse(output: output)) != nil)
return output
}
private func fetchContextUsage() async throws -> KiroContextUsageSnapshot? {
let result = try await self.runCommand(
arguments: ["chat", "--no-interactive", "/context"],
timeout: self.contextProbeTimeout,
idleTimeout: 3.0,
kind: .context)
let contextUsage = self.parseContextUsage(output: result.output)
try Self.validateCommandCompletion(
result,
command: "context",
allowIdleOutput: contextUsage != nil)
return contextUsage
}
private func runViaPipe(
arguments: [String],
timeout: TimeInterval,
idleTimeout: TimeInterval,
activityState state: KiroPipeActivityState) async throws -> KiroCLIResult
{
try Task.checkCancellation()
guard let binary = self.cliBinaryResolver() else {
throw KiroStatusProbeError.cliNotFound
}
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
var env = TTYCommandRunner.enrichedEnvironment()
env["TERM"] = "xterm-256color"
guard self.pipeProcessRegistry.beginLaunch() else {
throw KiroStatusProbeError.cliFailed("App shutdown in progress")
}
var launchReservationHeld = true
defer {
if launchReservationHeld {
self.pipeProcessRegistry.endLaunch()
}
}
let stdoutCapture = ProcessPipeCapture(pipe: stdoutPipe, onData: { state.markActivity() })
let stderrCapture = ProcessPipeCapture(pipe: stderrPipe, onData: { state.markActivity() })
stdoutCapture.start()
stderrCapture.start()
let process: SpawnedProcessGroup
do {
try Task.checkCancellation()
process = try SpawnedProcessGroup.launch(
binary: binary,
arguments: arguments,
environment: env,
stdoutPipe: stdoutPipe,
stderrPipe: stderrPipe)
} catch {
stdoutCapture.stop()
stderrCapture.stop()
throw error
}
guard self.pipeProcessRegistry.register(
process.pid,
URL(fileURLWithPath: binary).lastPathComponent)
else {
await Self.terminateCancelledPipeProcess(
process,
stdoutCapture: stdoutCapture,
stderrCapture: stderrCapture)
throw KiroStatusProbeError.cliFailed("App shutdown in progress")
}
self.pipeProcessRegistry.updateProcessGroup(process.pid, process.processGroup)
self.pipeProcessRegistry.endLaunch()
launchReservationHeld = false
defer { self.pipeProcessRegistry.unregister(process.pid) }
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(max(0, timeout)))
var didHitDeadline = false
var didTerminateForIdle = false
do {
while process.isRunning {
try Task.checkCancellation()
let now = clock.now
if now >= deadline {
didHitDeadline = true
break
}
if state.hasReceivedOutput,
state.lastActivityAt.duration(to: now) >= .seconds(max(0, idleTimeout))
{
didTerminateForIdle = true
break
}
try await Task.sleep(for: .milliseconds(100))
}
} catch {
await Self.terminateCancelledPipeProcess(
process,
stdoutCapture: stdoutCapture,
stderrCapture: stderrCapture)
throw error
}
if process.isRunning {
await process.terminate()
guard !process.isRunning else {
stdoutCapture.stop()
stderrCapture.stop()
throw KiroStatusProbeError.timeout
}
if !state.hasReceivedOutput {
stdoutCapture.stop()
stderrCapture.stop()
throw KiroStatusProbeError.timeout
}
}
await process.terminateResidualProcesses()
async let stdoutDataTask = stdoutCapture.finish(timeout: .seconds(1))
async let stderrDataTask = stderrCapture.finish(timeout: .seconds(1))
let (stdoutData, stderrData) = await (stdoutDataTask, stderrDataTask)
if !stdoutCapture.reachedEOF || !stderrCapture.reachedEOF {
await process.terminateResidualProcesses()
}
await process.finish()
guard let terminationStatus = process.terminationStatus else {
throw KiroStatusProbeError.timeout
}
return KiroCLIResult(
stdout: ProcessPipeCapture.decodeUTF8(stdoutData),
stderr: ProcessPipeCapture.decodeUTF8(stderrData),
terminationStatus: terminationStatus,
stoppedAfterOutput: didTerminateForIdle || didHitDeadline)
}
private static func terminateCancelledPipeProcess(
_ process: SpawnedProcessGroup,
stdoutCapture: ProcessPipeCapture,
stderrCapture: ProcessPipeCapture) async
{
let cleanupTask = Task.detached(priority: .userInitiated) {
await process.terminate()
stdoutCapture.stop()
stderrCapture.stop()
}
await cleanupTask.value
}
private func runViaPTY(
arguments: [String],
timeout: TimeInterval,
idleTimeout: TimeInterval,
cancellationState: KiroTransportCancellationState) throws -> KiroCLIResult
{
guard let binary = self.cliBinaryResolver() else {
throw KiroStatusProbeError.cliNotFound
}
do {
let result = try TTYCommandRunner().run(
binary: binary,
send: "",
options: TTYCommandRunner.Options(
rows: 50,
cols: 200,
timeout: timeout,
idleTimeout: idleTimeout,
extraArgs: arguments,
returnOnEmptyProcessExit: true,
cancellationCheck: {
cancellationState.isCancelled || Task<Never, Never>.isCancelled
}))
switch result.completion {
case let .processExited(status):
return KiroCLIResult(
stdout: result.text,
stderr: "",
terminationStatus: status,
stoppedAfterOutput: false)
case .idleTimeout:
return KiroCLIResult(
stdout: result.text,
stderr: "",
terminationStatus: 0,
stoppedAfterOutput: true)
case .outputCondition, .deadlineExceeded:
throw KiroStatusProbeError.timeout
}
} catch TTYCommandRunner.Error.binaryNotFound {
throw KiroStatusProbeError.cliNotFound
} catch TTYCommandRunner.Error.timedOut {
throw KiroStatusProbeError.timeout
} catch let TTYCommandRunner.Error.launchFailed(message) {
throw KiroStatusProbeError.cliFailed(message)
}
}
private func runViaPTYAsync(
arguments: [String],
timeout: TimeInterval,
idleTimeout: TimeInterval,
cancellationState: KiroTransportCancellationState) async throws -> KiroCLIResult
{
let task = Task.detached(priority: .userInitiated) {
try self.runViaPTY(
arguments: arguments,
timeout: timeout,
idleTimeout: idleTimeout,
cancellationState: cancellationState)
}
return try await withTaskCancellationHandler {
try await task.value
} onCancel: {
cancellationState.cancel()
}
}
private static func validateCommandCompletion(
_ result: KiroCLIResult,
command: String,
allowIdleOutput: Bool) throws
{
if result.stoppedAfterOutput {
guard allowIdleOutput else { throw KiroStatusProbeError.timeout }
return
}
guard result.terminationStatus == 0 else {
let message = Self.stripANSI(result.output).trimmingCharacters(in: .whitespacesAndNewlines)
throw KiroStatusProbeError.cliFailed(
message.isEmpty
? "Kiro CLI \(command) failed with status \(result.terminationStatus)."
: message)
}
}
func parse(
output: String,
accountEmail: String? = nil,
authMethod: String? = nil,
contextUsage: KiroContextUsageSnapshot? = nil) throws -> KiroUsageSnapshot
{
let stripped = Self.stripANSI(output)
let trimmed = stripped.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
throw KiroStatusProbeError.parseError("Empty output from kiro-cli.")
}
let lowered = stripped.lowercased()
if lowered.contains("could not retrieve usage information") {
throw KiroStatusProbeError.parseError("Kiro CLI could not retrieve usage information.")
}
// Check for not logged in
if lowered.contains("not logged in")
|| lowered.contains("login required")
|| lowered.contains("failed to initialize auth portal")
|| lowered.contains("kiro-cli login")
|| lowered.contains("oauth error")
{
throw KiroStatusProbeError.notLoggedIn
}
// Track which key patterns matched to detect format changes
var matchedPercent = false
var matchedCredits = false
var matchedNewFormat = false
let parsedPlan = Self.parsePlanName(from: stripped)
let planName = parsedPlan.name
matchedNewFormat = parsedPlan.matchedNewFormat
// Check if this is a managed plan with no usage data
let isManagedPlan = lowered.contains("managed by admin")
|| lowered.contains("managed by organization")
let resetsAt = Self.parseResetDate(in: stripped)
// Parse credits percentage from "████...█ X%"
var creditsPercent: Double = 0
if let percentMatch = stripped.range(of: #"█+\s*(\d+)%"#, options: .regularExpression) {
let percentStr = String(stripped[percentMatch])
if let numMatch = percentStr.range(of: #"\d+"#, options: .regularExpression) {
creditsPercent = Double(String(percentStr[numMatch])) ?? 0
matchedPercent = true
}
}
// Parse credits used/total from "(X.XX of Y covered in plan)"
var creditsUsed: Double = 0
var creditsTotal: Double = 50 // default free tier
let creditsPattern = #"\((\d+\.?\d*)\s+of\s+(\d+)\s+covered"#
if let creditsMatch = stripped.range(of: creditsPattern, options: .regularExpression) {
let creditsStr = String(stripped[creditsMatch])
let numbers = creditsStr.matches(of: /(\d+\.?\d*)/)
if numbers.count >= 2 {
creditsUsed = Double(String(numbers[0].output.1)) ?? 0
creditsTotal = Double(String(numbers[1].output.1)) ?? 50
matchedCredits = true
}
}
if !matchedPercent, matchedCredits, creditsTotal > 0 {
creditsPercent = (creditsUsed / creditsTotal) * 100.0
}
let bonusCredits = Self.parseBonusCredits(in: stripped)
let overagesStatus = Self.firstCapture(
in: stripped,
pattern: #"(?i)Overages:\s*([^\n]+)"#)
.map(Self.cleanInlineValue)
.flatMap(\.nilIfEmpty)
let overageCreditsUsed = Self.firstCapture(
in: stripped,
pattern: #"(?i)Credits used:\s*(\d+\.?\d*)"#)
.flatMap(Double.init)
let estimatedOverageCostUSD = Self.firstCapture(
in: stripped,
pattern: #"(?i)Est\.\s*cost:\s*\$?(\d+\.?\d*)\s*USD"#)
.flatMap(Double.init)
let manageURL = Self.firstCapture(
in: stripped,
pattern: #"https://app\.kiro\.dev/account/usage"#)
// Managed plans in new format may omit usage metrics. Only fall back to zeros when
// we did not parse any usage values, so we do not mask real metrics.
if matchedNewFormat, isManagedPlan, !matchedPercent, !matchedCredits {
// Managed plans don't expose credits; return snapshot with plan name only
return KiroUsageSnapshot(
planName: planName,
displayPlanName: Self.displayPlanName(planName),
accountEmail: accountEmail?.nilIfEmpty,
authMethod: authMethod?.nilIfEmpty,
creditsUsed: 0,
creditsTotal: 0,
creditsPercent: 0,
bonusCreditsUsed: bonusCredits.used,
bonusCreditsTotal: bonusCredits.total,
bonusExpiryDays: bonusCredits.expiryDays,
overagesStatus: overagesStatus,
overageCreditsUsed: overageCreditsUsed,
estimatedOverageCostUSD: estimatedOverageCostUSD,
manageURL: manageURL,
contextUsage: contextUsage,
resetsAt: nil,
updatedAt: Date())
}
// Require at least one key pattern to match to avoid silent failures.
// Managed plans without usage data return early above.
if !matchedPercent, !matchedCredits {
throw KiroStatusProbeError.parseError(
"No recognizable usage patterns found. Kiro CLI output format may have changed.")
}
return KiroUsageSnapshot(
planName: planName,
displayPlanName: Self.displayPlanName(planName),
accountEmail: accountEmail?.nilIfEmpty,
authMethod: authMethod?.nilIfEmpty,
creditsUsed: creditsUsed,
creditsTotal: creditsTotal,
creditsPercent: creditsPercent,
bonusCreditsUsed: bonusCredits.used,
bonusCreditsTotal: bonusCredits.total,
bonusExpiryDays: bonusCredits.expiryDays,
overagesStatus: overagesStatus,
overageCreditsUsed: overageCreditsUsed,
estimatedOverageCostUSD: estimatedOverageCostUSD,
manageURL: manageURL,
contextUsage: contextUsage,
resetsAt: resetsAt,
updatedAt: Date())
}
func parseContextUsage(output: String) -> KiroContextUsageSnapshot? {
let stripped = Self.stripANSI(output)
guard let total = Self.firstCapture(
in: stripped,
pattern: #"(?i)Context window:\s*(\d+\.?\d*)%\s+used"#)
.flatMap(Double.init)
else {
return nil
}
return KiroContextUsageSnapshot(
totalPercentUsed: total,
contextFilesPercent: Self.percent(after: "Context files", in: stripped),
toolsPercent: Self.percent(after: "Tools", in: stripped),
kiroResponsesPercent: Self.percent(after: "Kiro responses", in: stripped),
promptsPercent: Self.percent(after: "Your prompts", in: stripped))
}
private static func stripANSI(_ text: String) -> String {
// Remove ANSI escape sequences
let pattern = #"\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07"#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return text
}
let range = NSRange(text.startIndex..., in: text)
return regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "")
}
private static func parsePlanName(from text: String) -> (name: String, matchedNewFormat: Bool) {
var planName = "Kiro"
var matchedNewFormat = false
// Parse plan name from "| KIRO FREE" or similar (legacy format)
// Horizontal whitespace only ([ \t]) so the match cannot bridge a newline into the next line.
if let planMatch = text.range(of: #"\|[ \t]*(KIRO[ \t]+\w+)"#, options: .regularExpression) {
let raw = String(text[planMatch]).replacingOccurrences(of: "|", with: "")
planName = raw.trimmingCharacters(in: .whitespaces)
}
// Parse plan name from "Estimated Usage | resets on 2026-06-01 | KIRO FREE" (kiro-cli 2.x)
if let estimatedMatch = text.range(
of: #"Estimated Usage[ \t]*\|[^\n|]*\|[ \t]*([A-Z][A-Z0-9 ]+)"#,
options: .regularExpression)
{
let line = String(text[estimatedMatch])
if let plan = line.split(separator: "|").last?.trimmingCharacters(in: .whitespacesAndNewlines),
!plan.isEmpty
{
planName = plan
}
}
// Parse plan name from "Plan: Q Developer Pro" (new format, kiro-cli 1.24+)
if let newPlanMatch = text.range(of: #"Plan:[ \t]*(.+)"#, options: .regularExpression) {
let line = String(text[newPlanMatch])
let planLine = line.replacingOccurrences(of: "Plan:", with: "").trimmingCharacters(in: .whitespaces)
if let firstLine = planLine.split(separator: "\n").first {
planName = String(firstLine).trimmingCharacters(in: .whitespaces)
matchedNewFormat = true
}
}
return (planName, matchedNewFormat)
}
private static func parseResetDate(in text: String) -> Date? {
guard let resetMatch = text.range(
of: #"resets on (\d{4}-\d{2}-\d{2}|\d{2}/\d{2})"#,
options: .regularExpression)
else { return nil }
let resetStr = String(text[resetMatch])
guard let dateRange = resetStr.range(
of: #"\d{4}-\d{2}-\d{2}|\d{2}/\d{2}"#,
options: .regularExpression)
else { return nil }
return Self.parseResetDate(String(resetStr[dateRange]))
}
private static func parseBonusCredits(in text: String) -> (used: Double?, total: Double?, expiryDays: Int?) {
var used: Double?
var total: Double?
var expiryDays: Int?
if let bonusMatch = text.range(of: #"Bonus credits:\s*(\d+\.?\d*)/(\d+)"#, options: .regularExpression) {
let bonusStr = String(text[bonusMatch])
let numbers = bonusStr.matches(of: /(\d+\.?\d*)/)
if numbers.count >= 2 {
used = Double(String(numbers[0].output.1))
total = Double(String(numbers[1].output.1))
}
}
if let expiryMatch = text.range(of: #"expires in (\d+) days?"#, options: .regularExpression) {
let expiryStr = String(text[expiryMatch])
if let numMatch = expiryStr.range(of: #"\d+"#, options: .regularExpression) {
expiryDays = Int(String(expiryStr[numMatch]))
}
}
return (used, total, expiryDays)
}
private static func parseResetDate(_ dateStr: String) -> Date? {
if dateStr.contains("-") {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = Calendar.current.timeZone
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: dateStr)
}
// Format: MM/DD - assume current or next year
let parts = dateStr.split(separator: "/")
guard parts.count == 2,
let month = Int(parts[0]),
let day = Int(parts[1])
else { return nil }
let calendar = Calendar.current
let now = Date()
let currentYear = calendar.component(.year, from: now)
var components = DateComponents()
components.month = month
components.day = day
components.year = currentYear
if let date = calendar.date(from: components), date > now {
return date
}
// If the date is in the past, it's next year
components.year = currentYear + 1
return calendar.date(from: components)
}
public static func displayPlanName(_ planName: String) -> String {
let cleaned = Self.cleanInlineValue(planName)
.replacingOccurrences(of: #"\s+"#, with: " ", options: [.regularExpression])
.trimmingCharacters(in: .whitespacesAndNewlines)
guard cleaned.localizedCaseInsensitiveContains("KIRO") else {
return cleaned.isEmpty ? planName : cleaned
}
return cleaned
.split(separator: " ")
.map { word in
if word.caseInsensitiveCompare("KIRO") == .orderedSame {
return "Kiro"
}
return word.prefix(1).uppercased() + word.dropFirst().lowercased()
}
.joined(separator: " ")
}
private static func percent(after label: String, in text: String) -> Double? {
let escaped = NSRegularExpression.escapedPattern(for: label)
return self.firstCapture(
in: text,
pattern: #"(?i)"# + escaped + #"\s+(\d+\.?\d*)%"#)
.flatMap(Double.init)
}
private static func firstCapture(in text: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil }
let nsRange = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = regex.firstMatch(in: text, options: [], range: nsRange) else { return nil }
let captureIndex = match.numberOfRanges > 1 ? 1 : 0
guard let range = Range(match.range(at: captureIndex), in: text) else { return nil }
return String(text[range]).trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func cleanInlineValue(_ text: String) -> String {
self.stripANSI(text)
.replacingOccurrences(of: #"\x1B|\[[0-9;?]*[A-Za-z]"#, with: "", options: [.regularExpression])
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
extension KiroStatusProbe {
/// Recent Kiro CLIs can keep their TUI alive indefinitely under a PTY even with `--no-interactive`,
/// while older releases emit no output through pipes. Prefer pipes and retain PTY as a bounded fallback.
private func runCommand(
arguments: [String],
timeout: TimeInterval,
idleTimeout: TimeInterval,
kind: KiroCommandKind) async throws -> KiroCLIResult
{
let clock = ContinuousClock()
let deadline = clock.now.advanced(by: .seconds(max(0, timeout)))
let fallbackDelay = min(max(0, self.pipeTimeoutCap), max(0, timeout / 2))
let pipeActivity = KiroPipeActivityState()
let cancellationState = KiroTransportCancellationState()
return try await withThrowingTaskGroup(of: KiroTransportEvent.self) { group in
defer {
cancellationState.cancel()
group.cancelAll()
}
group.addTask {
await .pipe(self.pipeOutcome(
arguments: arguments,
timeout: timeout,
idleTimeout: idleTimeout,
activityState: pipeActivity))
}
group.addTask {
try? await Task.sleep(for: .seconds(fallbackDelay))
return .fallbackReady
}
var ptyStarted = false
var pipeFinishedWithoutAcceptedResult = false
var pendingPTYResult: KiroCLIResult?
var pendingPTYFailure: KiroStatusProbeError?
while let event = try await group.next() {
try Task.checkCancellation()
switch event {
case .fallbackReady:
guard !ptyStarted, !pipeActivity.hasReceivedOutput else { continue }
let remaining = Self.timeInterval(from: clock.now.duration(to: deadline))
guard remaining > 0 else { continue }
ptyStarted = true
group.addTask {
await .pty(self.ptyOutcome(
arguments: arguments,
timeout: remaining,
idleTimeout: min(idleTimeout, remaining),
cancellationState: cancellationState))
}
case let .pipe(.result(result)):
if try self.shouldReturnPipeResult(
result,
for: kind,
before: deadline,
now: clock.now)
{
cancellationState.cancel()
group.cancelAll()
return result
}
pipeFinishedWithoutAcceptedResult = true
Self.logger.debug("Kiro pipe \(kind.rawValue) output was incomplete; awaiting PTY fallback")
if let pending = try Self.resolvePendingPTY(
result: pendingPTYResult,
failure: pendingPTYFailure,
before: deadline,
now: clock.now)
{
return pending
}
if !ptyStarted {
let remaining = Self.timeInterval(from: clock.now.duration(to: deadline))
guard remaining > 0 else { throw KiroStatusProbeError.timeout }
ptyStarted = true
group.addTask {
await .pty(self.ptyOutcome(
arguments: arguments,
timeout: remaining,
idleTimeout: min(idleTimeout, remaining),
cancellationState: cancellationState))
}
}
case .pipe(.failure(.timeout)):
pipeFinishedWithoutAcceptedResult = true
Self.logger.debug("Kiro pipe \(kind.rawValue) probe timed out; awaiting PTY fallback")
if let pending = try Self.resolvePendingPTY(
result: pendingPTYResult,
failure: pendingPTYFailure,
before: deadline,
now: clock.now)
{
return pending
}
case let .pipe(.failure(error)):
cancellationState.cancel()
group.cancelAll()
throw error
case .pipe(.cancelled), .pty(.cancelled):
throw CancellationError()
case let .pty(.result(result)):
guard self.shouldAcceptPTYResult(result, for: kind) else {
if pipeFinishedWithoutAcceptedResult {
try Self.ensureBeforeDeadline(clock.now, deadline: deadline)
return result
}
pendingPTYResult = result
continue
}
guard clock.now <= deadline else { throw KiroStatusProbeError.timeout }
cancellationState.cancel()
group.cancelAll()
return result
case let .pty(.failure(error)):
if pipeFinishedWithoutAcceptedResult {
throw error
}
pendingPTYFailure = error
}
}
if let pending = try Self.resolvePendingPTY(
result: pendingPTYResult,
failure: pendingPTYFailure,
before: deadline,
now: clock.now)
{
return pending
}
throw KiroStatusProbeError.timeout
}
}
private func pipeOutcome(
arguments: [String],
timeout: TimeInterval,
idleTimeout: TimeInterval,
activityState: KiroPipeActivityState) async -> KiroTransportOutcome
{
do {
return try await .result(self.runViaPipe(
arguments: arguments,
timeout: timeout,
idleTimeout: idleTimeout,
activityState: activityState))
} catch is CancellationError {
return .cancelled
} catch let error as KiroStatusProbeError {
return .failure(error)
} catch {
return .failure(.cliFailed(error.localizedDescription))
}
}
private func ptyOutcome(
arguments: [String],
timeout: TimeInterval,
idleTimeout: TimeInterval,
cancellationState: KiroTransportCancellationState) async -> KiroTransportOutcome
{
do {
return try await .result(self.runViaPTYAsync(
arguments: arguments,
timeout: timeout,
idleTimeout: idleTimeout,
cancellationState: cancellationState))
} catch is CancellationError {
return .cancelled
} catch let error as KiroStatusProbeError {
return .failure(error)
} catch {
return .failure(.cliFailed(error.localizedDescription))
}
}
fileprivate static func resolvePendingPTY(
result: KiroCLIResult?,
failure: KiroStatusProbeError?,
before deadline: ContinuousClock.Instant,
now: ContinuousClock.Instant) throws -> KiroCLIResult?
{
if result != nil || failure != nil {
guard now <= deadline else { throw KiroStatusProbeError.timeout }
}
if let result {
return result
}
if let failure {
throw failure
}
return nil
}
private static func ensureBeforeDeadline(
_ now: ContinuousClock.Instant,
deadline: ContinuousClock.Instant) throws
{
guard now <= deadline else { throw KiroStatusProbeError.timeout }
}
private func shouldAcceptPipeResult(_ result: KiroCLIResult, for kind: KiroCommandKind) -> Bool {
let output = result.output
if Self.isLoginRequired(output) {
return true
}
switch kind {
case .whoAmI:
let account = self.parseWhoAmIOutput(output)
return account.authMethod != nil || account.email != nil
case .usage:
return (try? self.parse(output: output)) != nil
case .context:
if self.parseContextUsage(output: output) != nil {
return true
}
return result.terminationStatus == 0
&& !result.stoppedAfterOutput
&& output.isEmpty
}
}
private func shouldReturnPipeResult(
_ result: KiroCLIResult,
for kind: KiroCommandKind,
before deadline: ContinuousClock.Instant,
now: ContinuousClock.Instant) throws -> Bool
{
let accepted = self.shouldAcceptPipeResult(result, for: kind)
guard accepted else { return false }
if !Self.isLoginRequired(result.output), now > deadline {
throw KiroStatusProbeError.timeout
}
return true
}
private func shouldAcceptPTYResult(_ result: KiroCLIResult, for kind: KiroCommandKind) -> Bool {
if Self.isLoginRequired(result.output) {
return true
}
return result.terminationStatus == 0 && self.shouldAcceptPipeResult(result, for: kind)
}
fileprivate static func timeInterval(from duration: Duration) -> TimeInterval {
let components = duration.components
return max(
0,
TimeInterval(components.seconds)
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000)
}
}
extension String {
fileprivate var nilIfEmpty: String? {
self.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
}
}