Skip to content

Commit e23a0c4

Browse files
committed
fix: harden Kiro CLI idle handling (#145) (thanks @chadneal)
1 parent 7ff5d2f commit e23a0c4

4 files changed

Lines changed: 58 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- Vertex AI: harden quota usage parsing for edge-case responses.
1010
- Kiro: add CLI-based usage provider via kiro-cli. Thanks @neror!
1111
- Kiro: clean up provider wiring and show plan name in the menu.
12+
- Kiro: harden CLI idle handling to avoid partial usage snapshots (#145). Thanks @chadneal!
1213
- Augment: add provider with browser-cookie usage tracking.
1314
- Cursor: support legacy request-based plans and show individual on-demand usage (#125) — thanks @vltansky
1415
- Cursor: avoid Intel crash when opening login and harden WebKit teardown. Thanks @meghanto!

Sources/CodexBarCore/Providers/Kiro/KiroStatusProbe.swift

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ public struct KiroStatusProbe: Sendable {
114114
let stdout: String
115115
let stderr: String
116116
let terminationStatus: Int32
117+
let terminatedForIdle: Bool
117118
}
118119

119120
private func ensureLoggedIn() async throws {
@@ -147,7 +148,10 @@ public struct KiroStatusProbe: Sendable {
147148
}
148149

149150
private func runUsageCommand() async throws -> String {
150-
let result = try await self.runCommand(arguments: ["chat", "--no-interactive", "/usage"], timeout: 20.0)
151+
let result = try await self.runCommand(
152+
arguments: ["chat", "--no-interactive", "/usage"],
153+
timeout: 20.0,
154+
idleTimeout: 10.0)
151155
let trimmedStdout = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
152156
let trimmedStderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
153157
let combinedOutput = trimmedStderr.isEmpty ? trimmedStdout : trimmedStderr
@@ -162,6 +166,10 @@ public struct KiroStatusProbe: Sendable {
162166
throw KiroStatusProbeError.notLoggedIn
163167
}
164168

169+
if result.terminatedForIdle, !Self.isUsageOutputComplete(combinedOutput) {
170+
throw KiroStatusProbeError.timeout
171+
}
172+
165173
if !trimmedStdout.isEmpty {
166174
return result.stdout
167175
}
@@ -183,8 +191,8 @@ public struct KiroStatusProbe: Sendable {
183191
private func runCommand(
184192
arguments: [String],
185193
timeout: TimeInterval,
186-
idleTimeout: TimeInterval = 5.0
187-
) async throws -> KiroCLIResult {
194+
idleTimeout: TimeInterval = 5.0) async throws -> KiroCLIResult
195+
{
188196
guard let binary = TTYCommandRunner.which("kiro-cli") else {
189197
throw KiroStatusProbeError.cliNotFound
190198
}
@@ -212,37 +220,37 @@ public struct KiroStatusProbe: Sendable {
212220
private var _stderrData = Data()
213221

214222
var lastActivityAt: Date {
215-
lock.lock()
223+
self.lock.lock()
216224
defer { lock.unlock() }
217-
return _lastActivityAt
225+
return self._lastActivityAt
218226
}
219227

220228
var hasReceivedOutput: Bool {
221-
lock.lock()
229+
self.lock.lock()
222230
defer { lock.unlock() }
223-
return _hasReceivedOutput
231+
return self._hasReceivedOutput
224232
}
225233

226234
func appendStdout(_ data: Data) {
227-
lock.lock()
235+
self.lock.lock()
228236
defer { lock.unlock() }
229-
_stdoutData.append(data)
230-
_lastActivityAt = Date()
231-
_hasReceivedOutput = true
237+
self._stdoutData.append(data)
238+
self._lastActivityAt = Date()
239+
self._hasReceivedOutput = true
232240
}
233241

234242
func appendStderr(_ data: Data) {
235-
lock.lock()
243+
self.lock.lock()
236244
defer { lock.unlock() }
237-
_stderrData.append(data)
238-
_lastActivityAt = Date()
239-
_hasReceivedOutput = true
245+
self._stderrData.append(data)
246+
self._lastActivityAt = Date()
247+
self._hasReceivedOutput = true
240248
}
241249

242250
func getOutput() -> (stdout: Data, stderr: Data) {
243-
lock.lock()
251+
self.lock.lock()
244252
defer { lock.unlock() }
245-
return (_stdoutData, _stderrData)
253+
return (self._stdoutData, self._stderrData)
246254
}
247255
}
248256

@@ -274,13 +282,20 @@ public struct KiroStatusProbe: Sendable {
274282
}
275283

276284
let deadline = Date().addingTimeInterval(timeout)
285+
var didHitDeadline = false
286+
var didTerminateForIdle = false
277287

278-
while process.isRunning, Date() < deadline {
288+
while process.isRunning {
289+
if Date() >= deadline {
290+
didHitDeadline = true
291+
break
292+
}
279293
// Idle timeout: if we got output but then it went silent
280294
if state.hasReceivedOutput,
281295
Date().timeIntervalSince(state.lastActivityAt) >= idleTimeout
282296
{
283297
// Process went idle after producing output - likely done or stuck
298+
didTerminateForIdle = true
284299
break
285300
}
286301
Thread.sleep(forTimeInterval: 0.1)
@@ -293,8 +308,7 @@ public struct KiroStatusProbe: Sendable {
293308
if process.isRunning {
294309
process.terminate()
295310
process.waitUntilExit()
296-
// Only throw timeout if we never got output
297-
if !state.hasReceivedOutput {
311+
if didHitDeadline || !state.hasReceivedOutput {
298312
continuation.resume(throwing: KiroStatusProbeError.timeout)
299313
return
300314
}
@@ -313,7 +327,8 @@ public struct KiroStatusProbe: Sendable {
313327
continuation.resume(returning: KiroCLIResult(
314328
stdout: stdoutOutput,
315329
stderr: stderrOutput,
316-
terminationStatus: process.terminationStatus))
330+
terminationStatus: process.terminationStatus,
331+
terminatedForIdle: didTerminateForIdle))
317332
}
318333
}
319334
}
@@ -342,7 +357,6 @@ public struct KiroStatusProbe: Sendable {
342357
}
343358

344359
// Track which key patterns matched to detect format changes
345-
var matchedPlanName = false
346360
var matchedPercent = false
347361
var matchedCredits = false
348362

@@ -351,7 +365,6 @@ public struct KiroStatusProbe: Sendable {
351365
if let planMatch = stripped.range(of: #"\|\s*(KIRO\s+\w+)"#, options: .regularExpression) {
352366
let raw = String(stripped[planMatch]).replacingOccurrences(of: "|", with: "")
353367
planName = raw.trimmingCharacters(in: .whitespaces)
354-
matchedPlanName = true
355368
}
356369

357370
// Parse reset date from "resets on 01/01"
@@ -411,7 +424,7 @@ public struct KiroStatusProbe: Sendable {
411424
}
412425

413426
// Require at least one key pattern to match to avoid silent failures
414-
if !matchedPlanName, !matchedPercent, !matchedCredits {
427+
if !matchedPercent, !matchedCredits {
415428
throw KiroStatusProbeError.parseError(
416429
"No recognizable usage patterns found. Kiro CLI output format may have changed.")
417430
}
@@ -463,4 +476,11 @@ public struct KiroStatusProbe: Sendable {
463476
components.year = currentYear + 1
464477
return calendar.date(from: components)
465478
}
479+
480+
private static func isUsageOutputComplete(_ output: String) -> Bool {
481+
let stripped = self.stripANSI(output).lowercased()
482+
return stripped.contains("covered in plan")
483+
|| stripped.contains("resets on")
484+
|| stripped.contains("bonus credits")
485+
}
466486
}

Tests/CodexBarTests/KiroStatusProbeTests.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ struct KiroStatusProbeTests {
110110
#expect(snapshot.bonusExpiryDays == 1)
111111
}
112112

113+
@Test
114+
func rejectsOutputMissingUsageMarkers() throws {
115+
let output = """
116+
| KIRO FREE |
117+
"""
118+
119+
let probe = KiroStatusProbe()
120+
#expect(throws: KiroStatusProbeError.self) {
121+
try probe.parse(output: output)
122+
}
123+
}
124+
113125
// MARK: - Snapshot Conversion
114126

115127
@Test

docs/kiro.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Kiro uses the AWS `kiro-cli` tool to fetch usage data. No browser cookies or OAu
1414

1515
1) **CLI command** (primary and only strategy)
1616
- Command: `kiro-cli chat --no-interactive "/usage"`
17-
- Timeout: 10 seconds.
17+
- Timeout: 20 seconds (idle cutoff after ~10s of no output once the CLI starts responding).
1818
- Requires `kiro-cli` installed and logged in via AWS Builder ID.
1919
- Output is ANSI-decorated; CodexBar strips escape sequences before parsing.
2020

0 commit comments

Comments
 (0)