diff --git a/bridge/package-lock.json b/bridge/package-lock.json index ed38849..7b2ad4b 100644 --- a/bridge/package-lock.json +++ b/bridge/package-lock.json @@ -1,12 +1,12 @@ { "name": "ftown-bridge", - "version": "0.19.1", + "version": "0.19.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ftown-bridge", - "version": "0.19.1", + "version": "0.19.2", "license": "MIT", "dependencies": { "@xterm/addon-serialize": "^0.14.0", diff --git a/bridge/package.json b/bridge/package.json index a123a28..5755268 100644 --- a/bridge/package.json +++ b/bridge/package.json @@ -1,6 +1,6 @@ { "name": "ftown-bridge", - "version": "0.19.1", + "version": "0.19.2", "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay", "type": "module", "main": "dist/index.js", diff --git a/bridge/src/agent-commands.ts b/bridge/src/agent-commands.ts index 84950e5..119787c 100644 --- a/bridge/src/agent-commands.ts +++ b/bridge/src/agent-commands.ts @@ -10,6 +10,7 @@ export { buildCursorAgentCommand, buildCodexCommand, buildGrokCommand, + buildKimiCodeCommand, } from './harness-registry.js'; export interface BuildSessionCommandInput { @@ -22,6 +23,8 @@ export interface BuildSessionCommandInput { command?: string; /** Initial prompt passed as a CLI argument — avoids racing the TUI with typed input. */ initialPrompt?: string; + /** Resurrection resume — workdir-based harnesses (kimi-code) append their continue flag. */ + resume?: boolean; } export function buildSessionCommand(input: BuildSessionCommandInput): string { diff --git a/bridge/src/create-ftown-session.test.ts b/bridge/src/create-ftown-session.test.ts index 461bb67..b1dcf40 100644 --- a/bridge/src/create-ftown-session.test.ts +++ b/bridge/src/create-ftown-session.test.ts @@ -581,6 +581,21 @@ describe('deriveRelaunchCommand — single home of the relaunch heuristic', () = assert.deepEqual(derived, { command: 'my-wrapper --flag', isCustom: true }); }); + it('rebuilds a builder-default kimi-code stored command into the -c resume command (workdir-based)', () => { + const kimiStored = { + shellType: 'kimi-code' as const, + workingDir: '/tmp/work', + model: undefined, + claudeSessionId: undefined, + cursorSessionId: undefined, + codexSessionId: undefined, + }; + const kimiDefault = buildSessionCommand({ shellType: 'kimi-code' }); + const derived = deriveRelaunchCommand({ ...kimiStored, command: kimiDefault }); + assert.strictEqual(derived.isCustom, false); + assert.match(derived.command, /--yolo -c$/); + }); + it('KNOWN LIMITATION: a pre-model-fix claude session (stored command lacks --model) is misclassified as custom and relaunched without --model or --resume', () => { const derived = deriveRelaunchCommand({ ...stored, @@ -604,6 +619,11 @@ describe('canResumeStoredSession — which stored sessions can resume', () => { assert.strictEqual(canResumeStoredSession({ shellType: 'codex', claudeSessionId: 'c' }), false); }); + it('resumes kimi-code by working directory — no recorded id required', () => { + assert.strictEqual(canResumeStoredSession({ shellType: 'kimi-code' }), true); + assert.strictEqual(canResumeStoredSession({ shellType: 'kimi-code', claudeSessionId: ' ' }), true); + }); + it('never resumes plain shells, opencode, or sessions with no recorded id', () => { assert.strictEqual(canResumeStoredSession({ shellType: 'shell', claudeSessionId: 'c' }), false); assert.strictEqual(canResumeStoredSession({ shellType: 'opencode', claudeSessionId: 'c' }), false); diff --git a/bridge/src/create-ftown-session.ts b/bridge/src/create-ftown-session.ts index 080be99..7d67af7 100644 --- a/bridge/src/create-ftown-session.ts +++ b/bridge/src/create-ftown-session.ts @@ -286,6 +286,9 @@ export function deriveRelaunchCommand(session: RelaunchCommandSource): { claudeSessionId: session.claudeSessionId, cursorSessionId: session.cursorSessionId, codexSessionId: session.codexSessionId, + // Workdir-based resume (kimi-code `-c`): no id to carry, so signal resume + // explicitly. Id-based harnesses ignore this and key off their id fields. + resume: true, }); const isCustom = Boolean(session.command) && @@ -301,6 +304,9 @@ export function canResumeStoredSession( const shellType = session.shellType ?? 'claude'; if (shellType === 'cursor') return Boolean(session.cursorSessionId?.trim()); if (shellType === 'codex') return Boolean(session.codexSessionId?.trim()); + // kimi-code resumes by working directory (`-c`), so it needs no captured + // session id — a stored kimi-code session is always resumable on restart. + if (shellType === 'kimi-code') return true; return shellType !== 'shell' && shellType !== 'opencode' && Boolean(session.claudeSessionId?.trim()); } diff --git a/bridge/src/ftown-sessions-cli.ts b/bridge/src/ftown-sessions-cli.ts index e707538..41e5cfb 100644 --- a/bridge/src/ftown-sessions-cli.ts +++ b/bridge/src/ftown-sessions-cli.ts @@ -171,7 +171,7 @@ function parseLoopSchedule(args: string[], required: boolean): LoopSchedule | un function parseLoopHarness(raw: string | undefined): LoopHarness { const harness = (raw ?? 'claude') as LoopHarness; - if (!['claude', 'cursor', 'codex', 'grok', 'opencode', 'shell'].includes(harness)) { + if (!['claude', 'cursor', 'codex', 'grok', 'kimi-code', 'opencode', 'shell'].includes(harness)) { throw new Error(`Invalid --shell "${raw}"`); } return harness; diff --git a/bridge/src/harness-registry.test.ts b/bridge/src/harness-registry.test.ts index 2b86c16..d84ef3e 100644 --- a/bridge/src/harness-registry.test.ts +++ b/bridge/src/harness-registry.test.ts @@ -7,6 +7,7 @@ import { LOOP_HARNESS_TYPES, SHELL_TYPES, WORKFLOW_SHELLS, + buildKimiCodeCommand, harnessAcceptsPromptAsCliArg, isLoopHarness, isShellType, @@ -23,7 +24,7 @@ type Equals = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const _shellTypeIsRegistryKeys: Equals = true; const _loopHarnessUnion: Equals< LoopHarness, - 'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'opencode' + 'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'kimi-code' | 'opencode' > = true; const _workflowShellUnion: Equals< WorkflowShell, @@ -37,7 +38,7 @@ describe('harness registry', () => { it('contains exactly the historical ShellType set', () => { assert.deepEqual( [...SHELL_TYPES].sort(), - ['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'opencode', 'shell', 'zai'], + ['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'kimi-code', 'opencode', 'shell', 'zai'], ); }); @@ -85,7 +86,7 @@ describe('harness registry', () => { it('derives the historical LOOP_HARNESSES set', () => { assert.deepEqual( [...LOOP_HARNESS_TYPES].sort(), - ['claude', 'codex', 'cursor', 'grok', 'opencode', 'shell'], + ['claude', 'codex', 'cursor', 'grok', 'kimi-code', 'opencode', 'shell'], ); }); @@ -145,9 +146,50 @@ describe('harness registry', () => { assert.equal(harnessAcceptsPromptAsCliArg('opencode', {}), false); }); + it('kimi-code: never (interactive TUI takes no positional prompt)', () => { + assert.equal(harnessAcceptsPromptAsCliArg('kimi-code', {}), false); + assert.equal( + harnessAcceptsPromptAsCliArg('kimi-code', { + claudeSessionId: 'a', + cursorSessionId: 'b', + codexSessionId: 'c', + }), + false, + ); + }); + it('blank resume ids do not suppress the CLI arg (trim semantics preserved)', () => { assert.equal(harnessAcceptsPromptAsCliArg('claude', { claudeSessionId: ' ' }), true); assert.equal(harnessAcceptsPromptAsCliArg('cursor', { cursorSessionId: '' }), true); }); }); }); + +describe('buildKimiCodeCommand', () => { + const KIMI = '"$HOME/.kimi-code/bin/kimi"'; + + it('no-resume output is unchanged (frozen)', () => { + assert.equal(buildKimiCodeCommand({}), `${KIMI} --yolo`); + assert.equal(buildKimiCodeCommand({ model: 'k2' }), `${KIMI} --yolo -m 'k2'`); + }); + + it('resume appends -c after --yolo', () => { + assert.equal(buildKimiCodeCommand({ resume: true }), `${KIMI} --yolo -c`); + }); + + it('resume keeps the model so the resumed session retains it', () => { + assert.equal( + buildKimiCodeCommand({ resume: true, model: 'k2' }), + `${KIMI} --yolo -c -m 'k2'`, + ); + }); + + it('registry kimi-code entry threads the resume flag through', () => { + assert.equal(HARNESSES['kimi-code'].buildCommand({}), `${KIMI} --yolo`); + assert.equal(HARNESSES['kimi-code'].buildCommand({ resume: true }), `${KIMI} --yolo -c`); + assert.equal( + HARNESSES['kimi-code'].buildCommand({ resume: true, model: 'k2' }), + `${KIMI} --yolo -c -m 'k2'`, + ); + }); +}); diff --git a/bridge/src/harness-registry.ts b/bridge/src/harness-registry.ts index cad5910..77ef991 100644 --- a/bridge/src/harness-registry.ts +++ b/bridge/src/harness-registry.ts @@ -26,6 +26,12 @@ export interface BuildCommandInput { codexSessionId?: string; /** Initial prompt passed as a CLI argument — avoids racing the TUI with typed input. */ initialPrompt?: string; + /** + * Relaunch is a resurrection resume. Harnesses that resume by working + * directory rather than by a captured session id (kimi-code, via `-c`) + * consult this flag; id-based harnesses ignore it and use their id fields. + */ + resume?: boolean; } export interface HarnessSpec { @@ -131,6 +137,25 @@ export function buildGrokCommand(options: { return parts.join(' '); } +export function buildKimiCodeCommand(options: { model?: string; resume?: boolean }): string { + // Absolute path: the kimi-code installer adds ~/.kimi-code/bin to PATH only + // via .zshrc (interactive), but ftown launches agents with `zsh -l -c` + // (non-interactive login), which does NOT source .zshrc — so a bare `kimi` + // would fail to resolve. --yolo auto-approves all tool actions for unattended runs. + const parts = ['"$HOME/.kimi-code/bin/kimi"', '--yolo']; + // Resurrection resume: `-c` continues the previous session for the working + // directory. ftown launches each kimi-code session with a stable workingDir, + // so `-c` on relaunch resumes exactly the conversation that ran there — no + // captured session id needed. + if (options.resume) { + parts.push('-c'); + } + if (options.model?.trim()) { + parts.push('-m', shellQuote(options.model.trim())); + } + return parts.join(' '); +} + /** claude CLI launch — shared by 'claude' and every claude-rebadged provider flavor. */ function buildClaudeCommand(input: BuildCommandInput): string { const parts = ['claude', '--allow-dangerously-skip-permissions']; @@ -231,6 +256,14 @@ export const HARNESSES = { validForLoop: true, validForWorkflow: true, }, + 'kimi-code': { + // Own binary (absolute path); interactive TUI, no positional prompt. + buildCommand: (input) => buildKimiCodeCommand({ model: input.model, resume: input.resume }), + hooked: false, + promptAsCliArg: false, + validForLoop: true, + validForWorkflow: false, + }, zai: PROVIDER_FLAVOR_SPEC, kimi: PROVIDER_FLAVOR_SPEC, deepseek: PROVIDER_FLAVOR_SPEC, diff --git a/bridge/src/usage-collector.test.ts b/bridge/src/usage-collector.test.ts index 3cf2cdf..6a25f89 100644 --- a/bridge/src/usage-collector.test.ts +++ b/bridge/src/usage-collector.test.ts @@ -167,6 +167,178 @@ describe('collectSessionUsage — codex extractor', () => { }); }); +function kimiUsageRecord(model: string, usage: Record): string { + return JSON.stringify({ type: 'usage.record', model, usage, usageScope: 'turn', time: 1 }); +} + +async function writeKimiSession(opts: { + kimiCodeDir: string; + sessionDir: string; + workDir: string; + createdAt: string; + wireByAgent: Record; +}): Promise { + await mkdir(opts.sessionDir, { recursive: true }); + await writeFile( + join(opts.sessionDir, 'state.json'), + JSON.stringify({ createdAt: opts.createdAt, updatedAt: opts.createdAt, workDir: opts.workDir, agents: {} }), + ); + for (const [agent, lines] of Object.entries(opts.wireByAgent)) { + const agentDir = join(opts.sessionDir, 'agents', agent); + await mkdir(agentDir, { recursive: true }); + await writeFile(join(agentDir, 'wire.jsonl'), lines.join('\n') + '\n'); + } +} + +describe('collectSessionUsage — kimi-code extractor', () => { + const workingDir = '/Users/x/projects/demo'; + const sessionCreatedAt = '2026-07-17T12:00:00.000Z'; + + it('sums usage.record across agents, attributes per model, no costUsd', async () => { + const kimiCodeDir = join(root, 'kimi-sums'); + const sessionDir = join(kimiCodeDir, 'sessions', 'wd_demo_1', 'session_uuid-1'); + await mkdir(kimiCodeDir, { recursive: true }); + await writeFile( + join(kimiCodeDir, 'session_index.jsonl'), + JSON.stringify({ sessionId: 'session_uuid-1', sessionDir, workDir: workingDir }) + '\n' + + // duplicate append line for the same sessionDir — must be deduped + JSON.stringify({ sessionId: 'session_uuid-1', sessionDir, workDir: workingDir }) + '\n', + ); + await writeKimiSession({ + kimiCodeDir, + sessionDir, + workDir: workingDir, + createdAt: '2026-07-17T12:00:05.000Z', // >= session createdAt + wireByAgent: { + // 3 usage.record events, 2 models to exercise perModel + main: [ + JSON.stringify({ type: 'text', model: 'kimi-code/k3' }), + kimiUsageRecord('kimi-code/k3', { + inputOther: 100, output: 200, inputCacheRead: 1000, inputCacheCreation: 500, + }), + 'not json', + kimiUsageRecord('kimi-code/k3', { + inputOther: 10, output: 20, inputCacheRead: 5, inputCacheCreation: 0, + }), + kimiUsageRecord('kimi-code/k2', { + inputOther: 1000, output: 100, inputCacheRead: 200, inputCacheCreation: 400, + }), + ], + }, + }); + + const usage = await collectSessionUsage( + { shellType: 'kimi-code', workingDir, createdAt: sessionCreatedAt }, + { kimiCodeDir }, + ); + + assert.ok(usage); + assert.equal(usage.harness, 'kimi-code'); + assert.equal(usage.inputTokens, 1110); + assert.equal(usage.outputTokens, 320); + assert.equal(usage.cacheReadTokens, 1205); + assert.equal(usage.cacheWriteTokens, 900); + assert.equal(usage.totalTokens, 1110 + 320 + 1205 + 900); + assert.deepEqual(usage.models, ['kimi-code/k3', 'kimi-code/k2']); + assert.deepEqual(usage.perModel, [ + { + model: 'kimi-code/k3', + inputTokens: 110, + outputTokens: 220, + cacheReadTokens: 1005, + cacheWriteTokens: 500, + }, + { + model: 'kimi-code/k2', + inputTokens: 1000, + outputTokens: 100, + cacheReadTokens: 200, + cacheWriteTokens: 400, + }, + ]); + assert.ok(usage.collectedAt); + assert.ok(!('costUsd' in usage)); + }); + + it('sums main + sub-agent wire.jsonl for the session total', async () => { + const kimiCodeDir = join(root, 'kimi-multiagent'); + const sessionDir = join(kimiCodeDir, 'sessions', 'wd_ma_1', 'session_uuid-ma'); + await mkdir(kimiCodeDir, { recursive: true }); + await writeFile( + join(kimiCodeDir, 'session_index.jsonl'), + JSON.stringify({ sessionId: 'session_uuid-ma', sessionDir, workDir: workingDir }) + '\n', + ); + await writeKimiSession({ + kimiCodeDir, + sessionDir, + workDir: workingDir, + createdAt: '2026-07-17T12:00:01.000Z', + wireByAgent: { + main: [kimiUsageRecord('kimi-code/k3', { inputOther: 100, output: 10, inputCacheRead: 0, inputCacheCreation: 0 })], + 'agent-0': [kimiUsageRecord('kimi-code/k3', { inputOther: 5, output: 3, inputCacheRead: 0, inputCacheCreation: 0 })], + }, + }); + + const usage = await collectSessionUsage( + { shellType: 'kimi-code', workingDir, createdAt: sessionCreatedAt }, + { kimiCodeDir }, + ); + assert.ok(usage); + // main (100/10) + agent-0 (5/3) summed into one kimi-code/k3 model + assert.equal(usage.inputTokens, 105); + assert.equal(usage.outputTokens, 13); + assert.deepEqual(usage.models, ['kimi-code/k3']); + }); + + it('disambiguates two sessions in the same workDir by createdAt (>= session wins, newest)', async () => { + const kimiCodeDir = join(root, 'kimi-disambig'); + const olderDir = join(kimiCodeDir, 'sessions', 'wd_d_1', 'session_older'); + const newerDir = join(kimiCodeDir, 'sessions', 'wd_d_1', 'session_newer'); + await mkdir(kimiCodeDir, { recursive: true }); + await writeFile( + join(kimiCodeDir, 'session_index.jsonl'), + JSON.stringify({ sessionId: 'session_older', sessionDir: olderDir, workDir: workingDir }) + '\n' + + JSON.stringify({ sessionId: 'session_newer', sessionDir: newerDir, workDir: workingDir }) + '\n', + ); + // older kimi session created BEFORE the ftown session — not the spawn + await writeKimiSession({ + kimiCodeDir, + sessionDir: olderDir, + workDir: workingDir, + createdAt: '2026-07-17T11:00:00.000Z', + wireByAgent: { + main: [kimiUsageRecord('kimi-code/k3', { inputOther: 9, output: 9, inputCacheRead: 0, inputCacheCreation: 0 })], + }, + }); + // newer kimi session created AFTER the ftown session — the true spawn + await writeKimiSession({ + kimiCodeDir, + sessionDir: newerDir, + workDir: workingDir, + createdAt: '2026-07-17T12:00:03.000Z', + wireByAgent: { + main: [kimiUsageRecord('kimi-code/k3', { inputOther: 42, output: 7, inputCacheRead: 0, inputCacheCreation: 0 })], + }, + }); + + const usage = await collectSessionUsage( + { shellType: 'kimi-code', workingDir, createdAt: sessionCreatedAt }, + { kimiCodeDir }, + ); + assert.ok(usage); + assert.equal(usage.inputTokens, 42); // the createdAt-matched (newer) session + assert.equal(usage.outputTokens, 7); + }); + + it('returns null when the session index is missing', async () => { + const usage = await collectSessionUsage( + { shellType: 'kimi-code', workingDir, createdAt: sessionCreatedAt }, + { kimiCodeDir: join(root, 'kimi-missing') }, + ); + assert.equal(usage, null); + }); +}); + describe('collectSessionUsage — extractor routing', () => { it('prefers the claude extractor whenever claudeSessionId is present (provider flavors)', async () => { const claudeProjectsDir = join(root, 'routing'); diff --git a/bridge/src/usage-collector.ts b/bridge/src/usage-collector.ts index 84a562b..e1c1b63 100644 --- a/bridge/src/usage-collector.ts +++ b/bridge/src/usage-collector.ts @@ -1,5 +1,5 @@ import { createReadStream } from 'node:fs'; -import { readdir } from 'node:fs/promises'; +import { readFile, readdir } from 'node:fs/promises'; import { createInterface } from 'node:readline'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -13,8 +13,10 @@ import type { ModelUsage, Session, SessionUsage } from './types.js'; * not by shellType: provider flavors (zai/kimi/deepseek/fireworks) run the * claude CLI under the hood and get a claudeSessionId persisted by the hook * pipeline, so any session with a claudeSessionId uses the claude extractor - * regardless of shellType. Sessions with neither a claude nor a codex id - * (cursor, grok, plain shell) have no structured usage source and yield null. + * regardless of shellType. The kimi-code harness has no native session-id field + * on Session, so its extractor keys off workingDir + createdAt instead (see + * collectKimiCodeUsage). Sessions with none of these (cursor, grok, plain shell) + * have no structured usage source and yield null. * * TODO(opencode): add an opencode extractor once Session carries an * opencodeSessionId (no such field exists yet). @@ -27,13 +29,21 @@ import type { ModelUsage, Session, SessionUsage } from './types.js'; export type UsageSessionRef = Pick< Session, 'shellType' | 'claudeSessionId' | 'codexSessionId' | 'workingDir' ->; +> & + // createdAt is only consumed by the kimi-code extractor to disambiguate + // sessions sharing a workingDir; optional so non-kimi callers/tests need not set it. + Partial>; export interface UsageCollectorOptions { /** Override for tests. Default: ~/.claude/projects */ claudeProjectsDir?: string; /** Override for tests. Default: ~/.codex/sessions */ codexSessionsDir?: string; + /** + * Override for tests. Default: ~/.kimi-code. The kimi-code home dir holding + * session_index.jsonl and sessions/<...>/session_/. + */ + kimiCodeDir?: string; /** Cap on wall-clock read time per collection. Default 15s. */ timeoutMs?: number; } @@ -61,6 +71,9 @@ export async function collectSessionUsage( if (session.codexSessionId) { return await collectCodexUsage(session.codexSessionId, options); } + if (session.shellType === 'kimi-code' && session.workingDir) { + return await collectKimiCodeUsage(session.workingDir, session.createdAt, options); + } return null; } catch { return null; @@ -276,3 +289,154 @@ async function collectCodexUsage( collectedAt: new Date().toISOString(), }; } + +interface KimiIndexLine { + sessionId?: string; + sessionDir?: string; + workDir?: string; +} + +interface KimiUsageRecord { + type?: string; + model?: string; + usage?: { + inputOther?: number; + output?: number; + inputCacheRead?: number; + inputCacheCreation?: number; + }; +} + +/** + * Resolve the kimi-code session dir that this ftown session spawned. + * + * session_index.jsonl is an append log mapping workDir → sessionDir; multiple + * lines can share a workDir (successive runs in the same directory). Among the + * candidates matching this session's workingDir we pick by OWN creation time: + * each candidate's session dir has a state.json whose createdAt is when kimi + * started. The ftown session spawned kimi, so the true match is the newest + * candidate created at-or-after the ftown session — falling back to the newest + * overall when clock skew / a missing state.json leaves none at-or-after. + */ +async function resolveKimiSessionDir( + baseDir: string, + indexPath: string, + workingDir: string, + sessionCreatedAt: string | undefined, + deadline: number, +): Promise { + // Dedupe: the same sessionDir can appear on multiple index lines. + const dirs = new Set(); + for await (const raw of jsonlLines(indexPath, deadline)) { + const line = raw as KimiIndexLine; + if (line?.workDir === workingDir && typeof line.sessionDir === 'string') { + dirs.add(line.sessionDir); + } + } + if (dirs.size === 0) return null; + + // Read each candidate's own creation time from its state.json. + const candidates: Array<{ dir: string; createdAtMs: number }> = []; + for (const dir of dirs) { + try { + const parsed = JSON.parse(await readFile(join(dir, 'state.json'), 'utf8')) as { + createdAt?: string; + }; + const createdAtMs = parsed?.createdAt ? Date.parse(parsed.createdAt) : NaN; + candidates.push({ dir, createdAtMs: Number.isNaN(createdAtMs) ? 0 : createdAtMs }); + } catch { + // Missing / unparseable state.json — still a candidate, just undatable. + candidates.push({ dir, createdAtMs: 0 }); + } + } + + const sessionMs = sessionCreatedAt ? Date.parse(sessionCreatedAt) : NaN; + const newest = (list: Array<{ dir: string; createdAtMs: number }>) => + list.reduce((best, c) => (c.createdAtMs >= best.createdAtMs ? c : best)); + + if (!Number.isNaN(sessionMs)) { + const atOrAfter = candidates.filter((c) => c.createdAtMs >= sessionMs); + if (atOrAfter.length > 0) return newest(atOrAfter).dir; + } + return newest(candidates).dir; +} + +async function collectKimiCodeUsage( + workingDir: string, + sessionCreatedAt: string | undefined, + options: UsageCollectorOptions, +): Promise { + const baseDir = options.kimiCodeDir ?? join(homedir(), '.kimi-code'); + const indexPath = join(baseDir, 'session_index.jsonl'); + const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + + const sessionDir = await resolveKimiSessionDir( + baseDir, + indexPath, + workingDir, + sessionCreatedAt, + deadline, + ); + if (!sessionDir) return null; + + // Sum usage.record events across every agent's wire.jsonl (main + sub-agents). + const agentsDir = join(sessionDir, 'agents'); + let agentNames: string[]; + try { + const entries = await readdir(agentsDir, { withFileTypes: true }); + // Sort for deterministic per-model first-appearance order across agents. + agentNames = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(); + } catch { + return null; + } + + let counted = 0; + // Keyed by model id; Map preserves first-appearance order across agents. + const byModel = new Map(); + + for (const agent of agentNames) { + const wirePath = join(agentsDir, agent, 'wire.jsonl'); + for await (const raw of jsonlLines(wirePath, deadline)) { + const entry = raw as KimiUsageRecord; + if (entry?.type !== 'usage.record') continue; + const usage = entry.usage; + if (!usage) continue; + const model = entry.model ?? ''; + if (!model) continue; + + let acc = byModel.get(model); + if (!acc) { + acc = { model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; + byModel.set(model, acc); + } + // Per-turn incremental records — SUM across all of them. + acc.inputTokens += usage.inputOther ?? 0; + acc.outputTokens += usage.output ?? 0; + acc.cacheReadTokens += usage.inputCacheRead ?? 0; + acc.cacheWriteTokens += usage.inputCacheCreation ?? 0; + counted += 1; + } + } + + if (counted === 0) return null; + + const perModel = [...byModel.values()]; + const sum = (pick: (m: ModelUsage) => number): number => + perModel.reduce((acc, m) => acc + pick(m), 0); + const inputTokens = sum((m) => m.inputTokens); + const outputTokens = sum((m) => m.outputTokens); + const cacheReadTokens = sum((m) => m.cacheReadTokens); + const cacheWriteTokens = sum((m) => m.cacheWriteTokens); + + return { + inputTokens, + outputTokens, + cacheReadTokens, + cacheWriteTokens, + totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens, + models: perModel.map((m) => m.model), + perModel, + harness: 'kimi-code', + collectedAt: new Date().toISOString(), + }; +} diff --git a/bridge/src/wire-types.ts b/bridge/src/wire-types.ts index b7e9a57..8a8f683 100644 --- a/bridge/src/wire-types.ts +++ b/bridge/src/wire-types.ts @@ -75,7 +75,7 @@ export interface MailMessage { deliveredVia?: string; } -export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'opencode' | 'shell'; +export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'kimi-code' | 'opencode' | 'shell'; export type LoopSchedule = | { kind: 'interval'; everyMs: number } diff --git a/e2e/helpers/loops.ts b/e2e/helpers/loops.ts index f6b19a9..70e62f9 100644 --- a/e2e/helpers/loops.ts +++ b/e2e/helpers/loops.ts @@ -6,7 +6,7 @@ import { expect, type Page } from "@playwright/test"; * targeted by their placeholder / options (stable, user-visible anchors). */ -export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "opencode" | "shell"; +export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "kimi-code" | "opencode" | "shell"; export interface CreateLoopViaUiInput { name: string; diff --git a/ui/src/components/LoopFormModal.tsx b/ui/src/components/LoopFormModal.tsx index 43f5505..8fe5bac 100644 --- a/ui/src/components/LoopFormModal.tsx +++ b/ui/src/components/LoopFormModal.tsx @@ -462,6 +462,7 @@ export function LoopFormModal({ isOpen, onClose, onSubmit, bridges, editingLoop + diff --git a/ui/src/components/NewSessionModal.tsx b/ui/src/components/NewSessionModal.tsx index fac7aea..bdfc4da 100644 --- a/ui/src/components/NewSessionModal.tsx +++ b/ui/src/components/NewSessionModal.tsx @@ -77,6 +77,15 @@ const GROK_MODEL_OPTIONS = [ "grok-composer-2.5-fast", ] as const; +// kimi-code is a standalone CLI (its own binary), NOT the `kimi` provider-flavor. +// The default alias k3 is the CLI's own default, so its option value is empty — +// selecting it omits the -m flag entirely (matching the bridge command builder). +const KIMI_CODE_MODEL_OPTIONS: readonly { value: string; label: string }[] = [ + { value: "", label: "k3 (default)" }, + { value: "kimi-code/kimi-for-coding", label: "kimi-for-coding" }, + { value: "kimi-code/kimi-for-coding-highspeed", label: "kimi-for-coding-highspeed" }, +] as const; + const AUTO_COMPACT_WINDOW_KEY = "ftown:autoCompactWindow"; const FLAVOR_AUTO_COMPACT_DEFAULTS: Record<"standard" | "zai" | "kimi" | "deepseek" | "fireworks", string> = { @@ -218,6 +227,7 @@ const VALID_SHELL_TYPES: ShellType[] = [ "cursor", "codex", "grok", + "kimi-code", "opencode", "shell", ]; @@ -229,13 +239,14 @@ interface LastSessionDefaults { model?: string; } -type TopShell = "claude" | "cursor" | "codex" | "grok" | "opencode" | "shell"; +type TopShell = "claude" | "cursor" | "codex" | "grok" | "kimi-code" | "opencode" | "shell"; type ClaudeFlavor = "standard" | "zai" | "kimi" | "deepseek" | "fireworks"; function shellTypeToTop(s: ShellType | undefined): { top: TopShell; flavor: ClaudeFlavor } { if (s === "cursor") return { top: "cursor", flavor: "standard" }; if (s === "codex") return { top: "codex", flavor: "standard" }; if (s === "grok") return { top: "grok", flavor: "standard" }; + if (s === "kimi-code") return { top: "kimi-code", flavor: "standard" }; if (s === "opencode") return { top: "opencode", flavor: "standard" }; if (s === "shell") return { top: "shell", flavor: "standard" }; if (s === "zai") return { top: "claude", flavor: "zai" }; @@ -246,7 +257,7 @@ function shellTypeToTop(s: ShellType | undefined): { top: TopShell; flavor: Clau } function resolveShellType(top: TopShell, flavor: ClaudeFlavor): ShellType { - if (top === "cursor" || top === "codex" || top === "grok" || top === "opencode" || top === "shell") return top; + if (top === "cursor" || top === "codex" || top === "grok" || top === "kimi-code" || top === "opencode" || top === "shell") return top; if (flavor === "standard") return "claude"; return flavor; } @@ -309,6 +320,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, const [fireworksModels, setFireworksModels] = useState(FIREWORKS_DEFAULT_MODELS); const [zaiModels, setZaiModels] = useState(ZAI_DEFAULT_MODELS); const [grokModel, setGrokModel] = useState(GROK_MODEL_OPTIONS[0]); + const [kimiCodeModel, setKimiCodeModel] = useState(KIMI_CODE_MODEL_OPTIONS[0].value); const [autoCompactWindow, setAutoCompactWindow] = useState(""); const [submitError, setSubmitError] = useState(null); const [missingWorkingDir, setMissingWorkingDir] = useState(null); @@ -348,6 +360,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setFireworksModels(getStoredFireworksModels()); setZaiModels(getStoredZaiModels()); setGrokModel(GROK_MODEL_OPTIONS[0]); + setKimiCodeModel(KIMI_CODE_MODEL_OPTIONS[0].value); setAutoCompactWindow(getStoredAutoCompactWindow()); setSubmitError(null); setMissingWorkingDir(null); @@ -387,6 +400,10 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setGrokModel(parsed.model); } + if (restoredShellType === "kimi-code" && typeof parsed.model === "string") { + setKimiCodeModel(parsed.model); + } + if (defaults?.workingDir === undefined && typeof parsed.workingDir === "string") { setWorkingDir(parsed.workingDir); } @@ -439,7 +456,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, try { await onSubmit("", { name: name.trim() || undefined, - model: shellType === "grok" ? grokModel : undefined, + model: shellType === "grok" ? grokModel : shellType === "kimi-code" ? kimiCodeModel : undefined, workingDir: workingDir.trim() || undefined, bridgeId: effectiveBridgeId || undefined, shellType, @@ -471,6 +488,9 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, if (shellType === "grok") { lastDefaults.model = grokModel; } + if (shellType === "kimi-code") { + lastDefaults.model = kimiCodeModel; + } localStorage.setItem(LAST_SESSION_DEFAULTS_KEY, JSON.stringify(lastDefaults)); } catch { // localStorage unavailable (private browsing, quota) — ignore @@ -481,6 +501,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setTopShell("claude"); setClaudeFlavor("standard"); setGrokModel(GROK_MODEL_OPTIONS[0]); + setKimiCodeModel(KIMI_CODE_MODEL_OPTIONS[0].value); setBridgeId(""); setShowSuggestions(false); setSelectedClaudeSessionId(null); @@ -488,7 +509,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setSelectedCursorSessionId(null); setSelectedCursorSummary(null); onClose(); - }, [shellType, topShell, claudeFlavor, name, workingDir, effectiveBridgeId, hostname, selectedClaudeSessionId, selectedCursorSessionId, fireworksModels, zaiModels, grokModel, autoCompactWindow, onSubmit, onClose]); + }, [shellType, topShell, claudeFlavor, name, workingDir, effectiveBridgeId, hostname, selectedClaudeSessionId, selectedCursorSessionId, fireworksModels, zaiModels, grokModel, kimiCodeModel, autoCompactWindow, onSubmit, onClose]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -549,6 +570,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, + @@ -640,6 +662,26 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, )} + {shellType === "kimi-code" && ( +
+ + +
+ )} + {topShell === "claude" && (