diff --git a/CHANGELOG.md b/CHANGELOG.md index 686b1a9cc4..018bd65702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Quota warnings: add opt-in predictive pace alerts for Codex and Claude session and weekly limits, with one alert per risk episode. Thanks @vincent-peng! ### Fixed +- Kiro: restore usage refresh for current CLIs that stall under PTY by accepting complete pipe output first while retaining a same-deadline PTY fallback for older releases (#1883). Thanks @txarly89! - Ollama: validate API keys against an authenticated endpoint instead of the public model catalog while preserving refresh cancellation. Thanks @joeVenner! - Claude CLI: resolve yearless and time-only reset timestamps to their nearest occurrence so stale data cannot appear nearly a day or year away. Thanks @fanwenlin! - Catalan: complete current strings, align instructional voice, and enforce catalog parity. Thanks @pmontp19! diff --git a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift index 76eebff234..82c5e5d00a 100644 --- a/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift @@ -226,17 +226,57 @@ public enum KiroStatusProbeError: LocalizedError, Sendable { } 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) { + 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) @@ -304,6 +344,27 @@ public struct KiroStatusProbe: Sendable { 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 KiroAccountProbeStatus: Equatable { case account(KiroAccountInfo) case notLoggedIn @@ -339,28 +400,31 @@ public struct KiroStatusProbe: Sendable { } private func ensureLoggedIn() async throws -> KiroAccountInfo { - let result = try self.runViaPTY( + let result = try await self.runCommand( arguments: ["whoami"], timeout: self.accountProbeTimeout, - idleTimeout: 1.5) - guard case let .processExited(status) = result.completion else { - if Self.isWhoAmILoginRequired(result.text) { + 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.text, - stderr: "", - terminationStatus: status) + stdout: result.stdout, + stderr: result.stderr, + terminationStatus: result.terminationStatus) } func validateWhoAmIOutput(stdout: String, stderr: String, terminationStatus: Int32) throws -> KiroAccountInfo { - let trimmedStdout = stdout.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedStderr = stderr.trimmingCharacters(in: .whitespacesAndNewlines) - let combined = trimmedStderr.isEmpty ? trimmedStdout : trimmedStderr + let combined = KiroCLIResult( + stdout: stdout, + stderr: stderr, + terminationStatus: terminationStatus, + stoppedAfterOutput: false).output - if Self.isWhoAmILoginRequired(combined) { + if Self.isLoginRequired(combined) { throw KiroStatusProbeError.notLoggedIn } @@ -378,9 +442,13 @@ public struct KiroStatusProbe: Sendable { return self.parseWhoAmIOutput(combined) } - private static func isWhoAmILoginRequired(_ output: String) -> Bool { + private static func isLoginRequired(_ output: String) -> Bool { let lowered = Self.stripANSI(output).lowercased() - return lowered.contains("not logged in") || lowered.contains("login required") + 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 { @@ -415,52 +483,274 @@ public struct KiroStatusProbe: Sendable { } private func runUsageCommand() async throws -> String { - let result = try self.runViaPTY( + let result = try await self.runCommand( arguments: ["chat", "--no-interactive", "/usage"], - timeout: 20.0, - idleTimeout: 4.0) - let output = result.text - let combinedStripped = Self.stripANSI(output).lowercased() - - if combinedStripped.contains("not logged in") - || combinedStripped.contains("login required") - || combinedStripped.contains("failed to initialize auth portal") - || combinedStripped.contains("kiro-cli login") - || combinedStripped.contains("oauth error") - { + timeout: self.usageProbeTimeout, + idleTimeout: 4.0, + kind: .usage) + let output = result.output + if Self.isLoginRequired(output) { throw KiroStatusProbeError.notLoggedIn } - try Self.validatePTYCompletion( + try Self.validateCommandCompletion( result, command: "usage", - allowIdleOutput: Self.isUsageOutputComplete(output)) + allowIdleOutput: (try? self.parse(output: output)) != nil) return output } private func fetchContextUsage() async throws -> KiroContextUsageSnapshot? { - let result = try self.runViaPTY( + let result = try await self.runCommand( arguments: ["chat", "--no-interactive", "/context"], - timeout: 8.0, - idleTimeout: 3.0) - try Self.validatePTYCompletion(result, command: "context", allowIdleOutput: true) - return self.parseContextUsage(output: result.text) + 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 + } + + /// 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 pipeBudget = min(max(0, self.pipeTimeoutCap), max(0, timeout / 2)) + + try Task.checkCancellation() + do { + let result = try await self.runViaPipe( + arguments: arguments, + timeout: pipeBudget, + idleTimeout: idleTimeout) + if self.shouldAcceptPipeResult(result, for: kind) { + try Task.checkCancellation() + guard clock.now <= deadline else { throw KiroStatusProbeError.timeout } + return result + } + Self.logger.debug("Kiro pipe \(kind.rawValue) output was incomplete; retrying through PTY") + } catch KiroStatusProbeError.timeout { + Self.logger.debug("Kiro pipe \(kind.rawValue) probe timed out; retrying through PTY") + } + + try Task.checkCancellation() + let remaining = Self.timeInterval(from: clock.now.duration(to: deadline)) + guard remaining > 0 else { throw KiroStatusProbeError.timeout } + let result = try self.runViaPTY( + arguments: arguments, + timeout: remaining, + idleTimeout: min(idleTimeout, remaining)) + try Task.checkCancellation() + guard clock.now <= deadline else { throw KiroStatusProbeError.timeout } + return result + } + + 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 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) + } + + private func runViaPipe( + arguments: [String], + timeout: TimeInterval, + idleTimeout: TimeInterval) async throws -> KiroCLIResult + { + try Task.checkCancellation() + guard let binary = self.cliBinaryResolver() else { + throw KiroStatusProbeError.cliNotFound + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + var env = ProcessInfo.processInfo.environment + 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() + } + } + + final class ActivityState: @unchecked Sendable { + private let lock = NSLock() + private var _lastActivityAt = ContinuousClock.now + private var _hasReceivedOutput = false + + var lastActivityAt: ContinuousClock.Instant { + self.lock.withLock { self._lastActivityAt } + } + + var hasReceivedOutput: Bool { + self.lock.withLock { self._hasReceivedOutput } + } + + func markActivity() { + self.lock.withLock { + self._lastActivityAt = .now + self._hasReceivedOutput = true + } + } + } + + let state = ActivityState() + 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 } - /// Runs `kiro-cli` through a pseudo-terminal. The CLI (forked from Amazon Q Developer CLI) - /// performs terminal setup on startup and produces no output at all when launched without a - /// controlling terminal, so a plain pipe-backed launch hangs until it is killed. Routing every - /// invocation through a PTY (as the Codex/Claude probes do) is what makes it emit output. private func runViaPTY( arguments: [String], timeout: TimeInterval, - idleTimeout: TimeInterval) throws -> TTYCommandRunner.Result + idleTimeout: TimeInterval) throws -> KiroCLIResult { guard let binary = self.cliBinaryResolver() else { throw KiroStatusProbeError.cliNotFound } do { - return try TTYCommandRunner().run( + let result = try TTYCommandRunner().run( binary: binary, send: "", options: TTYCommandRunner.Options( @@ -470,6 +760,22 @@ public struct KiroStatusProbe: Sendable { idleTimeout: idleTimeout, extraArgs: arguments, returnOnEmptyProcessExit: true)) + 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 { @@ -479,22 +785,21 @@ public struct KiroStatusProbe: Sendable { } } - private static func validatePTYCompletion( - _ result: TTYCommandRunner.Result, + private static func validateCommandCompletion( + _ result: KiroCLIResult, command: String, allowIdleOutput: Bool) throws { - switch result.completion { - case let .processExited(status): - guard status == 0 else { - let message = Self.stripANSI(result.text).trimmingCharacters(in: .whitespacesAndNewlines) - throw KiroStatusProbeError.cliFailed( - message.isEmpty ? "Kiro CLI \(command) failed with status \(status)." : message) - } - case .idleTimeout: + if result.stoppedAfterOutput { guard allowIdleOutput else { throw KiroStatusProbeError.timeout } - case .outputCondition, .deadlineExceeded: - 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) } } @@ -782,7 +1087,9 @@ public struct KiroStatusProbe: Sendable { return cleaned .split(separator: " ") .map { word in - if word.caseInsensitiveCompare("KIRO") == .orderedSame { return "Kiro" } + if word.caseInsensitiveCompare("KIRO") == .orderedSame { + return "Kiro" + } return word.prefix(1).uppercased() + word.dropFirst().lowercased() } .joined(separator: " ") @@ -810,15 +1117,6 @@ public struct KiroStatusProbe: Sendable { .replacingOccurrences(of: #"\x1B|\[[0-9;?]*[A-Za-z]"#, with: "", options: [.regularExpression]) .trimmingCharacters(in: .whitespacesAndNewlines) } - - private static func isUsageOutputComplete(_ output: String) -> Bool { - let stripped = self.stripANSI(output).lowercased() - return stripped.contains("covered in plan") - || stripped.contains("resets on") - || stripped.contains("bonus credits") - || stripped.contains("plan:") - || stripped.contains("managed by admin") - } } extension String { diff --git a/Tests/CodexBarTests/KiroStatusProbeTestSupport.swift b/Tests/CodexBarTests/KiroStatusProbeTestSupport.swift new file mode 100644 index 0000000000..7fcf8150b7 --- /dev/null +++ b/Tests/CodexBarTests/KiroStatusProbeTestSupport.swift @@ -0,0 +1,83 @@ +import Foundation +@testable import CodexBarCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +final class KiroTestProcessRegistry: @unchecked Sendable { + private struct Record { + var processGroup: pid_t? + } + + private let lock = NSLock() + private let blockOnUnregister: Int? + private let blockStartedURL: URL? + private let unblock: DispatchSemaphore? + private var records: [pid_t: Record] = [:] + private var unregisteredPIDs: Set = [] + private var unregisterCount = 0 + + init( + blockOnUnregister: Int? = nil, + blockStartedURL: URL? = nil, + unblock: DispatchSemaphore? = nil) + { + self.blockOnUnregister = blockOnUnregister + self.blockStartedURL = blockStartedURL + self.unblock = unblock + } + + var dependencies: KiroStatusProbe.PipeProcessRegistry { + .init( + beginLaunch: { true }, + endLaunch: {}, + register: { pid, _ in + self.lock.withLock { + self.records[pid] = Record(processGroup: nil) + } + return true + }, + updateProcessGroup: { pid, processGroup in + self.lock.withLock { + guard self.records[pid] != nil else { return } + self.records[pid]?.processGroup = processGroup + } + }, + unregister: { pid in + let shouldBlock = self.lock.withLock { + self.records.removeValue(forKey: pid) + self.unregisteredPIDs.insert(pid) + self.unregisterCount += 1 + return self.unregisterCount == self.blockOnUnregister + } + if shouldBlock { + if let blockStartedURL = self.blockStartedURL { + _ = FileManager.default.createFile(atPath: blockStartedURL.path, contents: Data()) + } + self.unblock?.wait() + } + }) + } + + func isRegistered(_ pid: pid_t) -> Bool { + self.lock.withLock { self.records[pid] != nil } + } + + func didUnregister(_ pid: pid_t) -> Bool { + self.lock.withLock { self.unregisteredPIDs.contains(pid) } + } + + func terminate(_ pid: pid_t) { + let processGroup = self.lock.withLock { () -> pid_t? in + self.records[pid]?.processGroup + } + if let processGroup, processGroup > 0, processGroup != getpgrp() { + _ = kill(-processGroup, SIGKILL) + } + if pid > 0 { + _ = kill(pid, SIGKILL) + } + } +} diff --git a/Tests/CodexBarTests/KiroStatusProbeTests.swift b/Tests/CodexBarTests/KiroStatusProbeTests.swift index 77cc078205..1b48ce5328 100644 --- a/Tests/CodexBarTests/KiroStatusProbeTests.swift +++ b/Tests/CodexBarTests/KiroStatusProbeTests.swift @@ -51,6 +51,108 @@ struct KiroStatusProbeTests { #expect(snapshot.authMethod == nil) } + @Test + func `pipe and PTY share the account deadline`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + if [ ! -t 1 ]; then + sleep 5 + exit 1 + fi + sleep 0.45 + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + accountProbeTimeout: 0.8, + pipeTimeoutCap: 0.4).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == nil) + #expect(snapshot.authMethod == nil) + } + + @Test + func `accepted pipe output cannot overrun the usage deadline`() async throws { + let pipePIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-deadline-\(UUID().uuidString).pid") + let ptyMarker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-deadline-\(UUID().uuidString).pty") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ ! -t 1 ]; then + printf '%s\n' "$$" > '\(pipePIDFile.path)' + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + trap '' TERM + while true; do sleep 1; done + fi + : > '\(ptyMarker.path)' + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { + if let text = try? String(contentsOf: pipePIDFile, encoding: .utf8), + let pipePID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(pipePID, SIGKILL) + } + try? FileManager.default.removeItem(at: pipePIDFile) + try? FileManager.default.removeItem(at: ptyMarker) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + let clock = ContinuousClock() + let startedAt = clock.now + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + usageProbeTimeout: 0.8, + pipeTimeoutCap: 0.4) + + await #expect { + _ = try await probe.fetch() + } throws: { error in + guard case KiroStatusProbeError.timeout = error else { return false } + return true + } + + #expect(startedAt.duration(to: clock.now) < .seconds(2)) + #expect(!FileManager.default.fileExists(atPath: ptyMarker.path)) + let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8) + let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(pipePID, 0) == -1) + } + @Test func `fetch preserves account info when account probe succeeds`() async throws { let cliURL = try self.makeCLI( @@ -82,6 +184,350 @@ struct KiroStatusProbeTests { #expect(snapshot.accountEmail == "person@example.com") #expect(snapshot.authMethod == "Google") } +} + +extension KiroStatusProbeTests { + @Test + func `fetch supports kiro cli that only completes through pipes`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ -t 1 ]; then + sleep 30 + exit 1 + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe(cliBinaryResolver: { cliURL.path }) + let snapshot = try await probe.fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(snapshot.accountEmail == "person@example.com") + } + + @Test + func `fetch falls back to PTY for older kiro cli`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + sleep 30 + exit 1 + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + pipeTimeoutCap: 0.2) + let snapshot = try await probe.fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + #expect(snapshot.accountEmail == "person@example.com") + } + + @Test + func `fetch falls back to PTY after incomplete pipe output`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Plan: loading...\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + fi + + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.creditsUsed == 12.50) + } + + @Test + func `pipe cleanup finishes before PTY fallback starts`() async throws { + let childPIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pipe-child-\(UUID().uuidString).pid") + let ptyMarker = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-pty-fallback-\(UUID().uuidString).started") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ ! -t 1 ]; then + (trap '' TERM; while true; do sleep 1; done) & + child=$! + printf '%s\n' "$child" > '\(childPIDFile.path)' + printf 'Plan: loading...\n' + exit 0 + fi + if test -s '\(childPIDFile.path)' && kill -0 "$(cat '\(childPIDFile.path)')" 2>/dev/null; then + printf 'pipe child still running\n' >&2 + exit 97 + fi + : > '\(ptyMarker.path)' + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { + if let text = try? String(contentsOf: childPIDFile, encoding: .utf8), + let childPID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(childPID, SIGKILL) + } + try? FileManager.default.removeItem(at: childPIDFile) + try? FileManager.default.removeItem(at: ptyMarker) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(FileManager.default.fileExists(atPath: ptyMarker.path)) + let childPIDText = try String(contentsOf: childPIDFile, encoding: .utf8) + let childPID = try #require(pid_t(childPIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(kill(childPID, 0) == -1) + } + + @Test + func `shutdown registry terminates an active pipe probe`() async throws { + let pipePIDFile = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-shutdown-\(UUID().uuidString).pid") + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + if [ -t 1 ]; then + exit 97 + fi + printf '%s\n' "$$" > '\(pipePIDFile.path)' + printf 'Plan: loading...\n' + trap '' TERM + while true; do sleep 1; done + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + + let registry = KiroTestProcessRegistry() + let task = Task { + try await KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + pipeProcessRegistry: registry.dependencies).fetch() + } + defer { + task.cancel() + if let text = try? String(contentsOf: pipePIDFile, encoding: .utf8), + let pipePID = pid_t(text.trimmingCharacters(in: .whitespacesAndNewlines)) + { + _ = kill(pipePID, SIGKILL) + } + try? FileManager.default.removeItem(at: pipePIDFile) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + try await waitForFile(pipePIDFile) + let pipePIDText = try String(contentsOf: pipePIDFile, encoding: .utf8) + let pipePID = try #require(pid_t(pipePIDText.trimmingCharacters(in: .whitespacesAndNewlines))) + #expect(registry.isRegistered(pipePID)) + + let clock = ContinuousClock() + let shutdownStartedAt = clock.now + registry.terminate(pipePID) + await #expect(throws: (any Error).self) { + _ = try await task.value + } + + #expect(shutdownStartedAt.duration(to: clock.now) < .seconds(2)) + #expect(!registry.isRegistered(pipePID)) + #expect(registry.didUnregister(pipePID)) + #expect(kill(pipePID, 0) == -1) + } + + @Test + func `fetch combines pipe stdout with stderr warnings`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ -t 1 ]; then + exit 91 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + printf 'warning: cached session\n' >&2 + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + printf 'warning: telemetry unavailable\n' >&2 + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.authMethod == "Google") + } + + @Test + func `fetch falls back to PTY after pipe requires a terminal`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + printf 'terminal required\n' >&2 + exit 2 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + printf 'Context window: 7.5%% used (estimated)\n' + printf '█ Context files 2.5%% (estimated)\n' + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + let snapshot = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + + #expect(snapshot.planName == "KIRO FREE") + #expect(snapshot.accountEmail == "person@example.com") + #expect(snapshot.contextUsage?.totalPercentUsed == 7.5) + #expect(snapshot.contextUsage?.contextFilesPercent == 2.5) + } + + @Test + func `pipe auth failure on stderr remains authoritative`() async throws { + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ ! -t 1 ]; then + printf 'Opening browser...\n' + printf 'Not logged in\n' >&2 + exit 1 + fi + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) } + + await #expect { + _ = try await KiroStatusProbe(cliBinaryResolver: { cliURL.path }).fetch() + } throws: { error in + guard case KiroStatusProbeError.notLoggedIn = error else { return false } + return true + } + } @Test func `fetch rejects account markers from failed whoami`() async throws { @@ -119,6 +565,19 @@ struct KiroStatusProbeTests { let cliURL = try self.makeCLI( """ #!/bin/sh + if [ -t 1 ]; then + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + fi + if [ "$1" = "whoami" ]; then printf 'Logged in with Google\nEmail: person@example.com\n' exit 0 @@ -288,6 +747,56 @@ struct KiroStatusProbeTests { #expect(Date().timeIntervalSince(cancelledAt) < 4) } + @Test + func `cancellation during pipe cleanup wins over an expired context deadline`() async throws { + let cleanupStarted = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-kiro-cleanup-\(UUID().uuidString).started") + let unblockCleanup = DispatchSemaphore(value: 0) + let registry = KiroTestProcessRegistry( + blockOnUnregister: 3, + blockStartedURL: cleanupStarted, + unblock: unblockCleanup) + let cliURL = try self.makeCLI( + """ + #!/bin/sh + if [ "$1" = "whoami" ]; then + printf 'Logged in with Google\nEmail: person@example.com\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/usage" ]; then + printf 'Estimated Usage | resets on 2026-06-01 | KIRO FREE\n' + printf 'Credits (12.50 of 50 covered in plan)\n' + printf '████████████████████ 25%%\n' + exit 0 + fi + if [ "$1" = "chat" ] && [ "$3" = "/context" ]; then + exit 0 + fi + exit 1 + """) + defer { + unblockCleanup.signal() + try? FileManager.default.removeItem(at: cleanupStarted) + try? FileManager.default.removeItem(at: cliURL.deletingLastPathComponent()) + } + + let probe = KiroStatusProbe( + cliBinaryResolver: { cliURL.path }, + contextProbeTimeout: 0.2, + pipeProcessRegistry: registry.dependencies) + let task = Task { try await probe.fetch() } + defer { task.cancel() } + + try await waitForFile(cleanupStarted) + task.cancel() + try await Task.sleep(for: .milliseconds(250)) + unblockCleanup.signal() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + @Test func `fetch cancellation while waiting for account probe is preserved`() async throws { let accountStarted = FileManager.default.temporaryDirectory diff --git a/docs/kiro.md b/docs/kiro.md index 90ae837cf0..61056986cd 100644 --- a/docs/kiro.md +++ b/docs/kiro.md @@ -14,7 +14,9 @@ Kiro uses the AWS `kiro-cli` tool to fetch usage data. No browser cookies or OAu 1) **CLI command** (primary and only strategy) - Command: `kiro-cli chat --no-interactive "/usage"` - - Timeout: 20 seconds (idle cutoff after ~10s of no output once the CLI starts responding). + - Timeout: 20 seconds (idle cutoff after 4 seconds of no output once the CLI starts responding). + - CodexBar tries ordinary stdout/stderr pipes first for current Kiro CLI releases. Incomplete or unusable + pipe output falls back to a pseudo-terminal within the same overall command deadline for older releases. - Requires `kiro-cli` installed and logged in via AWS Builder ID. - Output is ANSI-decorated; CodexBar strips escape sequences before parsing.