feat(kimi): Kimi Code sessions in AI Vault + agent status hooks

Adds Kimi Code session parsing for AI Vault and managed Kimi agent status hooks.
This commit is contained in:
Neil
2026-06-19 16:42:59 -07:00
committed by GitHub
parent 9600e15757
commit 4eb3e75b9e
22 changed files with 1333 additions and 12 deletions
+2
View File
@@ -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"
@@ -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(
@@ -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({
@@ -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(
@@ -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)
}
}
@@ -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: '<system-reminder>\nAuto permission mode is active.\n</system-reminder>'
}
],
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<string, unknown>
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 <id>` 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')
})
})
@@ -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/<id>/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<AiVaultSession | null> {
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<void> {
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)
)
}
@@ -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 <KIMI_CODE_HOME>/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: <home>/sessions/wd_<name>_<hash>/session_<uuid>/state.json
// The session id is the session directory name (it keeps the `session_` prefix,
// which is exactly what `kimi --session <id>` 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_<uuid>
const workspaceDir = dirname(sessionDir) // .../wd_<name>_<hash>
const sessionsDir = dirname(workspaceDir) // .../sessions
const home = dirname(sessionsDir) // .../<KIMI_CODE_HOME>
return join(home, 'session_index.jsonl')
}
// Why: the primary agent transcript lives at <sessionDir>/agents/<id>/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<string, unknown> | 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<Map<string, string>>
}
// 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<string, WorkDirCacheEntry>()
export function clearKimiSessionIndexCache(): void {
workDirCacheByIndexPath.clear()
}
export async function readKimiWorkDirBySessionId(indexPath: string): Promise<Map<string, string>> {
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<Map<string, string>> {
const map = new Map<string, string>()
// 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<string, unknown> | 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
}
@@ -22,6 +22,7 @@ export type AiVaultScanOptions = {
piSessionsDir?: string
droidSessionsDir?: string
droidProjectsDir?: string
kimiSessionsDir?: string
limit?: number
limitPerAgent?: number
platform?: NodeJS.Platform
+54 -1
View File
@@ -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: <sessions>/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'"
)
})
})
+13 -1
View File
@@ -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 <sessions>/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_')
})
])
+87
View File
@@ -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)
})
})
+244
View File
@@ -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<string>, 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<AgentHookInstallStatus> {
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()
+109
View File
@@ -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))
})
})
+103
View File
@@ -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<string> {
const present = new Set<string>()
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
}
+2 -1
View File
@@ -130,7 +130,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
grok: 'Grok',
hermes: 'Hermes',
devin: 'Devin',
ante: 'Ante'
ante: 'Ante',
kimi: 'Kimi'
}
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
+61
View File
@@ -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,
+92 -1
View File
@@ -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<string, unknown>).text
if (typeof text === 'string') {
parts.push(text)
}
}
}
return parts.join(' ').replace(/\s+/g, ' ').trim()
}
function extractPromptText(hookPayload: Record<string, unknown>): ExtractedPromptText {
const candidateKeys = [
'prompt',
@@ -328,6 +348,16 @@ function extractPromptText(hookPayload: Record<string, unknown>): ExtractedPromp
// surrounding whitespace would otherwise leak into UI and caches.
return { text: value.trim(), source: key as Exclude<ExtractedPromptText['source'], null> }
}
// 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<ExtractedPromptText['source'], null> }
}
}
}
// 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<string, unknown>
): 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<Record<string, AgentHookSource>>
'/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 {
+1
View File
@@ -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. */
+2 -1
View File
@@ -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]
+3 -1
View File
@@ -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_<uuid>).
case 'kimi': {
const id = readSessionId(payload, ['session_id'])
return id ? { key: 'session_id', id } : null
}
+8 -2
View File
@@ -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<AiVaultAgent, string>
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 <id>` (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}`