From 21d0391881591dbaffc506168efdfdb811558d86 Mon Sep 17 00:00:00 2001 From: Prethish Kumar <89143162+Prethish-Kumar@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:15:34 +0530 Subject: [PATCH] fix(kimi): map AskUserQuestion to waiting and expose hook status Maps Kimi AskUserQuestion PreToolUse events to waiting, exposes the Kimi hook status IPC/preload surface, and hardens Kimi session parsing plus remote hook path construction. --- .../session-scanner-kimi-parser.test.ts | 7 +++ .../ai-vault/session-scanner-kimi-parser.ts | 7 ++- src/main/ipc/agent-hooks.test.ts | 14 ++++++ src/main/ipc/agent-hooks.ts | 15 ++++++ src/main/kimi/hook-service.ts | 12 +++-- src/preload/index.ts | 3 +- src/shared/agent-hook-listener.test.ts | 49 +++++++++++++++++++ src/shared/agent-hook-listener.ts | 30 ++++++++---- 8 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/main/ai-vault/session-scanner-kimi-parser.test.ts b/src/main/ai-vault/session-scanner-kimi-parser.test.ts index d22f91726..4f4e4afec 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.test.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.test.ts @@ -125,6 +125,13 @@ async function writeKimiSession(args: { } describe('parseKimiSessionFile', () => { + it('returns null for malformed state.json', async () => { + const { file } = await writeKimiSession({}) + await writeFile(file.path, '{not-json') + + await expect(parseKimiSessionFile(file, 'darwin')).resolves.toBeNull() + }) + it('parses a full session from state.json + index + wire transcript', async () => { const { file } = await writeKimiSession({}) const session = await parseKimiSessionFile(file, 'darwin') diff --git a/src/main/ai-vault/session-scanner-kimi-parser.ts b/src/main/ai-vault/session-scanner-kimi-parser.ts index 67600c024..68eeeac4b 100644 --- a/src/main/ai-vault/session-scanner-kimi-parser.ts +++ b/src/main/ai-vault/session-scanner-kimi-parser.ts @@ -34,7 +34,12 @@ export async function parseKimiSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform ): Promise { - const stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + let stateRecord: Record | null + try { + stateRecord = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown) + } catch { + return null + } if (!stateRecord) { return null } diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index ab49c4b68..48201441a 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -83,6 +83,9 @@ vi.mock('../hermes/hook-service', () => ({ vi.mock('../devin/hook-service', () => ({ devinHookService: { getStatus: vi.fn(() => ({ agent: 'devin', state: 'absent' })) } })) +vi.mock('../kimi/hook-service', () => ({ + kimiHookService: { getStatus: vi.fn(() => ({ agent: 'kimi', state: 'absent' })) } +})) beforeEach(() => { dropStatusEntry.mockReset() @@ -237,6 +240,17 @@ describe('agentHooks:devinStatus IPC', () => { }) }) +describe('agentHooks:kimiStatus IPC', () => { + it('returns Kimi hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:kimiStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'kimi', state: 'absent' }) + }) +}) + describe('agentStatus:inferInterrupt IPC', () => { it('forwards valid inference requests to the hook server', async () => { inferInterrupt.mockReturnValue(true) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 948bd5969..f168f8336 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -23,6 +23,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 AgentStatusRuntimeEnrichment = Pick< @@ -70,6 +71,7 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment ipcMain.removeHandler('agentHooks:copilotStatus') ipcMain.removeHandler('agentHooks:hermesStatus') ipcMain.removeHandler('agentHooks:devinStatus') + ipcMain.removeHandler('agentHooks:kimiStatus') ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot') @@ -286,4 +288,17 @@ export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment } } }) + ipcMain.handle('agentHooks:kimiStatus', (): AgentHookInstallStatus => { + try { + return kimiHookService.getStatus() + } catch (err) { + return { + agent: 'kimi', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) } diff --git a/src/main/kimi/hook-service.ts b/src/main/kimi/hook-service.ts index 40a197fef..2b5177b78 100644 --- a/src/main/kimi/hook-service.ts +++ b/src/main/kimi/hook-service.ts @@ -8,7 +8,7 @@ import { writeFileSync } from 'fs' import { homedir } from 'os' -import { dirname, join } from 'path' +import { dirname, join, posix as pathPosix } from 'path' import { randomUUID } from 'crypto' import type { SFTPWrapper } from 'ssh2' import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' @@ -193,9 +193,13 @@ export class KimiHookService { // the local install. POSIX-only by design (Kimi's shell is sh/Git Bash); the // managed script body is already platform-independent. async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { - const home = remoteHome.replace(/\/$/, '') - const remoteConfigPath = `${home}/.kimi-code/config.toml` - const remoteScriptPath = `${home}/.orca/agent-hooks/${MANAGED_SCRIPT_FILE_NAME}` + const remoteConfigPath = pathPosix.join(remoteHome, '.kimi-code', 'config.toml') + const remoteScriptPath = pathPosix.join( + remoteHome, + '.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)) ?? '' diff --git a/src/preload/index.ts b/src/preload/index.ts index cf336af41..c0a35cf2b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1699,7 +1699,8 @@ const api = { copilotStatus: (): Promise => ipcRenderer.invoke('agentHooks:copilotStatus'), hermesStatus: (): Promise => - ipcRenderer.invoke('agentHooks:hermesStatus') + ipcRenderer.invoke('agentHooks:hermesStatus'), + kimiStatus: (): Promise => ipcRenderer.invoke('agentHooks:kimiStatus') }, agentTrust: { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index b37dabece..daf6f9690 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -480,6 +480,55 @@ describe('shared agent-hook-listener', () => { expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) }) + it('maps Kimi AskUserQuestion PreToolUse to waiting, then back to working on answer', () => { + const question = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + session_id: 'session_abc', + tool_name: 'AskUserQuestion', + tool_input: { + questions: [ + { + question: 'Which region should I deploy to?', + options: [{ label: 'us-east', description: 'US East' }] + } + ] + } + } + }, + 'production' + ) + const answered = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PostToolUse', + session_id: 'session_abc', + tool_name: 'AskUserQuestion', + tool_response: { selected: ['us-east'] } + } + }, + 'production' + ) + + expect(question?.payload).toMatchObject({ + agentType: 'kimi', + state: 'waiting', + toolName: 'AskUserQuestion' + }) + expect(answered?.payload).toMatchObject({ + agentType: 'kimi', + state: 'working', + toolName: 'AskUserQuestion' + }) + }) + it('rejects oversized paneKey', () => { const event = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index bca246a33..20e79ae1c 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2069,6 +2069,13 @@ function normalizeDevinEvent( ) } +// Why: Kimi's AskUserQuestion tool is auto-allowed, so it emits PreToolUse +// instead of PermissionRequest while blocked on a human answer. Treat it as a +// waiting state so the UI shows the attention icon instead of the working spinner. +function isKimiUserInputTool(toolName: string | undefined): boolean { + return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' +} + // 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 @@ -2080,17 +2087,22 @@ function normalizeKimiEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { - const stateName = + const toolName = readString(hookPayload, 'tool_name') + const isUserInputTool = isKimiUserInputTool(toolName) + + let stateName: 'working' | 'waiting' | 'done' | null = null + if ( eventName === 'UserPromptSubmit' || - eventName === 'PreToolUse' || eventName === 'PostToolUse' || - eventName === 'PostToolUseFailure' - ? 'working' - : eventName === 'PermissionRequest' - ? 'waiting' - : eventName === 'Stop' || eventName === 'StopFailure' - ? 'done' - : null + eventName === 'PostToolUseFailure' || + (eventName === 'PreToolUse' && !isUserInputTool) + ) { + stateName = 'working' + } else if (eventName === 'PermissionRequest' || (eventName === 'PreToolUse' && isUserInputTool)) { + stateName = 'waiting' + } else if (eventName === 'Stop' || eventName === 'StopFailure') { + stateName = 'done' + } if (!stateName) { return null