Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Added
- **CodeBurn tells you when OpenAI banks a limit reset on your Codex account.** These grants — the "banked" or "goodwill" resets that restore a rate-limit window early — are sometimes announced and sometimes not, and until now the only way to notice one was to go looking. The reset-credit inventory CodeBurn already reads on every Codex quota refresh is now compared against the previous reading: a credit seen for the first time posts one notification naming what was granted, when it landed and how many you have available to use. The first reading after connecting is a baseline, not news; a credit that disappears because you spent it says nothing; a failed fetch or a malformed payload is treated as no opinion rather than as an empty account, so reconnecting does not re-announce what you were already told; and the fired event is persisted next to the existing quota snapshots, so a relaunch does not repeat it. Settings → Notifications gains a switch for it, on by default. The count and the most recent grant also appear in the Codex Plan tab, in the agent-tab quota hover card, and as a `Limit resets · …` line in `codeburn quota` (text and `--format json`), worded identically on both sides. No new endpoint, no new polling loop and no new data source: this reads fields off a response already fetched on the existing cadence, per the rule #702/#724 established. CodeBurn never spends a credit — this is a notice only. (#725)
- **Kimi Code sessions now show up in the live-sessions block.** A Kimi session whose `agents/*/wire.jsonl` was written inside the liveness window is reported as `kimicode`, with its project, the model of its last request and the activity of its sub-agents folded in.

### Added (macOS)
- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 628 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages; Settings > General > Language overrides it for CodeBurn alone by writing `AppleLanguages` into the app's own preferences domain, which is the same key `CFBundleLocalizations` makes System Settings > Language & Region > Applications write, so the two surfaces are one setting rather than two. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own short symbols clipped to two units, so a Chinese UI reads `2026年9月` and `周一 周二` while English keeps `Mo Tu We`. That locale move is the one place English output changes: `EEE MMM d` reads `Sat, Sep 12` in en_US and `Sat 12 Sep` in en_GB, and `MMM d` reads `12 Sep` in en_GB. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219)
Expand Down
83 changes: 81 additions & 2 deletions src/live-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
/// the optional `liveSessions` block of the menubar payload; the app renders
/// only what it finds here.
import { open, readdir, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
import { kimicodeHomes, projectFromWorkDir, readState as readKimicodeState } from './providers/kimicode.js'
import { reportedContextWindow } from './context-tree.js'
import { getShortModelName } from './models.js'
import type { ApiUsage, AssistantMessageContent, JournalEntry } from './types.js'
Expand Down Expand Up @@ -292,7 +293,85 @@ export async function collectLiveSessionInputs(
parent.subagentActivityMs.push(sidechain.mtimeMs)
}

return [...inputs.values()]
return [...inputs.values(), ...await collectKimicodeInputs(nowMs, windowMs)]
}

/// Last model the session actually asked for. Tail-only, like the Claude
/// scanner: a running wire file reaches tens of MB and only the end matters.
async function kimicodeModel(wirePath: string): Promise<string | null> {
let text = ''
try {
text = await readTail(wirePath, TAIL_BYTES)
} catch {
return null
}
const lines = text.split('\n')
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]
if (!line || !line.trim()) continue
let record: { type?: unknown; model?: unknown }
try {
record = JSON.parse(line) as { type?: unknown; model?: unknown }
} catch {
continue
}
if (record.type !== 'llm.request') continue
if (typeof record.model === 'string' && record.model) return record.model
}
return null
}

/// Kimi Code keeps a directory per session, the session's own turns in
/// `agents/main/wire.jsonl` and every sub-agent in a sibling agent directory.
/// A missing or unreadable store is silent: no Kimi install means no rows.
export async function collectKimicodeInputs(
nowMs: number,
windowMs: number,
roots: string[] = kimicodeHomes(),
): Promise<LiveSessionInput[]> {
const paths: string[] = []
for (const root of roots) {
const entries = await readdir(join(root, 'sessions'), { recursive: true, withFileTypes: true }).catch(() => [])
for (const entry of entries) {
if (entry.isFile() && entry.name === 'wire.jsonl') paths.push(join(entry.parentPath, entry.name))
}
}

const bySession = new Map<string, { mainMs: number; subagentMs: number[] }>()
await Promise.all(paths.map(async path => {
const info = await stat(path).catch(() => null)
if (!info?.isFile()) return
const sessionDir = dirname(dirname(dirname(path)))
const agents = bySession.get(sessionDir) ?? { mainMs: 0, subagentMs: [] }
if (basename(dirname(path)) === 'main') agents.mainMs = info.mtimeMs
else if (nowMs - info.mtimeMs <= windowMs) agents.subagentMs.push(info.mtimeMs)
bySession.set(sessionDir, agents)
}))

const inputs: LiveSessionInput[] = []
for (const [sessionDir, agents] of bySession) {
const mainIsLive = agents.mainMs > 0 && nowMs - agents.mainMs <= windowMs
if (!mainIsLive && agents.subagentMs.length === 0) continue
const state = await readKimicodeState(sessionDir)
// `createdAt` is absent on stores that never wrote it; the state file itself
// is created with the session, so its birthtime is the same moment.
const birthtimeMs = state.createdAtMs
? 0
: (await stat(join(sessionDir, 'state.json')).catch(() => null))?.birthtimeMs ?? 0
inputs.push({
id: basename(sessionDir).replace(/^session_/, ''),
provider: 'kimicode',
project: projectFromWorkDir(state.cwd || state.workDir || '', basename(dirname(sessionDir))),
branch: null,
model: await kimicodeModel(join(sessionDir, 'agents', 'main', 'wire.jsonl')),
contextTokens: null,
contextWindow: null,
startedMs: state.createdAtMs || birthtimeMs,
lastActivityMs: agents.mainMs,
subagentActivityMs: agents.subagentMs,
})
}
return inputs
}

export async function collectLiveSessions(
Expand Down
12 changes: 9 additions & 3 deletions src/providers/kimicode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ type JsonObject = Record<string, unknown>

type SessionState = {
createdAt?: string
/// Epoch-ms `createdAt`, which is how the CLI store writes it (the string
/// form above belongs to other hosts).
createdAtMs?: number
cwd?: string
updatedAt?: string
workDir?: string
/// Map of agent name -> descriptor. Carries the `parentAgentId` field
Expand Down Expand Up @@ -78,7 +82,7 @@ function timestampIso(value: unknown): string {
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}

function kimicodeHomes(override?: string): string[] {
export 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
Expand Down Expand Up @@ -108,7 +112,7 @@ async function isFile(path: string): Promise<boolean> {
}
}

async function readState(sessionDir: string): Promise<SessionState> {
export async function readState(sessionDir: string): Promise<SessionState> {
try {
const state = asObject(JSON.parse(await readFile(join(sessionDir, 'state.json'), 'utf8')))
if (!state) return {}
Expand All @@ -125,6 +129,8 @@ async function readState(sessionDir: string): Promise<SessionState> {
}
return {
createdAt: stringValue(state['createdAt']) || undefined,
createdAtMs: nonNegativeNumber(state['createdAt']) || undefined,
cwd: stringValue(state['cwd']) || undefined,
updatedAt: stringValue(state['updatedAt']) || undefined,
workDir: stringValue(state['workDir']) || undefined,
...(agentsMap ? { agents: agentsMap } : {}),
Expand All @@ -134,7 +140,7 @@ async function readState(sessionDir: string): Promise<SessionState> {
}
}

function projectFromWorkDir(workDir: string, workDirKey: string): string {
export function projectFromWorkDir(workDir: string, workDirKey: string): string {
if (workDir) return basename(workDir.replace(/[\\/]+$/, '')) || workDir
const match = /^wd_(.+)_[a-f0-9]{12}$/i.exec(workDirKey)
return match?.[1] || workDirKey.replace(/^wd_/, '') || 'kimicode'
Expand Down
90 changes: 88 additions & 2 deletions tests/live-sessions.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { mkdtemp, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { LIVE_WINDOW_SECONDS, TAIL_BYTES, buildLiveSessions, scanTranscript, type LiveSessionInput } from '../src/live-sessions.js'
import { LIVE_WINDOW_SECONDS, TAIL_BYTES, buildLiveSessions, collectKimicodeInputs, scanTranscript, type LiveSessionInput } from '../src/live-sessions.js'

const NOW = Date.parse('2026-09-01T12:00:00.000Z')

Expand Down Expand Up @@ -180,3 +180,89 @@ describe('scanTranscript', () => {
expect((await scanTranscript(join(dir, 'missing.jsonl'))).contextTokens).toBeNull()
})
})

describe('collectKimicodeInputs', () => {
const WINDOW_MS = LIVE_WINDOW_SECONDS * 1000

async function wire(sessionDir: string, agent: string, mtimeMs: number, lines: unknown[] = []): Promise<void> {
const dir = join(sessionDir, 'agents', agent)
await mkdir(dir, { recursive: true })
const path = join(dir, 'wire.jsonl')
await writeFile(path, lines.map(l => JSON.stringify(l)).join('\n'))
await utimes(path, new Date(mtimeMs), new Date(mtimeMs))
}

async function session(root: string, workDirKey: string, name: string, state: string): Promise<string> {
const dir = join(root, 'sessions', workDirKey, name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'state.json'), state)
return dir
}

async function store(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'kimi-live-'))
const live = await session(root, 'wd_atlas_aaaaaaaaaaaa', 'session_live-1', JSON.stringify({
id: 'session_live-1',
cwd: '/Users/x/Projects/atlas',
createdAt: NOW - 3_600_000,
}))
await wire(live, 'main', NOW - 30_000, [
{ type: 'llm.request', model: 'k2', modelAlias: 'kimi-code/k2', time: NOW - 120_000 },
{ type: 'llm.request', model: 'k3', modelAlias: 'kimi-code/k3', time: NOW - 30_000 },
{ type: 'usage.record', model: 'kimi-code/k3', usage: { output: 12 }, time: NOW - 30_000 },
])
await wire(live, 'agent-0', NOW - 5_000, [{ type: 'llm.request', model: 'k3' }])

const stale = await session(root, 'wd_atlas_aaaaaaaaaaaa', 'session_stale-1', JSON.stringify({ cwd: '/Users/x/Projects/atlas' }))
await wire(stale, 'main', NOW - 3_600_000, [{ type: 'llm.request', model: 'k3' }])

const broken = await session(root, 'wd_doors_bbbbbbbbbbbb', 'session_broken-1', '{not json')
await wire(broken, 'main', NOW - 3_600_000, [{ type: 'llm.request', model: 'k3' }])
return root
}

it('reports only the session whose wire was touched inside the window', async () => {
const inputs = await collectKimicodeInputs(NOW, WINDOW_MS, [await store()])
expect(inputs).toHaveLength(1)
expect(inputs[0]).toMatchObject({
id: 'live-1',
provider: 'kimicode',
project: 'atlas',
branch: null,
model: 'k3',
contextTokens: null,
contextWindow: null,
startedMs: NOW - 3_600_000,
lastActivityMs: NOW - 30_000,
subagentActivityMs: [NOW - 5_000],
})
// The sub-agent wrote more recently than the session itself, so it is the
// session's last activity.
expect(buildLiveSessions(inputs, NOW, LIVE_WINDOW_SECONDS).sessions[0]!.idleSeconds).toBe(5)
})

it('keeps a session whose own wire went quiet while a sub-agent runs', async () => {
const root = await mkdtemp(join(tmpdir(), 'kimi-live-'))
const dir = await session(root, 'wd_atlas_aaaaaaaaaaaa', 'session_delegating', JSON.stringify({ cwd: '/Users/x/atlas' }))
await wire(dir, 'main', NOW - 3_600_000, [{ type: 'llm.request', model: 'k3' }])
await wire(dir, 'agent-1', NOW - 9_000)
const inputs = await collectKimicodeInputs(NOW, WINDOW_MS, [root])
expect(inputs.map(i => i.id)).toEqual(['delegating'])
expect(buildLiveSessions(inputs, NOW, LIVE_WINDOW_SECONDS).sessions).toHaveLength(1)
})

it('falls back to the work-dir key when state.json is unreadable', async () => {
const root = await mkdtemp(join(tmpdir(), 'kimi-live-'))
const dir = await session(root, 'wd_doors_bbbbbbbbbbbb', 'session_nostate', '{not json')
await wire(dir, 'main', NOW - 20_000, [{ type: 'llm.request', model: 'k3' }])
const inputs = await collectKimicodeInputs(NOW, WINDOW_MS, [root])
expect(inputs).toHaveLength(1)
expect(inputs[0]).toMatchObject({ project: 'doors', model: 'k3' })
expect(inputs[0]!.startedMs).toBeGreaterThan(0)
})

it('stays silent on a store that is not there', async () => {
const root = await mkdtemp(join(tmpdir(), 'kimi-live-'))
expect(await collectKimicodeInputs(NOW, WINDOW_MS, [join(root, 'missing')])).toEqual([])
})
})
Loading