mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
live-sessions: report running Kimi Code sessions
Scan the Kimi Code stores for session dirs whose agents/*/wire.jsonl was written inside the liveness window and fold them into the menubar liveSessions block as provider kimicode, with the project from state.cwd, the model from the last llm.request in the main wire tail, and sub-agent wire mtimes as the session's sub-agent activity.
This commit is contained in:
parent
68ce480ec1
commit
66a1d30422
4 changed files with 179 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
@ -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…
Add table
Add a link
Reference in a new issue