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