diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md index 8030088..a92356b 100644 --- a/docs/providers/kimicode.md +++ b/docs/providers/kimicode.md @@ -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_*// ├── state.json └── agents//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 diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index fb3edbb..32c9cdd 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -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" diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffa..489af5f 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -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 } diff --git a/mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift b/mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift new file mode 100644 index 0000000..10aacd9 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/KimiQuotaPresentation.swift @@ -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