Merge pull request #799 from getagentseal/fix/kimicode-visibility

kimicode: session discovery fixes + subscription quota in menubar
This commit is contained in:
Resham Joshi 2026-07-26 06:37:20 -07:00 committed by GitHub
commit d2aa1e8984
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1141 additions and 15 deletions

View file

@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity.
## Where it reads from
The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans:
By default the provider scans every known Kimi Code runtime store:
```text
$KIMI_CODE_HOME/sessions/wd_*/session_*/
~/.kimi-code
~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime)
```
Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans:
```text
$HOME/sessions/wd_*/<session-dir>/
├── state.json
└── agents/<agent-id>/wire.jsonl
```
Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions.
Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions.
## Storage format

View file

@ -138,11 +138,19 @@ final class AppStore {
var codexError: String?
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
var kimiUsage: KimiUsage?
var kimiError: String?
// No keychain dance for Kimi "connected" just means the CLI's
// credential file exists, so we start dormant and auto-activate on the
// first refresh tick.
var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
/// Generation tokens for the in-flight refresh tasks. Incremented on every
/// disconnect / reset so a fetch that started before the disconnect cannot
/// resume after the await and re-populate the freshly-cleared state.
private var claudeRefreshGen: Int = 0
private var codexRefreshGen: Int = 0
private var kimiRefreshGen: Int = 0
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
@ -1104,6 +1112,90 @@ final class AppStore {
}
}
// MARK: - Kimi Code
/// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's
/// credential file is prompt-free, so the first refresh tick activates
/// the dormant state automatically.
func bootstrapKimi() async {
// Capture the generation before the await so a disconnect that lands
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
let gen = kimiRefreshGen
kimiLoadState = .bootstrapping
do {
let usage = try await KimiSubscriptionService.refresh()
guard gen == kimiRefreshGen else { return }
kimiUsage = usage
kimiError = nil
kimiLoadState = .loaded
} catch let err as KimiSubscriptionService.FetchError {
guard gen == kimiRefreshGen else { return }
applyKimiFetchError(err)
} catch {
guard gen == kimiRefreshGen else { return }
kimiError = sanitizeForUI(String(describing: error))
kimiLoadState = .failed
}
}
func refreshKimi() async {
_ = await refreshKimiReportingSuccess()
}
@discardableResult
func refreshKimiReportingSuccess() async -> Bool {
if case .dormant = kimiLoadState {
await bootstrapKimi()
return kimiLoadState == .loaded
}
guard KimiSubscriptionService.hasCredential else {
if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped }
return false
}
let gen = kimiRefreshGen
if kimiUsage == nil { kimiLoadState = .loading }
do {
let usage = try await KimiSubscriptionService.refresh()
guard gen == kimiRefreshGen else { return false }
kimiUsage = usage
kimiError = nil
kimiLoadState = .loaded
return true
} catch let err as KimiSubscriptionService.FetchError {
guard gen == kimiRefreshGen else { return false }
applyKimiFetchError(err)
return false
} catch {
guard gen == kimiRefreshGen else { return false }
kimiError = sanitizeForUI(String(describing: error))
kimiLoadState = .failed
return false
}
}
func disconnectKimi() {
KimiSubscriptionService.disconnect()
kimiRefreshGen &+= 1
kimiUsage = nil
kimiError = nil
kimiLoadState = .notBootstrapped
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
}
private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
kimiError = sanitized
if case .noCredentials = err {
kimiLoadState = .noCredentials
} else if err.isTerminal {
kimiLoadState = .terminalFailure(reason: sanitized)
} else if let retryAt = err.rateLimitRetryAt {
kimiLoadState = .transientFailure(retryAt: retryAt)
} else {
kimiLoadState = .failed
}
}
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
subscriptionError = sanitized
@ -1172,6 +1264,10 @@ final class AppStore {
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
if worst > 0 { providers.append(("Codex", worst)) }
}
if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) {
let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0)
if worst > 0 { providers.append(("Kimi Code", worst)) }
}
let worst = providers.map(\.percent).max() ?? 0
let severity = QuotaSummary.severity(for: worst / 100)
let sorted = providers.sorted { $0.percent > $1.percent }
@ -1192,6 +1288,7 @@ final class AppStore {
switch filter {
case .claude: return claudeQuotaSummary(filter: filter)
case .codex: return codexQuotaSummary(filter: filter)
case .kimiCode: return kimiQuotaSummary(filter: filter)
default: return nil
}
}
@ -1297,6 +1394,43 @@ final class AppStore {
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
}
private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
if case .notBootstrapped = kimiLoadState { return nil }
if case .bootstrapping = kimiLoadState { return nil }
if case .noCredentials = kimiLoadState { return nil }
let connection: QuotaSummary.Connection = {
switch kimiLoadState {
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
case .loading: return kimiUsage == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return kimiUsage == nil ? .loading : .stale
// Kimi tokens expire ~every 15 min and only the CLI renews them, so
// terminal is the steady state between CLI uses. Keep the last-known
// bars (marked stale) instead of flapping the chip to a reconnect
// card; the reconnect card is reserved for the genuinely-no-data case.
case let .terminalFailure(reason): return kimiUsage == nil ? .terminalFailure(reason: reason) : .stale
case .transientFailure: return .transientFailure
}
}()
var primary: QuotaSummary.Window?
var details: [QuotaSummary.Window] = []
if let usage = kimiUsage {
if let w = usage.primary {
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
primary = row
details.append(row)
}
for w in usage.details {
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
if primary == nil { primary = row }
details.append(row)
}
}
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: [])
}
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
/// when the current window has just reset and projection from current data isn't meaningful.
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
@ -1400,6 +1534,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case ibmBob = "IBM Bob"
case kiro = "Kiro"
case kimi = "Kimi"
case kimiCode = "Kimi Code"
case lingtaiTui = "LingTai TUI"
case kiloCode = "KiloCode"
case openclaw = "OpenClaw"
@ -1432,6 +1567,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .grok: ["grok", "grok build"]
case .hermes: ["hermes", "hermes agent"]
case .lingtaiTui: ["lingtai-tui", "lingtai tui"]
case .kimiCode: ["kimicode", "kimi code"]
default: [rawValue.lowercased()]
}
}
@ -1453,6 +1589,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .kiloCode: "kilo-code"
case .kiro: "kiro"
case .kimi: "kimi"
case .kimiCode: "kimicode"
case .lingtaiTui: "lingtai-tui"
case .openclaw: "openclaw"
case .opencode: "opencode"

View file

@ -519,6 +519,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
fileprivate var lastSubscriptionRefreshAt: Date?
fileprivate var lastCodexRefreshAt: Date?
fileprivate var lastKimiRefreshAt: Date?
private var claudeQuotaFailureCount = 0
private var nextClaudeQuotaRefreshAt: Date?
@ -614,8 +615,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
if let task = codexQuotaRefreshTask {
return await task.value
}
let task = Task { [store] in
await store.refreshCodexReportingSuccess()
// Kimi Code rides the same tick, but with its own cadence anchor:
// when Codex is not connected its refresh returns false immediately,
// so lastCodexRefreshAt never advances anchoring Kimi on it would
// poll api.kimi.com on every payload tick instead of the configured
// quota cadence. Anchor on attempt (not success) so a failing Kimi
// endpoint also respects the cadence.
let kimiDue: Bool = {
let cadence = SubscriptionRefreshCadence.current
guard cadence != .manual else { return false }
return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
}()
if kimiDue { lastKimiRefreshAt = Date() }
let task = Task { [store, kimiDue] in
async let codex = store.refreshCodexReportingSuccess()
if kimiDue {
_ = await store.refreshKimiReportingSuccess()
}
return await codex
}
codexQuotaRefreshTask = task
let result = await task.value
@ -833,6 +850,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
func resetSubscriptionCadenceAnchor() {
lastSubscriptionRefreshAt = nil
lastCodexRefreshAt = nil
lastKimiRefreshAt = nil
claudeQuotaFailureCount = 0
nextClaudeQuotaRefreshAt = nil
}

View file

@ -0,0 +1,51 @@
import Foundation
/// Pure display-decision helpers for the Kimi Code quota surfaces.
///
/// Kimi Code tokens live ~15 min and only the Kimi CLI renews them, so the
/// load state sits in `.terminalFailure` as its dominant steady state between
/// CLI uses. The always-visible surfaces (Plan tab, tab-strip chip) must keep
/// showing the last good snapshot with a quiet caption instead of flapping to
/// a reconnect screen every cycle. The reconnect screen is reserved for the
/// no-data case, where there is genuinely nothing to show.
enum KimiQuotaPresentation {
/// Which Plan-tab subview to render, given the load state and whether a
/// last-known snapshot exists.
enum PlanContent: Equatable {
case noCredentials
case loading
case failed
case transientFailed
case reconnect(reason: String?)
/// Render the loaded usage bars. `idle` is true when the login has
/// gone terminal but a snapshot is still on hand the caller stamps a
/// quiet "run the CLI" caption instead of hiding the data.
case usage(idle: Bool)
}
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
switch loadState {
case .notBootstrapped, .noCredentials:
return .noCredentials
case .dormant, .bootstrapping:
return .loading
case .loading, .loaded:
return hasUsage ? .usage(idle: false) : .loading
case .failed:
return .failed
case .transientFailure:
return hasUsage ? .usage(idle: false) : .transientFailed
case .terminalFailure(let reason):
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
}
}
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
/// so a bar can never silently masquerade as current. Kimi's ~15-min token
/// life makes 10 minutes the point where a snapshot is likely a cycle old.
static let stalenessThreshold: TimeInterval = 10 * 60
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
now.timeIntervalSince(fetchedAt) > stalenessThreshold
}
}

View file

@ -0,0 +1,372 @@
import Foundation
/// Live quota snapshot for a Kimi Code subscription, returned by
/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what
/// steipete/CodexBar consumes: a top-level `usage` envelope plus a
/// `limits` array of additional rate-limit windows. Numeric fields are
/// decoded leniently (String or Int/Double) because the API has shipped
/// both.
struct KimiUsage: Sendable, Equatable {
struct Window: Sendable, Equatable {
let label: String
let limit: Double
let used: Double
let remaining: Double?
let resetsAt: Date?
var usedPercent: Double { // 0.0 ... 100.0
guard limit > 0 else { return 0 }
return min(100, max(0, used / limit * 100))
}
}
/// The top-level `usage` envelope treated as the primary window.
let primary: Window?
/// Additional windows from the `limits` array (e.g. 5-hour rate limit).
let details: [Window]
/// Membership tier from user.membership.level (e.g. "Intermediate").
let plan: String?
/// Max parallel sessions from parallel.limit, when reported.
let parallelLimit: Int?
let fetchedAt: Date
}
/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's
/// credential file directly (~/.kimi-code/credentials/kimi-code.json)
/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived
/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is
/// a terminal state: the UI tells the user to run the CLI once.
enum KimiSubscriptionService {
private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")!
private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case noCredentials
case tokenExpired
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
var errorDescription: String? {
switch self {
case .noCredentials:
return "No Kimi Code credentials found. Sign in with the Kimi CLI first."
case .tokenExpired:
return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again."
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed: return "Kimi quota response was malformed."
case let .network(err): return "Network error: \(err.localizedDescription)"
}
}
var isTerminal: Bool {
if case .tokenExpired = self { return true }
if case .noCredentials = self { return true }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Credential file
private struct CredentialFile: Decodable {
let accessToken: String
let expiresAt: Double
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresAt = "expires_at"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accessToken = try c.decode(String.self, forKey: .accessToken)
// Tolerate Int / Double / String epoch-seconds.
if let n = try? c.decode(Double.self, forKey: .expiresAt) {
expiresAt = n
} else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) {
expiresAt = n
} else {
throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric")
}
}
}
private static var credentialsURL: URL {
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
?? NSHomeDirectory() + "/.kimi-code"
return URL(fileURLWithPath: home + "/credentials/kimi-code.json")
}
static var hasCredential: Bool {
FileManager.default.fileExists(atPath: credentialsURL.path)
}
/// Returns the access token only when it is still fresh (60s skew).
/// Throws noCredentials / tokenExpired otherwise.
private static func freshToken() throws -> String {
guard let data = FileManager.default.contents(atPath: credentialsURL.path),
let cred = try? JSONDecoder().decode(CredentialFile.self, from: data),
!cred.accessToken.isEmpty else {
throw FetchError.noCredentials
}
guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else {
throw FetchError.tokenExpired
}
return cred.accessToken
}
private static func deviceId() -> String? {
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
?? NSHomeDirectory() + "/.kimi-code"
guard let data = FileManager.default.contents(atPath: home + "/device_id"),
let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty else { return nil }
return id
}
// MARK: - Fetch
static func refresh() async throws -> KimiUsage {
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
let token = try freshToken()
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
// Kimi server expects these platform headers (same as CodexBar sends).
request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform")
if let deviceId = deviceId() {
request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
return try parseUsage(data: data)
} catch {
// Never log the body account data readable via `log stream`.
NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error))
throw FetchError.usageDecodeFailed
}
case 401, 403:
// We don't self-refresh; surface as terminal so the UI prompts
// the user to run the CLI.
throw FetchError.tokenExpired
case 429:
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
// MARK: - Decode (internal so tests can drive fixtures)
private struct LenientDouble: Decodable {
let value: Double?
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let n = try? c.decode(Double.self) { value = n }
else if let s = try? c.decode(String.self) { value = Double(s) }
else { value = nil }
}
}
private struct UsageEnvelopeDTO: Decodable {
let limit: LenientDouble?
let used: LenientDouble?
let remaining: LenientDouble?
let resetTime: LenientString?
let resetAt: LenientString?
let reset_time: LenientString?
let reset_at: LenientString?
var resetsAtRaw: String? {
resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value
}
}
/// Reset timestamps are normally ISO-8601 strings, but decode numbers
/// tolerantly too (epoch seconds) so a schema drift can't fail the whole
/// response. A plain String? would throw on a JSON number.
private struct LenientString: Decodable {
let value: String?
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let s = try? c.decode(String.self) { value = s }
else if let n = try? c.decode(Double.self) { value = String(n) }
else { value = nil }
}
}
private struct LimitDTO: Decodable {
let window: WindowDTO?
let detail: UsageEnvelopeDTO?
struct WindowDTO: Decodable {
let duration: LenientDouble?
let timeUnit: String?
}
}
private struct ResponseDTO: Decodable {
let usage: UsageEnvelopeDTO?
let limits: [LimitDTO]?
let user: UserDTO?
let parallel: ParallelDTO?
struct UserDTO: Decodable {
let membership: MembershipDTO?
struct MembershipDTO: Decodable {
let level: String?
}
}
struct ParallelDTO: Decodable {
let limit: LenientDouble?
}
}
static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage {
let root = try JSONDecoder().decode(ResponseDTO.self, from: data)
// The top-level usage envelope is the account's weekly quota (its
// reset lands ~7 days out), so label it like Claude's weekly window.
let primary = makeWindow(label: "Weekly", dto: root.usage)
let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in
guard let dto = limit.detail else { return nil }
let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit)
return makeWindow(label: label, dto: dto)
}
guard primary != nil || !details.isEmpty else {
throw FetchError.usageDecodeFailed
}
return KimiUsage(
primary: primary,
details: details,
plan: planName(from: root.user?.membership?.level),
parallelLimit: root.parallel?.limit?.value.map { Int($0) },
fetchedAt: now
)
}
/// "LEVEL_INTERMEDIATE" "Intermediate". Unknown / missing nil so the
/// UI falls back to the plain "Kimi Code" label.
private static func planName(from level: String?) -> String? {
guard var raw = level, !raw.isEmpty else { return nil }
if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) }
return raw.replacingOccurrences(of: "_", with: " ").capitalized
}
private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? {
guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil }
// Rate-limit windows report only limit + remaining derive used.
let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit))
return KimiUsage.Window(
label: label,
limit: limit,
used: used,
remaining: dto.remaining?.value,
resetsAt: dto.resetsAtRaw.flatMap(parseResetTime)
)
}
/// Window size human label. The API sends enum-style units
/// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"),
/// so normalize first; sub-hour durations roll up to hours when exact.
private static func windowLabel(duration: Double?, timeUnit: String?) -> String {
guard let duration, let rawUnit = timeUnit else { return "Rate Limit" }
var unit = rawUnit.lowercased()
if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) }
var d = duration
// Roll up exact sub-day durations: 300 minutes 5 hours.
if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 {
d /= 60; unit = "hour"
}
let i = Int(d)
switch unit {
case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min"
case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour"
case "day", "days":
if i == 1 { return "Daily" }
if i == 7 { return "Weekly" }
return "\(i)-day"
case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week"
case "month", "months": return i == 1 ? "Monthly" : "\(i)-month"
default: return "\(i) \(unit)"
}
}
/// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds.
private static func parseResetTime(_ raw: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: raw) { return date }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
if let date = plain.date(from: raw) { return date }
if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) }
return nil
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
static func disconnect() {
clearUsageBlock()
}
}

View file

@ -494,6 +494,7 @@ extension ProviderFilter {
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0)
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)

View file

@ -62,7 +62,7 @@ struct HeatmapSection: View {
// their own quota data sources.
InsightMode.allCases.filter { mode in
if mode == .plan {
return store.selectedProvider == .claude || store.selectedProvider == .codex
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode
}
return true
}
@ -80,6 +80,8 @@ struct HeatmapSection: View {
case .plan:
if store.selectedProvider == .codex {
CodexPlanInsight()
} else if store.selectedProvider == .kimiCode {
KimiPlanInsight()
} else {
PlanInsight(usage: store.subscription)
}
@ -2158,6 +2160,117 @@ private struct CodexPlanInsight: View {
}
}
/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no
/// OAuth refresh tokens are short-lived and only the CLI renews them), so
/// terminal failure means "run the CLI once to refresh your login".
private struct KimiPlanInsight: View {
@Environment(AppStore.self) private var store
var body: some View {
Group {
switch KimiQuotaPresentation.planContent(loadState: store.kimiLoadState, hasUsage: store.kimiUsage != nil) {
case .noCredentials:
PlanNoCredentialsView(
title: "No Kimi Code credentials found",
message: "Sign in with the Kimi CLI first. Then click Try Again."
) { Task { await store.bootstrapKimi() } }
case .loading:
PlanLoadingView(message: "Reading Kimi Code credentials...")
case .failed:
PlanFailedView(
error: store.kimiError
) { Task { await store.refreshKimi() } }
case .transientFailed:
PlanFailedView(
error: store.kimiError ?? "Kimi temporarily unreachable — retrying."
) { Task { await store.refreshKimi() } }
case let .reconnect(reason):
PlanReconnectView(
title: "Refresh Kimi Code login",
reason: reason,
fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect."
) { Task { await store.bootstrapKimi() } }
case let .usage(idle):
if let usage = store.kimiUsage {
loadedBody(usage: usage, idle: idle)
} else {
PlanLoadingView(message: "Reading Kimi Code credentials...")
}
}
}
}
@ViewBuilder
private func loadedBody(usage: KimiUsage, idle: Bool) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(usage.plan ?? "Kimi Code")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = usage.primary?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if let primary = usage.primary {
UtilizationRow(
label: "\(primary.label) window",
percent: primary.usedPercent,
resetsAt: primary.resetsAt,
projection: nil
)
}
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
UtilizationRow(
label: "\(window.label) window",
percent: window.usedPercent,
resetsAt: window.resetsAt,
projection: nil
)
}
if let parallel = usage.parallelLimit, parallel > 0 {
HStack(alignment: .firstTextBaseline) {
Text("Parallel sessions")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text("\(parallel)")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if idle {
Text("Login idle. Run the Kimi CLI to refresh.")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
if KimiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
Text("as of \(shortTime(usage.fetchedAt))")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 8)
}
private func relativeReset(_ date: Date) -> String {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
private func shortTime(_ date: Date) -> String {
let f = DateFormatter()
f.timeStyle = .short
f.dateStyle = .none
return f.string(from: date)
}
}
private struct WindowProjection {
enum Source { case linear, historicalBaseline }
let percent: Double

View file

@ -92,6 +92,10 @@ struct MenuBarContent: View {
private var isFilteredEmpty: Bool {
guard store.selectedProvider != .all else { return false }
// Plan-capable providers keep their sections visible so the Plan tab
// (live subscription quota) stays reachable even on days with no
// local usage the quota endpoint doesn't depend on local sessions.
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false }
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
if providerHasCostInAllPayload { return false }
return true

View file

@ -21,6 +21,10 @@ struct SettingsView: View {
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
.tag("codex")
KimiSettingsTab()
.tabItem { Label("Kimi", systemImage: "moon.stars") }
.tag("kimi")
DevinSettingsTab()
.tabItem { Label("Devin", systemImage: "flame.fill") }
.tag("devin")
@ -29,7 +33,9 @@ struct SettingsView: View {
.tabItem { Label("About", systemImage: "info.circle") }
.tag("about")
}
.frame(width: 520, height: 430)
// 6 tabs need ~600pt to render as a visible tab bar; narrower widths
// make SwiftUI collapse the tab bar into a ">>" overflow menu.
.frame(width: 600, height: 430)
}
}
@ -606,6 +612,131 @@ private struct CodexConnectionRow: View {
}
}
// MARK: - Kimi Code
private struct KimiSettingsTab: View {
var body: some View {
Form {
Section("Connection") {
KimiConnectionRow()
}
Section {
Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
}
}
private struct KimiConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.kimiLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.kimiLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.kimiLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Login refresh required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Kimi quota"
}
}
private var stateDetail: String {
switch store.kimiLoadState {
case .loaded:
return "Live quota tracked from api.kimi.com."
case .terminalFailure:
return "Run the Kimi CLI once to refresh your login, then click Reconnect."
case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.kimi-code credentials."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com."
case .notBootstrapped, .noCredentials:
return "Sign in with the Kimi CLI first, then click Connect."
case .failed: return store.kimiError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.kimiLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Kimi Code?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectKimi()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Devin
private struct DevinSettingsTab: View {

View file

@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
/// Kimi Code tokens live ~15 min and only the CLI renews them, so
/// `.terminalFailure` is the dominant steady state between CLI uses. These
/// tests pin the display decision: a terminal login with a snapshot on hand
/// must keep showing the bars (with a quiet idle caption), and only the
/// no-data case falls through to the reconnect screen.
@Suite("Kimi quota presentation")
struct KimiQuotaPresentationTests {
typealias Presentation = KimiQuotaPresentation
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
func terminalWithUsageShowsIdleUsage() {
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: true)
#expect(content == .usage(idle: true))
}
@Test("terminal failure with no snapshot falls through to reconnect")
func terminalWithoutUsageShowsReconnect() {
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: false)
#expect(content == .reconnect(reason: "expired"))
}
@Test("loaded with a snapshot shows usage without the idle caption")
func loadedShowsPlainUsage() {
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
}
@Test("transient failure keeps the last snapshot, else shows the retry screen")
func transientFailureFallsBackToUsage() {
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
}
@Test("credential-absent states route to the connect prompt")
func credentialStatesRouteToNoCredentials() {
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
}
@Test("dormant and bootstrapping render the loading state")
func dormantAndBootstrappingLoad() {
#expect(Presentation.planContent(loadState: .dormant, hasUsage: false) == .loading)
#expect(Presentation.planContent(loadState: .bootstrapping, hasUsage: false) == .loading)
}
@Test("loading with a snapshot shows usage; without one, the loading state")
func loadingPrefersExistingSnapshot() {
#expect(Presentation.planContent(loadState: .loading, hasUsage: true) == .usage(idle: false))
#expect(Presentation.planContent(loadState: .loading, hasUsage: false) == .loading)
}
@Test("a fresh snapshot is not stamped stale")
func freshSnapshotIsNotStale() {
let now = Date()
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
}
@Test("a snapshot older than the threshold is stamped stale")
func oldSnapshotIsStale() {
let now = Date()
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
}
}

View file

@ -0,0 +1,114 @@
import XCTest
@testable import CodeBurnMenubar
/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response.
/// The API has shipped numbers as both JSON numbers and strings, and the
/// reset timestamp under several key spellings (resetTime / reset_at / ...),
/// so the parser must tolerate all of them.
final class KimiUsageParsingTests: XCTestCase {
func testParsesNumericShapeWithResetTime() throws {
let json = """
{
"usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"},
"limits": [
{"window": {"duration": 5, "timeUnit": "hour"},
"detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 100)
XCTAssertEqual(usage.primary?.used, 40)
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001)
XCTAssertEqual(usage.primary?.remaining, 60)
XCTAssertNotNil(usage.primary?.resetsAt)
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001)
}
func testParsesStringNumbersAndSnakeCaseReset() throws {
let json = """
{
"usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"},
"limits": []
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 500)
XCTAssertEqual(usage.primary?.used, 123)
XCTAssertNotNil(usage.primary?.resetsAt)
}
func testWeeklyWindowLabel() throws {
let json = """
{
"limits": [
{"window": {"duration": 7, "timeUnit": "day"},
"detail": {"limit": 1000, "used": 250}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertNil(usage.primary)
XCTAssertEqual(usage.details.first?.label, "Weekly")
}
func testEpochResetTime() throws {
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testNumericEpochResetTime() throws {
// A JSON number (not string) must not fail the whole decode.
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testLiveResponseShape() {
// Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23).
let json = """
{
"user": {"userId": "x", "region": "REGION_OVERSEA",
"membership": {"level": "LEVEL_INTERMEDIATE"}},
"usage": {"limit": "100", "used": "5", "remaining": "95",
"resetTime": "2026-07-30T13:27:17.211180Z"},
"limits": [
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
"detail": {"limit": "100", "remaining": "100",
"resetTime": "2026-07-23T23:27:17.211180Z"}}
],
"parallel": {"limit": "20"}
}
""".data(using: .utf8)!
let usage = try! KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.plan, "Intermediate")
XCTAssertEqual(usage.parallelLimit, 20)
XCTAssertEqual(usage.primary?.label, "Weekly")
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001)
// 300 minutes rolls up to a 5-hour label; used derives from remaining.
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001)
XCTAssertNotNil(usage.details.first?.resetsAt)
}
func testEmptyEnvelopeThrows() {
let json = "{}".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
func testZeroLimitWindowDropped() throws {
let json = """
{"usage": {"limit": 0, "used": 0}, "limits": []}
""".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
}

View file

@ -83,6 +83,7 @@ const PROVIDER_COLORS: Record<string, string> = {
opencode: '#A78BFA',
pi: '#F472B6',
kimi: '#B6E34A',
kimicode: '#A3E635',
all: '#FF8C42',
}
@ -670,6 +671,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
opencode: 'OpenCode',
pi: 'Pi',
kimi: 'Kimi',
kimicode: 'Kimi Code',
}
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }

View file

@ -84,6 +84,7 @@ export type ProviderCost = {
import type { OptimizeResult } from './optimize.js'
import { getCurrency } from './currency.js'
import type { GranularHistory } from './granular-history.js'
import { getShortModelName } from './models.js'
import type { ReworkedFile } from './workflow-insights.js'
import type { PrRow, BranchRow } from './sessions-report.js'
@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa
}
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
// Day entries key models by the raw provider id (day-aggregator), so resolve
// display names here — the menubar shows "Kimi K3" rather than "k3". Ids that
// collapse to one display name (e.g. k3 and kimi-k3) merge into a single row.
const merged = new Map<string, { cost: number; calls: number; savingsUSD: number; estimatedCostUSD: number }>()
for (const m of models) {
if (m.name === SYNTHETIC_MODEL_NAME) continue
const name = getShortModelName(m.name)
const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 }
acc.cost += m.cost
acc.calls += m.calls
acc.savingsUSD += m.savingsUSD ?? 0
acc.estimatedCostUSD += m.estimatedCostUSD ?? 0
merged.set(name, acc)
}
return [...merged.entries()]
.sort(([, a], [, b]) => b.cost - a.cost)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
.map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {

View file

@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record<string, string> = {
'kimi-auto': 'kimi-k2-thinking',
'kimi-code': 'kimi-k2-thinking',
'kimi-for-coding': 'kimi-k2-thinking',
// Kimi Code wires report the bare `k3` id in llm.request.model; without an
// alias those calls priced at $0 and the provider looked absent in the UI.
'k3': 'kimi-k3',
// Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`.
'k3-agent': 'kimi-k3',
'k2d6-agent': 'kimi-k2p6',
'mimo-v2-flash': 'xiaomi/mimo-v2-flash',
'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro',
// Cursor emits dot-version tier-last names plus tier/reasoning suffixes
@ -879,6 +885,8 @@ const SHORT_NAMES: Record<string, string> = {
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo',
'kimi-k2-thinking': 'Kimi K2 Thinking',
'kimi-k3': 'Kimi K3',
'kimi-k2p6': 'Kimi K2.6',
'kimi-thinking-preview': 'Kimi Thinking',
'kimi-k2.6': 'Kimi K2.6',
'kimi-k2.5': 'Kimi K2.5',

View file

@ -71,8 +71,18 @@ function timestampIso(value: unknown): string {
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}
function kimicodeHome(override?: string): string {
return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code'))
function kimicodeHomes(override?: string): string[] {
const explicit = override || process.env['KIMI_CODE_HOME']
if (explicit) return [resolve(explicit)]
// Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep
// the same wire layout under their own home (Kimi desktop app, Kimi Code
// IDE); each home is scanned so embedded-agent usage is not invisible.
const home = homedir()
const homes = [
join(home, '.kimi-code'),
join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'),
]
return [...new Set(homes.map(h => resolve(h)))]
}
async function directoryEntries(path: string) {
@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise<SessionSource[]> {
const workDirPath = join(sessionsDir, workDirEntry.name)
for (const sessionEntry of await directoryEntries(workDirPath)) {
if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue
// Session dir naming differs by host product: the CLI uses session_*,
// embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory
// is accepted; the agents/*/wire.jsonl probe below gates real sessions.
if (!sessionEntry.isDirectory()) continue
const sessionDir = join(workDirPath, sessionEntry.name)
const state = await readState(sessionDir)
const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name)
@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider {
},
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }]
return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' }))
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSources(kimicodeHome(homeOverride))
const all: SessionSource[] = []
for (const home of kimicodeHomes(homeOverride)) {
all.push(...await discoverSources(home))
}
return all.sort((a, b) => a.path.localeCompare(b.path))
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {

View file

@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => {
expect(payload.current.topModels[0].name).toBe('Model0')
})
it('resolves raw model ids to display names in topModels and merges rows that collapse', () => {
// Day entries key models by the raw wire id (day-aggregator); the menubar
// must show friendly names and merge ids that share one (k3 + kimi-k3).
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [],
models: [
{ name: 'k3', cost: 2.5, calls: 78 },
{ name: 'kimi-k3', cost: 0.5, calls: 2 },
{ name: 'kimi-for-coding', cost: 0.06, calls: 13 },
{ name: 'k3-agent', cost: 1.2, calls: 40 },
],
}
const payload = buildMenubarPayload(period, [], null)
const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')!
expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2)
expect(kimiK3.calls).toBe(78 + 2 + 40)
const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')!
expect(k2.cost).toBeCloseTo(0.06)
expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined()
expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined()
})
it('caps topActivities at 20 so all task categories can surface', () => {
const period: PeriodData = {
label: 'Today',

View file

@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => {
const local = payload.current.topModels.find(m => m.name === 'Local Model')!
expect(local.savingsUSD).toBe(5)
expect(local.cost).toBe(0)
const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')!
const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')!
expect(paid.savingsUSD).toBe(0)
})

View file

@ -672,6 +672,9 @@ describe('observed provider model aliases', () => {
const cases: Array<[string, string]> = [
['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'],
['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'],
// Kimi Code wires report bare `k3` in llm.request.model; it must price
// through the kimi-k3 table entry, not fall through to $0.
['k3', 'kimi-k3'],
]
for (const [input, expectedModel] of cases) {
@ -685,6 +688,10 @@ describe('observed provider model aliases', () => {
})
}
it('k3 shows the Kimi K3 display name', () => {
expect(getShortModelName('k3')).toBe('Kimi K3')
})
it('does not map dated Qwen3 Max to a reseller price without provider context', () => {
expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull()
expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)

View file

@ -167,6 +167,40 @@ describe('Kimi Code provider', () => {
expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true)
})
it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => {
// Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs
// conv-*/ctitle-* instead of session_*; their wires carry the same event
// format and must be discovered and priced the same way.
for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) {
const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main')
await mkdir(agentDir, { recursive: true })
await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({
createdAt: '2026-07-01T10:00:00.000Z',
updatedAt: '2026-07-01T10:05:00.000Z',
workDir: '/workspace/neutral-project',
}))
await writeFile(join(agentDir, 'wire.jsonl'), [
JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }),
JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)),
JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)),
JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })),
'',
].join('\n'))
}
const provider = createKimicodeProvider(fixtureHome)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(2)
expect(sources.every(source => source.project === 'neutral-project')).toBe(true)
const seen = new Set<string>()
const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat()
expect(calls).toHaveLength(2)
expect(calls.every(call => call.model === 'k3-agent')).toBe(true)
// k3-agent prices through the kimi-k3 alias, never $0.
expect(calls.every(call => call.costUSD > 0)).toBe(true)
})
it('parses single-turn usage with the real model id and estimated token pricing', async () => {
const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [
prompt('Summarize the neutral module.', 1782900000000),