mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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_')
|
||||
})
|
||||
])
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user