diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 2e218b9d0c0..57b456d0693 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -25,6 +25,8 @@ "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", "../src/main/hermes/hook-service.ts", + "../src/main/kimi/hook-service.ts", + "../src/main/kimi/kimi-hook-config-toml.ts", "../src/main/openclaude/hook-service.ts", "../src/main/runtime/runtime-metadata.ts", "../src/main/win32-utils.ts" diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index 00338d5669a..e3070afed3e 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -13,6 +13,7 @@ import { geminiHookService } from '../gemini/hook-service' import { devinHookService } from '../devin/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void] @@ -32,7 +33,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['grok', () => grokHookService.install()], ['copilot', () => copilotHookService.install()], ['hermes', () => hermesHookService.install()], - ['devin', () => devinHookService.install()] + ['devin', () => devinHookService.install()], + ['kimi', () => kimiHookService.install()] ] const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ @@ -48,7 +50,8 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ ['grok', () => grokHookService.remove()], ['copilot', () => copilotHookService.remove()], ['hermes', () => hermesHookService.remove()], - ['devin', () => devinHookService.remove()] + ['devin', () => devinHookService.remove()], + ['kimi', () => kimiHookService.remove()] ] const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ @@ -64,7 +67,8 @@ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ ['command-code', () => commandCodeHookService.getStatus()], ['copilot', () => copilotHookService.getStatus()], ['hermes', () => hermesHookService.getStatus()], - ['devin', () => devinHookService.getStatus()] + ['devin', () => devinHookService.getStatus()], + ['kimi', () => kimiHookService.getStatus()] ] export function isAgentStatusHooksEnabled( diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 8af3627a845..f3a288f5536 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -19,6 +19,7 @@ import { GrokHookService } from '../grok/hook-service' import { CopilotHookService } from '../copilot/hook-service' import { HermesHookService } from '../hermes/hook-service' import { DevinHookService } from '../devin/hook-service' +import { KimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' type FakeFs = { @@ -375,6 +376,34 @@ describe('remote hook service installers', () => { expect(devin.fs.files.get('/home/dev/.orca/agent-hooks/devin-hook.sh')).toContain('/hook/devin') }) + it('installs remote Kimi hooks as a managed config.toml block preserving user config', async () => { + const userConfig = 'default_model = "kimi-k2.6"\n\n[providers."mine"]\napi_key = "sk-secret"\n' + const { sftp, fs } = createFakeSftp({ '/home/dev/.kimi-code/config.toml': userConfig }) + + const status = await new KimiHookService().installRemote(sftp, '/home/dev') + expect(status.state).toBe('installed') + + const config = fs.files.get('/home/dev/.kimi-code/config.toml')! + // User config above the managed block is preserved. + expect(config).toContain('default_model = "kimi-k2.6"') + expect(config).toContain('api_key = "sk-secret"') + for (const eventName of [ + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure' + ]) { + expect(config).toContain(`event = "${eventName}"`) + } + // The command points at the POSIX managed script via the `[ -x ]` guard. + expect(config).toContain('/home/dev/.orca/agent-hooks/kimi-hook.sh') + expect(config).toMatch(/command = "if \[ -x /) + expect(fs.files.get('/home/dev/.orca/agent-hooks/kimi-hook.sh')).toContain('/hook/kimi') + }) + it('does not overwrite malformed remote Devin JSONC', async () => { const original = '{"hooks": }' const { sftp, fs } = createFakeSftp({ diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 95048dd3791..7913b863577 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -10,6 +10,7 @@ import { commandCodeHookService } from '../command-code/hook-service' import { devinHookService } from '../devin/hook-service' import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' +import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' type RemoteManagedHookInstaller = readonly [ @@ -28,7 +29,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['command-code', (sftp, remoteHome) => commandCodeHookService.installRemote(sftp, remoteHome)], ['grok', (sftp, remoteHome) => grokHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], - ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)] + ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], + ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] ] export async function installRemoteManagedAgentHooks( diff --git a/src/main/ai-vault/session-scanner-agent-parser.ts b/src/main/ai-vault/session-scanner-agent-parser.ts index e97bb196aa1..f65bb8eec6a 100644 --- a/src/main/ai-vault/session-scanner-agent-parser.ts +++ b/src/main/ai-vault/session-scanner-agent-parser.ts @@ -6,6 +6,7 @@ import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers' +import { parseKimiSessionFile } from './session-scanner-kimi-parser' import { parseClaudeSessionFile, parseCodexSessionFile, @@ -50,5 +51,7 @@ export async function parseAgentSessionFile( return parseDroidSessionFile(candidate.file, platform) case 'devin': return parseDevinSessionFile(candidate.file, platform) + case 'kimi': + return parseKimiSessionFile(candidate.file, platform) } } diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts new file mode 100644 index 00000000000..d22f9172661 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -0,0 +1,214 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, describe, expect, it } from 'vitest' +import { parseKimiSessionFile } from './session-scanner-kimi-parser' +import { clearKimiSessionIndexCache } from './session-scanner-kimi-paths' +import type { FileWithMtime } from './session-scanner-types' + +let tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs = [] + clearKimiSessionIndexCache() +}) + +const SESSION_ID = 'session_4243babe-c33c-4ca3-8245-689c9e34ba3b' + +// Mirrors a real `agents/main/wire.jsonl` produced by Kimi Code 0.18.0. +const WIRE_LINES = [ + { type: 'metadata', protocol_version: '1.4', created_at: 1781853559132 }, + { type: 'config.update', profileName: 'agent', systemPrompt: 'You are Kimi Code CLI...' }, + { type: 'config.update', modelAlias: 'mock-model', thinkingLevel: 'high', time: 1781853559132 }, + { + type: 'turn.prompt', + input: [{ type: 'text', text: 'Please explain this project briefly' }], + origin: { kind: 'user' }, + time: 1781853559164 + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Please explain this project briefly' }], + toolCalls: [], + origin: { kind: 'user' } + }, + time: 1781853559164 + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [ + { + type: 'text', + text: '\nAuto permission mode is active.\n' + } + ], + toolCalls: [], + origin: { kind: 'injection', variant: 'permission_mode' } + }, + time: 1781853559165 + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', step: 1 }, + time: 1781853559165 + }, + { + type: 'context.append_loop_event', + event: { + type: 'content.part', + step: 1, + part: { type: 'text', text: 'Hello! This is a mock response. ' } + }, + time: 1781853559177 + }, + { + type: 'context.append_loop_event', + event: { type: 'step.end', step: 1, finishReason: 'end_turn' }, + time: 1781853559177 + }, + { + type: 'usage.record', + model: 'mock-model', + usage: { inputOther: 12, output: 18, inputCacheRead: 0, inputCacheCreation: 0 }, + usageScope: 'turn', + time: 1781853559177 + } +] + +async function writeKimiSession(args: { + sessionId?: string + state?: Record + workDir?: string | null + wireLines?: unknown[] | null +}): Promise<{ file: FileWithMtime }> { + const home = await mkdtemp(join(tmpdir(), 'orca-kimi-')) + tempDirs.push(home) + const sessionId = args.sessionId ?? SESSION_ID + const sessionDir = join(home, 'sessions', 'wd_kimi-test-proj_36fb0f9f4385', sessionId) + await mkdir(join(sessionDir, 'agents', 'main'), { recursive: true }) + + const statePath = join(sessionDir, 'state.json') + const state = args.state ?? { + createdAt: '2026-06-19T07:19:19.118Z', + updatedAt: '2026-06-19T07:19:19.161Z', + title: 'Please explain this project briefly', + isCustomTitle: false, + agents: { + main: { homedir: join(sessionDir, 'agents', 'main'), type: 'main', parentAgentId: null } + }, + custom: {}, + lastPrompt: 'Please explain this project briefly' + } + await writeFile(statePath, JSON.stringify(state)) + + if (args.workDir !== null) { + await writeFile( + join(home, 'session_index.jsonl'), + `${JSON.stringify({ sessionId, sessionDir, workDir: args.workDir ?? '/private/tmp/kimi-test-proj' })}\n` + ) + } + + if (args.wireLines !== null) { + await writeFile( + join(sessionDir, 'agents', 'main', 'wire.jsonl'), + (args.wireLines ?? WIRE_LINES).map((line) => JSON.stringify(line)).join('\n') + ) + } + + const mtimeMs = Date.now() + return { file: { path: statePath, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } } +} + +describe('parseKimiSessionFile', () => { + it('parses a full session from state.json + index + wire transcript', async () => { + const { file } = await writeKimiSession({}) + const session = await parseKimiSessionFile(file, 'darwin') + + expect(session).not.toBeNull() + expect(session?.agent).toBe('kimi') + // The session id keeps the `session_` prefix that `kimi --session ` expects. + expect(session?.sessionId).toBe(SESSION_ID) + expect(session?.title).toBe('Please explain this project briefly') + expect(session?.cwd).toBe('/private/tmp/kimi-test-proj') + expect(session?.model).toBe('mock-model') + expect(session?.totalTokens).toBe(30) + // 1 real user turn + 1 assistant turn; the injected system-reminder is excluded. + expect(session?.messageCount).toBe(2) + expect(session?.previewMessages).toEqual([ + { role: 'user', text: 'Please explain this project briefly', timestamp: null }, + { role: 'assistant', text: 'Hello! This is a mock response.', timestamp: null } + ]) + expect(session?.createdAt).toBe('2026-06-19T07:19:19.118Z') + expect(session?.updatedAt).toBe('2026-06-19T07:19:19.161Z') + }) + + it('builds a work-dir-scoped resume command', async () => { + const { file } = await writeKimiSession({}) + const session = await parseKimiSessionFile(file, 'darwin') + expect(session?.resumeCommand).toBe( + `cd '/private/tmp/kimi-test-proj' && kimi --session '${SESSION_ID}'` + ) + }) + + it('still lists a metadata-only session with no transcript yet', async () => { + const { file } = await writeKimiSession({ wireLines: null }) + const session = await parseKimiSessionFile(file, 'darwin') + expect(session?.title).toBe('Please explain this project briefly') + expect(session?.messageCount).toBe(0) + expect(session?.model).toBeNull() + }) + + it('lists a session even when the index (work dir) is missing', async () => { + const { file } = await writeKimiSession({ workDir: null }) + const session = await parseKimiSessionFile(file, 'darwin') + expect(session?.cwd).toBeNull() + expect(session?.resumeCommand).toBe(`kimi --session '${SESSION_ID}'`) + }) + + it('keeps preview messages at the 220-char preview limit, not the 96-char title limit', async () => { + const longReply = 'x'.repeat(300) + const { file } = await writeKimiSession({ + wireLines: [ + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'y'.repeat(300) }], + origin: { kind: 'user' } + } + }, + { + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'text', text: longReply } } + }, + { type: 'context.append_loop_event', event: { type: 'step.end' } } + ] + }) + const session = await parseKimiSessionFile(file, 'darwin') + const [userPreview, assistantPreview] = session!.previewMessages + // 220-char cap = 217 chars + '...'; the 96-char title cap would be 93 + '...'. + expect(userPreview.text.length).toBe(220) + expect(assistantPreview.text.length).toBe(220) + expect(assistantPreview.text.endsWith('...')).toBe(true) + }) + + it('falls back to lastPrompt when the title is empty', async () => { + const { file } = await writeKimiSession({ + state: { + createdAt: '2026-06-19T07:19:19.118Z', + updatedAt: '2026-06-19T07:19:19.161Z', + title: '', + lastPrompt: 'do the thing', + agents: {} + }, + wireLines: [] + }) + const session = await parseKimiSessionFile(file, 'darwin') + expect(session?.title).toBe('do the thing') + }) +}) diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts new file mode 100644 index 00000000000..67600c024bf --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -0,0 +1,172 @@ +import { createReadStream } from 'fs' +import { readFile } from 'fs/promises' +import { createInterface } from 'readline' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { + addPreviewContent, + addPreviewMessage, + createAccumulator, + finalizeSession, + updateTimeline +} from './session-scanner-accumulator' +import { + kimiPrimaryAgentWirePath, + kimiSessionIdFromStatePath, + kimiSessionIndexPathFromStatePath, + readKimiWorkDirBySessionId +} from './session-scanner-kimi-paths' +import type { FileWithMtime, SessionAccumulator } from './session-scanner-types' +import { + asRecord, + extractContentText, + extractString, + normalizePreviewText, + normalizeTitleText, + numberValue, + parseJsonObject +} from './session-scanner-values' + +// Parses a Kimi Code `state.json` plus its sibling `agents//wire.jsonl` +// transcript into an AI Vault session. Metadata (title, timestamps, last prompt) +// comes from state.json; the work directory comes from the top-level +// session_index.jsonl; model/messages/tokens come from the wire transcript. +export async function parseKimiSessionFile( + file: FileWithMtime, + platform: NodeJS.Platform = process.platform +): Promise { + const stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + if (!stateRecord) { + return null + } + + const sessionId = kimiSessionIdFromStatePath(file.path) + const accumulator = createAccumulator({ agent: 'kimi', file, sessionId }) + + // Why: Kimi sessions are work-dir-scoped — the resume command must `cd` into + // the original directory or the CLI rejects it. That path lives only in the + // top-level session_index.jsonl, keyed by the (prefixed) session id. + const workDirBySessionId = await readKimiWorkDirBySessionId( + kimiSessionIndexPathFromStatePath(file.path) + ) + accumulator.cwd = workDirBySessionId.get(sessionId) ?? null + + accumulator.title = normalizeTitleText(extractString(stateRecord.title) ?? '') + accumulator.fallbackTitle = normalizeTitleText(extractString(stateRecord.lastPrompt) ?? '') + updateTimeline(accumulator, extractString(stateRecord.createdAt)) + updateTimeline(accumulator, extractString(stateRecord.updatedAt)) + + await consumeKimiWireTranscript(accumulator, kimiPrimaryAgentWirePath(file.path, stateRecord)) + + return finalizeSession(accumulator, platform) +} + +async function consumeKimiWireTranscript( + accumulator: SessionAccumulator, + wirePath: string +): Promise { + let pendingAssistantText: string[] = [] + const flushAssistant = (): void => { + // Why: previews use the 220-char limit (normalizePreviewText), not the + // 96-char title limit — assistant replies are shown in full preview width + // like every other agent's. Join raw chunks first so inter-chunk spacing + // survives; normalizePreviewText then collapses whitespace and caps length. + const text = normalizePreviewText(pendingAssistantText.join('')) + pendingAssistantText = [] + if (text) { + accumulator.messageCount++ + addPreviewMessage(accumulator, { role: 'assistant', text }) + } + } + + try { + const lines = createInterface({ + input: createReadStream(wirePath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + const record = parseJsonObject(line) + if (!record) { + continue + } + switch (record.type) { + case 'config.update': + accumulator.model = extractString(record.modelAlias) ?? accumulator.model + break + case 'usage.record': + accumulator.model = extractString(record.model) ?? accumulator.model + accumulator.totalTokens += kimiUsageTotal(record.usage, record.usageScope) + break + case 'context.append_message': + consumeKimiUserMessage(accumulator, record.message) + break + case 'context.append_loop_event': + consumeKimiLoopEvent(record.event, pendingAssistantText, flushAssistant) + break + default: + break + } + } + } catch { + // No transcript yet (session created but never ran a turn) — metadata-only + // sessions still belong in the panel. + } + flushAssistant() +} + +function consumeKimiUserMessage(accumulator: SessionAccumulator, value: unknown): void { + const message = asRecord(value) + // Why: only real user turns count. Kimi injects synthetic `role: "user"` + // messages (origin.kind === "injection") for system reminders like the + // auto-permission notice; those are not user activity. + if (!message || message.role !== 'user' || asRecord(message.origin)?.kind !== 'user') { + return + } + accumulator.messageCount++ + // Title uses the 96-char title limit; the preview uses the 220-char limit. + accumulator.title ??= extractContentText(message.content) + addPreviewContent(accumulator, 'user', message.content) +} + +function consumeKimiLoopEvent( + value: unknown, + pendingAssistantText: string[], + flushAssistant: () => void +): void { + const event = asRecord(value) + if (!event) { + return + } + if (event.type === 'content.part') { + const part = asRecord(event.part) + // Push the raw chunk text; flushAssistant normalizes the joined result so + // multi-chunk spacing is not lost to per-chunk trimming. + if (part?.type === 'text' && typeof part.text === 'string') { + pendingAssistantText.push(part.text) + } + return + } + // A step end closes one assistant turn; flush its accumulated text as a single + // preview message so streamed `content.part` chunks collapse into one entry. + if (event.type === 'step.end') { + flushAssistant() + } +} + +// Kimi reports per-turn usage as {inputOther, output, inputCacheRead, +// inputCacheCreation}; sum all four for a session total. Skip any future +// cumulative ("session"-scoped) record so turn deltas are not double-counted. +function kimiUsageTotal(value: unknown, usageScope: unknown): number { + if (usageScope === 'session') { + return 0 + } + const usage = asRecord(value) + if (!usage) { + return 0 + } + return ( + numberValue(usage.inputOther) + + numberValue(usage.output) + + numberValue(usage.inputCacheRead) + + numberValue(usage.inputCacheCreation) + ) +} diff --git a/src/main/ai-vault/session-scanner-kimi-paths.ts b/src/main/ai-vault/session-scanner-kimi-paths.ts new file mode 100644 index 00000000000..4da687a1274 --- /dev/null +++ b/src/main/ai-vault/session-scanner-kimi-paths.ts @@ -0,0 +1,123 @@ +import { stat } from 'fs/promises' +import { createReadStream } from 'fs' +import { homedir } from 'os' +import { basename, dirname, join } from 'path' +import { createInterface } from 'readline' +import { asRecord, extractString } from './session-scanner-values' + +// Why: Kimi Code stores sessions under /sessions/, mirroring the +// CLI's own `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (see kimi-fetcher.ts). +export function resolveKimiSessionsDir(override?: string): string { + if (override?.trim()) { + return override.trim() + } + const home = process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code') + return join(home, 'sessions') +} + +// Layout: /sessions/wd__/session_/state.json +// The session id is the session directory name (it keeps the `session_` prefix, +// which is exactly what `kimi --session ` expects). +export function kimiSessionIdFromStatePath(statePath: string): string { + return basename(dirname(statePath)) +} + +// Walk up from a session's state.json to the Kimi home directory so the +// top-level session_index.jsonl (which holds the real workDir) can be located +// without trusting absolute paths embedded in state.json. +export function kimiSessionIndexPathFromStatePath(statePath: string): string { + const sessionDir = dirname(statePath) // .../session_ + const workspaceDir = dirname(sessionDir) // .../wd__ + const sessionsDir = dirname(workspaceDir) // .../sessions + const home = dirname(sessionsDir) // .../ + return join(home, 'session_index.jsonl') +} + +// Why: the primary agent transcript lives at /agents//wire.jsonl. +// The id is the key in state.json's `agents` map whose `type` is "main"; default +// to "main" when the map is missing so a malformed state.json still resolves a +// plausible path. +export function kimiPrimaryAgentWirePath( + statePath: string, + stateRecord: Record | null +): string { + const agents = asRecord(stateRecord?.agents) + let primaryId = 'main' + if (agents) { + for (const [id, value] of Object.entries(agents)) { + const record = asRecord(value) + if (record?.type === 'main' && record.parentAgentId == null) { + primaryId = id + break + } + } + } + return join(dirname(statePath), 'agents', primaryId, 'wire.jsonl') +} + +type WorkDirCacheEntry = { + mtimeMs: number + map: Promise> +} + +// Why: every session under one Kimi home shares a single session_index.jsonl. +// Re-reading it once per session would be O(n^2); memoize by path + mtime so a +// scan reads the index at most once and the cache self-invalidates when Kimi +// appends a new session (mtime bump). +const workDirCacheByIndexPath = new Map() + +export function clearKimiSessionIndexCache(): void { + workDirCacheByIndexPath.clear() +} + +export async function readKimiWorkDirBySessionId(indexPath: string): Promise> { + let mtimeMs: number + try { + mtimeMs = (await stat(indexPath)).mtimeMs + } catch { + // Missing index (e.g. user deleted it): sessions still list, just without cwd. + return new Map() + } + + const cached = workDirCacheByIndexPath.get(indexPath) + if (cached && cached.mtimeMs === mtimeMs) { + return cached.map + } + + const map = parseKimiSessionIndex(indexPath) + workDirCacheByIndexPath.set(indexPath, { mtimeMs, map }) + return map +} + +async function parseKimiSessionIndex(indexPath: string): Promise> { + const map = new Map() + // Why: never reject. This promise is memoized and shared by every session + // under one Kimi home; a mid-read failure (file deleted after stat, EACCES) + // must degrade to whatever was parsed so the other sessions still list. + try { + const lines = createInterface({ + input: createReadStream(indexPath, { encoding: 'utf-8' }), + crlfDelay: Infinity + }) + for await (const line of lines) { + if (!line.trim()) { + continue + } + let record: Record | null + try { + record = asRecord(JSON.parse(line) as unknown) + } catch { + continue + } + const sessionId = extractString(record?.sessionId) + const workDir = extractString(record?.workDir) + if (sessionId && workDir) { + // Later lines win so a resumed session reflects its most recent workDir. + map.set(sessionId, workDir) + } + } + } catch { + // Return the partial map gathered before the read error. + } + return map +} diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 6f6eca3359b..30a7ef7000e 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -22,6 +22,7 @@ export type AiVaultScanOptions = { piSessionsDir?: string droidSessionsDir?: string droidProjectsDir?: string + kimiSessionsDir?: string limit?: number limitPerAgent?: number platform?: NodeJS.Platform diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index e055819d121..86ca2a39a17 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -28,7 +28,8 @@ function isolatedScanRoots(root: string) { openclawLegacyStateDir: join(root, 'openclaw-legacy-state'), piSessionsDir: join(root, 'pi-sessions'), droidSessionsDir: join(root, 'droid-sessions'), - droidProjectsDir: join(root, 'droid-projects') + droidProjectsDir: join(root, 'droid-projects'), + kimiSessionsDir: join(root, 'kimi-sessions') } } @@ -574,6 +575,55 @@ describe('scanAiVaultSessions', () => { ]) ) + // Kimi: /wd_*/session_*/state.json + sibling agents/main/wire.jsonl, + // with the work dir resolved from the top-level session_index.jsonl. + const kimiSessionDir = join(roots.kimiSessionsDir, 'wd_app_abc', 'session_kimi-session') + await mkdir(join(kimiSessionDir, 'agents', 'main'), { recursive: true }) + await writeFile( + join(kimiSessionDir, 'state.json'), + JSON.stringify({ + createdAt: '2026-05-01T10:11:00.000Z', + updatedAt: '2026-05-01T10:11:05.000Z', + title: 'Kimi vault title', + lastPrompt: 'Kimi vault title', + agents: { main: { type: 'main', parentAgentId: null } } + }) + ) + await writeFile( + join(root, 'session_index.jsonl'), + jsonLines([ + { sessionId: 'session_kimi-session', sessionDir: kimiSessionDir, workDir: '/tmp/kimi' } + ]) + ) + await writeFile( + join(kimiSessionDir, 'agents', 'main', 'wire.jsonl'), + jsonLines([ + { type: 'config.update', modelAlias: 'kimi-k2.6', time: 1781853559132 }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Kimi vault title' }], + origin: { kind: 'user' } + }, + time: 1781853559164 + }, + { + type: 'context.append_loop_event', + event: { type: 'content.part', part: { type: 'text', text: 'Kimi reply' } }, + time: 1781853559177 + }, + { type: 'context.append_loop_event', event: { type: 'step.end' }, time: 1781853559178 }, + { + type: 'usage.record', + model: 'kimi-k2.6', + usage: { inputOther: 4, output: 6, inputCacheRead: 0, inputCacheCreation: 0 }, + usageScope: 'turn', + time: 1781853559178 + } + ]) + ) + const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', @@ -615,6 +665,9 @@ describe('scanAiVaultSessions', () => { expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'") expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'") expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'") + expect(commandByAgent.get('kimi')).toBe( + "cd '/tmp/kimi' && kimi --session 'session_kimi-session'" + ) }) }) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 4623ff29a6b..ca7403d2798 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -1,5 +1,5 @@ import { homedir } from 'os' -import { basename, join } from 'path' +import { basename, dirname, join } from 'path' import type { AiVaultListResult, AiVaultScanIssue, @@ -9,6 +9,7 @@ import { sessionSortTime } from './session-scanner-accumulator' import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir, uniqueCodexSessionsDirs } from './session-scanner-codex-paths' import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery' +import { resolveKimiSessionsDir } from './session-scanner-kimi-paths' import type { AiVaultScanOptions, SessionFileCandidate, @@ -172,6 +173,17 @@ export async function scanAiVaultSessions( agent: 'droid', issues, extensions: ['.jsonl'] + }), + discoverFiles({ + rootDir: resolveKimiSessionsDir(options.kimiSessionsDir), + limit: limitPerAgent, + agent: 'kimi', + issues, + extensions: ['.json'], + // Why: each Kimi session is /wd_*/session_*/state.json; match + // only those (not the sibling agents/*/wire.jsonl transcripts). + filePredicate: (path) => + basename(path) === 'state.json' && basename(dirname(path)).startsWith('session_') }) ]) diff --git a/src/main/kimi/hook-service.test.ts b/src/main/kimi/hook-service.test.ts new file mode 100644 index 00000000000..c11f0921500 --- /dev/null +++ b/src/main/kimi/hook-service.test.ts @@ -0,0 +1,87 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { KimiHookService } from './hook-service' +import { KIMI_HOOK_EVENTS } from './kimi-hook-config-toml' + +// Why: getSharedManagedScriptPath() writes the managed script under +// homedir()/.orca, and getKimiHome() honors KIMI_CODE_HOME. Point both at a +// temp dir so the local install/remove cycle never touches the real ~/.orca or +// ~/.kimi-code. os.homedir() resolves $HOME on POSIX (verified at write time). +let home: string +let originalHome: string | undefined +let originalKimiHome: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-kimi-hook-')) + originalHome = process.env.HOME + originalKimiHome = process.env.KIMI_CODE_HOME + process.env.HOME = home + process.env.KIMI_CODE_HOME = join(home, '.kimi-code') +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalKimiHome === undefined) { + delete process.env.KIMI_CODE_HOME + } else { + process.env.KIMI_CODE_HOME = originalKimiHome + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.kimi-code', 'config.toml') +const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'kimi-hook.sh') + +describe('KimiHookService', () => { + it('reports not_installed before install', () => { + expect(new KimiHookService().getStatus().state).toBe('not_installed') + }) + + it('installs the managed hooks block and the managed script', () => { + const status = new KimiHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + + const config = readFileSync(configPath(), 'utf-8') + for (const event of KIMI_HOOK_EVENTS) { + expect(config).toContain(`event = "${event}"`) + } + // The managed script must exist and POST to the Kimi hook endpoint. + const script = readFileSync(scriptPath(), 'utf-8') + expect(script).toContain('/hook/kimi') + // The command Kimi runs points at the managed script via sh. + expect(config).toContain('agent-hooks/kimi-hook.sh') + }) + + it('keeps user config when installing, then restores it on remove', () => { + const dir = join(home, '.kimi-code') + mkdirSync(dir, { recursive: true }) + // Pre-existing user config with their own provider. + const userConfig = + 'default_model = "kimi-k2.6"\n\n[providers."mine"]\ntype = "openai"\napi_key = "sk-secret"\n' + writeFileSync(configPath(), userConfig) + + const service = new KimiHookService() + expect(service.install().state).toBe('installed') + + const installed = readFileSync(configPath(), 'utf-8') + expect(installed).toContain('api_key = "sk-secret"') + expect(installed).toContain('default_model = "kimi-k2.6"') + + // Reinstall must not duplicate the managed block. + service.install() + const reinstalled = readFileSync(configPath(), 'utf-8') + expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) + + const removed = service.remove() + expect(removed.state).toBe('not_installed') + const afterRemove = readFileSync(configPath(), 'utf-8') + expect(afterRemove).toBe(userConfig) + }) +}) diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts new file mode 100644 index 00000000000..40a197feff8 --- /dev/null +++ b/src/main/kimi/hook-service.ts @@ -0,0 +1,244 @@ +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync +} from 'fs' +import { homedir } from 'os' +import { dirname, join } from 'path' +import { randomUUID } from 'crypto' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + createManagedCommandMatcher, + getSharedManagedScriptPath, + wrapPosixHookCommand, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { + readTextFileRemote, + writeManagedScriptRemote, + writeTextFileRemoteAtomic +} from '../agent-hooks/installer-utils-remote' +import { + applyManagedKimiHooks, + KIMI_HOOK_EVENTS, + readManagedKimiHookEvents, + removeManagedKimiHooks +} from './kimi-hook-config-toml' + +// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (also used by +// kimi-fetcher.ts and the AI Vault session scanner) so hooks land in the same +// home Kimi reads at launch. +function getKimiHome(): string { + return process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code') +} + +function getConfigPath(): string { + return join(getKimiHome(), 'config.toml') +} + +// Always a POSIX `.sh` script: Kimi runs hook commands through its shell, which +// is Git Bash even on Windows (see the CLI README / KIMI_SHELL_PATH), so a +// single curl-based script body works on every platform. +const MANAGED_SCRIPT_FILE_NAME = 'kimi-hook.sh' + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(MANAGED_SCRIPT_FILE_NAME) +} + +function getManagedCommand(scriptPath: string): string { + // Forward slashes so Kimi's Git Bash shell accepts the path on Windows. + const posixPath = process.platform === 'win32' ? scriptPath.replaceAll('\\', '/') : scriptPath + return wrapPosixHookCommand(posixPath) +} + +function getManagedScript(): string { + return [ + '#!/bin/sh', + // Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY + // that survived an Orca restart still reaches the live listener. See + // claude/hook-service.ts for the full rationale. + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'payload=$(cat)', + 'if [ -z "$payload" ]; then', + ' exit 0', + 'fi', + // Why: worktreeId embeds a filesystem path, so hand-building JSON in POSIX + // shell is not safe once a path contains quotes or newlines. Post the raw + // hook payload plus metadata as form fields and let the receiver parse it. + 'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/kimi" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +// Returns the file text, '' when the config does not exist yet (Kimi creates it +// lazily), or null on an unreadable file so callers can report a structured error. +function readConfigToml(configPath: string): string | null { + if (!existsSync(configPath)) { + return '' + } + try { + return readFileSync(configPath, 'utf-8') + } catch { + return null + } +} + +// Why: temp+rename keeps a hand-editable config.toml intact if a write is +// interrupted, and a single rolling .bak makes a bad write recoverable. +function writeConfigToml(configPath: string, text: string): void { + const dir = dirname(configPath) + mkdirSync(dir, { recursive: true }) + if (existsSync(configPath)) { + try { + if (readFileSync(configPath, 'utf-8') === text) { + return + } + } catch { + // Fall through to the atomic write path. + } + } + const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`) + try { + writeFileSync(tmpPath, text, 'utf-8') + if (existsSync(configPath)) { + copyFileSync(configPath, `${configPath}.bak`) + } + renameSync(tmpPath, configPath) + } finally { + if (existsSync(tmpPath)) { + try { + unlinkSync(tmpPath) + } catch { + // best effort + } + } + } +} + +function buildStatus(present: Set, configPath: string): AgentHookInstallStatus { + const missing = KIMI_HOOK_EVENTS.filter((event) => !present.has(event)) + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + state = 'installed' + detail = null + } else if (present.size === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = `Managed hook missing for events: ${missing.join(', ')}` + } + return { agent: 'kimi', state, configPath, managedHooksPresent: present.size > 0, detail } +} + +export class KimiHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const text = readConfigToml(configPath) + if (text === null) { + return { + agent: 'kimi', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Kimi config.toml' + } + } + const isManagedCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME) + return buildStatus(readManagedKimiHookEvents(text, isManagedCommand), configPath) + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const text = readConfigToml(configPath) + if (text === null) { + return { + agent: 'kimi', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Kimi config.toml' + } + } + const scriptPath = getManagedScriptPath() + const command = getManagedCommand(scriptPath) + // Write the script first so config.toml never points at a missing script. + writeManagedScript(scriptPath, getManagedScript()) + writeConfigToml(configPath, applyManagedKimiHooks(text, command)) + return this.getStatus() + } + + // Why: install Orca's managed Kimi hooks on a remote box over SFTP, mirroring + // the local install. POSIX-only by design (Kimi's shell is sh/Git Bash); the + // managed script body is already platform-independent. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.kimi-code/config.toml` + const remoteScriptPath = `${home}/.orca/agent-hooks/${MANAGED_SCRIPT_FILE_NAME}` + try { + // null (file absent) → start from an empty config; Kimi creates it lazily. + const text = (await readTextFileRemote(sftp, remoteConfigPath)) ?? '' + const command = wrapPosixHookCommand(remoteScriptPath) + // Write the script first so config.toml never points at a missing script. + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript()) + await writeTextFileRemoteAtomic(sftp, remoteConfigPath, applyManagedKimiHooks(text, command)) + return { + agent: 'kimi', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'kimi', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const text = readConfigToml(configPath) + if (text === null) { + return { + agent: 'kimi', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not read Kimi config.toml' + } + } + const { text: nextText, changed } = removeManagedKimiHooks(text) + if (changed) { + writeConfigToml(configPath, nextText) + } + return this.getStatus() + } +} + +export const kimiHookService = new KimiHookService() diff --git a/src/main/kimi/kimi-hook-config-toml.test.ts b/src/main/kimi/kimi-hook-config-toml.test.ts new file mode 100644 index 00000000000..954e3639975 --- /dev/null +++ b/src/main/kimi/kimi-hook-config-toml.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedKimiHooks, + buildManagedKimiHooksBlock, + KIMI_HOOK_EVENTS, + readManagedKimiHookEvents, + removeManagedKimiHooks +} from './kimi-hook-config-toml' + +const COMMAND = + "if [ -x '/home/u/.orca/agent-hooks/kimi-hook.sh' ]; then /bin/sh '/home/u/.orca/agent-hooks/kimi-hook.sh'; fi" +const isManaged = (command: string | undefined): boolean => + typeof command === 'string' && command.includes('agent-hooks/kimi-hook.sh') + +describe('kimi managed hooks TOML block', () => { + it('installs every managed event without a matcher', () => { + const block = buildManagedKimiHooksBlock(COMMAND) + for (const event of KIMI_HOOK_EVENTS) { + expect(block).toContain(`event = "${event}"`) + } + // Kimi treats matcher as a regex; omitting it matches all tools. + expect(block).not.toContain('matcher') + expect(readManagedKimiHookEvents(applyManagedKimiHooks('', COMMAND), isManaged)).toEqual( + new Set(KIMI_HOOK_EVENTS) + ) + }) + + it('preserves existing user config above the managed block', () => { + const userConfig = [ + 'default_model = "kimi-k2.6"', + '', + '[providers."mine"]', + 'type = "openai"', + 'base_url = "https://example.com/v1"', + 'api_key = "sk-secret"', + '', + '[[hooks]]', + 'event = "SessionStart"', + 'command = "node my-own-hook.mjs"', + '' + ].join('\n') + + const next = applyManagedKimiHooks(userConfig, COMMAND) + expect(next).toContain('default_model = "kimi-k2.6"') + expect(next).toContain('api_key = "sk-secret"') + // The user's own hook survives untouched. + expect(next).toContain('command = "node my-own-hook.mjs"') + expect(readManagedKimiHookEvents(next, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + }) + + it('is idempotent — reinstalling does not duplicate the block', () => { + const once = applyManagedKimiHooks('default_model = "x"\n', COMMAND) + const twice = applyManagedKimiHooks(once, COMMAND) + expect(twice).toBe(once) + const markerCount = (twice.match(/orca-managed-kimi-hooks \(/g) ?? []).length + expect(markerCount).toBe(1) + }) + + it('removes the managed block and restores the user config', () => { + const userConfig = 'default_model = "kimi-k2.6"\n' + const installed = applyManagedKimiHooks(userConfig, COMMAND) + const { text, changed } = removeManagedKimiHooks(installed) + expect(changed).toBe(true) + expect(text).toBe(userConfig) + expect(readManagedKimiHookEvents(text, isManaged).size).toBe(0) + }) + + it('reports no change when removing from a config without the managed block', () => { + const { text, changed } = removeManagedKimiHooks('default_model = "x"\n') + expect(changed).toBe(false) + expect(text).toBe('default_model = "x"\n') + }) + + it('is stable across repeated calls (no stateful global-regex lastIndex drift)', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND) + // Repeated detection/removal on the same and on a clean input must be + // consistent — a `g`-flagged .test() would drift lastIndex and flip results. + expect(removeManagedKimiHooks(installed).changed).toBe(true) + expect(removeManagedKimiHooks(installed).changed).toBe(true) + expect(removeManagedKimiHooks('default_model = "x"\n').changed).toBe(false) + expect(removeManagedKimiHooks(installed).changed).toBe(true) + expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + expect(readManagedKimiHookEvents(installed, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + }) + + it('recovers when a hand-edit deletes only the trailing end marker', () => { + const installed = applyManagedKimiHooks('default_model = "x"\n', COMMAND) + // Simulate a user deleting just the `# <<< ... <<<` end-marker line. + const orphaned = installed.replace(/\n# <<< orca-managed-kimi-hooks <<<\n?/, '\n') + expect(orphaned).not.toContain('<<<') + // The orphaned (still-active) hook tables are still recognized... + expect(readManagedKimiHookEvents(orphaned, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + // ...remove strips them... + expect(removeManagedKimiHooks(orphaned)).toEqual({ + text: 'default_model = "x"\n', + changed: true + }) + // ...and reinstall converges to a single block instead of duplicating. + const reinstalled = applyManagedKimiHooks(orphaned, COMMAND) + expect((reinstalled.match(/orca-managed-kimi-hooks \(/g) ?? []).length).toBe(1) + }) + + it('treats stale managed entries pointing at a moved script path as managed', () => { + const staleCommand = + "if [ -x '/old/userData/agent-hooks/kimi-hook.sh' ]; then /bin/sh '/old/userData/agent-hooks/kimi-hook.sh'; fi" + const stale = applyManagedKimiHooks('', staleCommand) + expect(readManagedKimiHookEvents(stale, isManaged)).toEqual(new Set(KIMI_HOOK_EVENTS)) + }) +}) diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts new file mode 100644 index 00000000000..5e80080704c --- /dev/null +++ b/src/main/kimi/kimi-hook-config-toml.ts @@ -0,0 +1,103 @@ +// Kimi Code keeps all preferences in TOML (`~/.kimi-code/config.toml`) and reads +// lifecycle hooks from an array of `[[hooks]]` tables. There is no JSON settings +// file to reuse the shared JSON installer with, and no TOML library is vendored, +// so Orca manages only its own marker-delimited block: install rewrites the +// block, remove strips it, and arbitrary user config outside the markers is left +// untouched. Appending table headers is always valid TOML, so the block can live +// at the end of any existing file. + +// Why: mirror the Claude-compatible events Orca normalizes for status. Kimi uses +// these exact event names (see normalizeKimiEvent), so each maps to a +// working/waiting/done transition. +export const KIMI_HOOK_EVENTS = [ + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'StopFailure' +] as const + +const BLOCK_START = '# >>> orca-managed-kimi-hooks (managed by Orca; do not edit) >>>' +const BLOCK_END = '# <<< orca-managed-kimi-hooks <<<' + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Matches the managed block plus any blank lines immediately preceding it so +// repeated install/remove cycles do not accumulate whitespace. The `|$` +// fallback also matches from BLOCK_START to end-of-file when the trailing +// BLOCK_END marker is missing (e.g. a hand-edit deleted it): the managed block +// is always written last, so this recovers orphaned hook tables and lets +// install re-converge in one step instead of appending a duplicate block. +const MANAGED_BLOCK_RE = new RegExp( + `\\n*${escapeRegExp(BLOCK_START)}[\\s\\S]*?(?:${escapeRegExp(BLOCK_END)}[^\\n]*|$)`, + 'g' +) + +// TOML basic (double-quoted) string. The managed command may contain single +// quotes (from POSIX quoting) but no double quotes or backslashes on the paths +// Orca generates; escape both defensively anyway. +function tomlBasicString(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + // Control chars would make Kimi's TOML parser reject the file. + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + return `"${escaped}"` +} + +export function buildManagedKimiHooksBlock(command: string): string { + const commandLiteral = tomlBasicString(command) + // Omit `matcher`: Kimi treats it as a regex (so Claude's literal "*" is + // invalid) and an absent matcher already matches every tool. + const entries = KIMI_HOOK_EVENTS.map((event) => + [`[[hooks]]`, `event = "${event}"`, `command = ${commandLiteral}`].join('\n') + ) + return [BLOCK_START, ...entries, BLOCK_END].join('\n') +} + +export function applyManagedKimiHooks(configText: string, command: string): string { + const withoutManaged = configText.replace(MANAGED_BLOCK_RE, '').replace(/\s+$/, '') + const block = buildManagedKimiHooksBlock(command) + return withoutManaged.length > 0 ? `${withoutManaged}\n\n${block}\n` : `${block}\n` +} + +export function removeManagedKimiHooks(configText: string): { text: string; changed: boolean } { + // Why: compare instead of MANAGED_BLOCK_RE.test() — the regex carries the `g` + // flag, so .test() advances lastIndex and would behave inconsistently across + // calls. .replace() ignores/resets lastIndex, so it is safe to reuse. + const stripped = configText.replace(MANAGED_BLOCK_RE, '') + if (stripped === configText) { + return { text: configText, changed: false } + } + const trimmed = stripped.replace(/\s+$/, '') + return { text: trimmed.length > 0 ? `${trimmed}\n` : '', changed: true } +} + +// Returns the managed events present in the block whose command still matches an +// Orca-managed script (by filename, so a moved userData path is still swept). +export function readManagedKimiHookEvents( + configText: string, + isManagedCommand: (command: string | undefined) => boolean +): Set { + const present = new Set() + const match = configText.match(MANAGED_BLOCK_RE) + if (!match) { + return present + } + const blockText = match[0] + // Split on each table header and pair the `event`/`command` lines within. + for (const chunk of blockText.split('[[hooks]]').slice(1)) { + const event = chunk.match(/event\s*=\s*"([^"]+)"/)?.[1] + const command = chunk.match(/command\s*=\s*"((?:[^"\\]|\\.)*)"/)?.[1] + if (event && isManagedCommand(command)) { + present.add(event) + } + } + return present +} diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 518b4c784b0..05461437229 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -130,7 +130,8 @@ const WELL_KNOWN_LABELS: Record = { grok: 'Grok', hermes: 'Hermes', devin: 'Devin', - ante: 'Ante' + ante: 'Ante', + kimi: 'Kimi' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 4b4bbd05b4c..b37dabece91 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -419,6 +419,67 @@ describe('shared agent-hook-listener', () => { expect(ended?.payload).toMatchObject({ agentType: 'devin', state: 'done' }) }) + it('normalizes Kimi Code Claude-compatible lifecycle events as kimi status', () => { + const submitted = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + session_id: 'session_abc', + cwd: '/repo', + // Kimi sends the prompt as a content-block array, not a bare string. + prompt: [{ type: 'text', text: 'list the files here' }] + } + }, + 'production' + ) + const tool = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + session_id: 'session_abc', + tool_name: 'Bash', + tool_input: { command: 'ls' } + } + }, + 'production' + ) + const waiting = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'PermissionRequest', session_id: 'session_abc' } + }, + 'production' + ) + const stopped = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'Stop', session_id: 'session_abc' } + }, + 'production' + ) + + expect(submitted?.payload).toMatchObject({ + agentType: 'kimi', + state: 'working', + prompt: 'list the files here' + }) + expect(tool?.payload).toMatchObject({ agentType: 'kimi', state: 'working', toolName: 'Bash' }) + expect(waiting?.payload).toMatchObject({ agentType: 'kimi', state: 'waiting' }) + expect(stopped?.payload).toMatchObject({ agentType: 'kimi', state: 'done' }) + // The Claude-shaped session_id is captured for provider-session resume. + expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) + }) + it('rejects oversized paneKey', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 3f84a7a7df1..bca246a3310 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -311,6 +311,26 @@ type ExtractedPromptText = { | null } +// Joins the `text` of an Anthropic-style content-block array ([{ type: 'text', +// text }, ...]); plain string items are included too. Returns '' when nothing +// textual is present so callers can fall through to the next prompt source. +function contentBlockArrayText(value: unknown[]): string { + const parts: string[] = [] + for (const item of value) { + if (typeof item === 'string') { + parts.push(item) + continue + } + if (item && typeof item === 'object') { + const text = (item as Record).text + if (typeof text === 'string') { + parts.push(text) + } + } + } + return parts.join(' ').replace(/\s+/g, ' ').trim() +} + function extractPromptText(hookPayload: Record): ExtractedPromptText { const candidateKeys = [ 'prompt', @@ -328,6 +348,16 @@ function extractPromptText(hookPayload: Record): ExtractedPromp // surrounding whitespace would otherwise leak into UI and caches. return { text: value.trim(), source: key as Exclude } } + // Why: Kimi Code sends UserPromptSubmit `prompt` as a content-block array + // ([{ type: 'text', text }]) rather than a string. Extract its text for the + // genuine prompt keys. `message` stays string-only: it is the ambiguous + // status/permission field that hasExplicitUserPrompt intentionally distrusts. + if (key !== 'message' && Array.isArray(value)) { + const text = contentBlockArrayText(value) + if (text.length > 0) { + return { text, source: key as Exclude } + } + } } // Why: OpenCode's plugin sends MessagePart events with { role, text }. When // role === 'user', the text *is* the prompt — surface it even though @@ -1804,6 +1834,9 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // typecheck here instead of silently falling through to `false`. switch (source) { case 'claude': + // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit + // is its new-turn boundary too. + case 'kimi': return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -1895,6 +1928,8 @@ function extractToolFields( // typecheck here instead of silently routing through OpenCode's extractor. switch (source) { case 'claude': + // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + case 'kimi': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) @@ -2034,6 +2069,58 @@ function normalizeDevinEvent( ) } +// Why: Kimi Code emits Claude-compatible hook payloads and reuses Claude's +// lifecycle event names (UserPromptSubmit/PreToolUse/Stop/...). Normalize them +// into Orca's shared status states while attributing the status to Kimi so the +// sidebar shows the Kimi icon and label instead of falling back to Claude. +function normalizeKimiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + const stateName = + eventName === 'UserPromptSubmit' || + eventName === 'PreToolUse' || + eventName === 'PostToolUse' || + eventName === 'PostToolUseFailure' + ? 'working' + : eventName === 'PermissionRequest' + ? 'waiting' + : eventName === 'Stop' || eventName === 'StopFailure' + ? 'done' + : null + + if (!stateName) { + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('kimi', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return parseAgentStatusPayload( + JSON.stringify({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('kimi', eventName) + }), + agentType: 'kimi', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + lastAssistantMessage: snapshot.lastAssistantMessage, + interrupted + }) + ) +} + function normalizeGeminiEvent( state: HookListenerState, eventName: unknown, @@ -2974,6 +3061,9 @@ export function normalizeHookPayload( case 'devin': payload = normalizeDevinEvent(state, eventName, promptText, paneKey, hookPayloadRecord) break + case 'kimi': + payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break } // Why: connectionId stays null at the listener layer. The local server keeps @@ -3027,7 +3117,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/grok': 'grok', '/hook/copilot': 'copilot', '/hook/hermes': 'hermes', - '/hook/devin': 'devin' + '/hook/devin': 'devin', + '/hook/kimi': 'kimi' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index dd4b9c9a673..c378e27f0c0 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -47,6 +47,7 @@ export type AgentHookSource = | 'copilot' | 'hermes' | 'devin' + | 'kimi' /** Env marker used by the remote relay. It is a transport/location marker, not * a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */ diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 1410c11d3ed..707a89ffda3 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -16,7 +16,8 @@ export const AGENT_HOOK_TARGETS = [ 'grok', 'copilot', 'hermes', - 'devin' + 'devin', + 'kimi' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 044f2d0f727..4407e58c259 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -107,7 +107,9 @@ export function extractAgentProviderSession( case 'claude': case 'codex': case 'gemini': - case 'droid': { + case 'droid': + // Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_). + case 'kimi': { const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index e33aab8ac37..2df68373451 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -14,7 +14,8 @@ export const AI_VAULT_AGENTS = [ 'grok', 'openclaw', 'devin', - 'droid' + 'droid', + 'kimi' ] as const satisfies readonly TuiAgent[] export type AiVaultAgent = (typeof AI_VAULT_AGENTS)[number] @@ -35,7 +36,8 @@ export const AI_VAULT_AGENT_LABELS = { grok: 'Grok', openclaw: 'OpenClaw', devin: 'Devin', - droid: 'Droid' + droid: 'Droid', + kimi: 'Kimi' } as const satisfies Record export type AiVaultSessionPreviewMessage = { @@ -138,6 +140,10 @@ function buildAgentResumeInvocation( return `${baseCommand} rovodev run --restore ${sessionArg}` case 'opencode': case 'pi': + // Why: Kimi Code resumes with `kimi --session ` (alias `-S`). Sessions + // are work-dir-scoped, so the cwd prefix from buildAiVaultResumeCommand is + // required — resuming from another directory is rejected by the CLI. + case 'kimi': return `${baseCommand} --session ${sessionArg}` case 'copilot': return `${baseCommand} --resume=${sessionArg}`