kimicode: discover desktop-runtime sessions and fix menubar visibility

- Resolve k3/k3-agent/k2d6-agent model aliases to canonical Kimi names
- Discover sessions across all Kimi Code homes (CLI + desktop runtime)
- Accept conv-*/ctitle-* session directory naming, not just session_*
- Add Kimi Code provider tab with brand color to the menubar
- Show short model names (Kimi K3, Kimi K2.6) in the menubar payload
This commit is contained in:
reviewer 2026-07-23 21:31:25 +02:00
parent 2f8e5bddcd
commit be5c0c00aa
10 changed files with 129 additions and 11 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

@ -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

@ -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),