From 0571e45107b751bb6589a8a0fdab67037005e945 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:53:58 -0700 Subject: [PATCH] fix(kimi): keep a resumed Kimi pane resumable across the next Orca restart (#31311) (#33489) Kimi sessions keep resuming across repeated Orca restarts: register Kimi's SessionStart hook (matcher ^resume$, so new sessions pay nothing) and map a resume to an idle session-start status that refreshes the resume record; recognise npm-installed Kimi (node_modules/@moonshot-ai/kimi-code/dist/main.mjs) on Windows so the pane isn't mistaken for a shell. Fixes #31311 --- src/main/kimi/kimi-hook-config-toml.test.ts | 5 ++-- src/main/kimi/kimi-hook-config-toml.ts | 5 +++- ...listener-claude-compatible-vendors.test.ts | 23 +++++++++++++++++++ .../providers/kimi-events.ts | 12 ++++++++++ .../agent-node-entrypoint-identities.ts | 7 ++++++ src/shared/agent-process-recognition.test.ts | 10 ++++++++ 6 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/main/kimi/kimi-hook-config-toml.test.ts b/src/main/kimi/kimi-hook-config-toml.test.ts index dbf7f3b44ba..a0525e61364 100644 --- a/src/main/kimi/kimi-hook-config-toml.test.ts +++ b/src/main/kimi/kimi-hook-config-toml.test.ts @@ -21,13 +21,14 @@ function deleteEndMarker(text: string): string { } describe('kimi managed hooks TOML block', () => { - it('installs every managed event without a matcher', () => { + it('installs every managed event, scoping only SessionStart to resumes', () => { 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(block.match(/matcher = .*/g)).toEqual(['matcher = "^resume$"']) + expect(block).toContain('event = "SessionStart"\nmatcher = "^resume$"') expect( readManagedKimiHookEvents(applyManagedKimiHooks('', COMMAND, isManaged), isManaged) ).toEqual(new Set(KIMI_HOOK_EVENTS)) diff --git a/src/main/kimi/kimi-hook-config-toml.ts b/src/main/kimi/kimi-hook-config-toml.ts index b7f213ffdfc..64653ac100e 100644 --- a/src/main/kimi/kimi-hook-config-toml.ts +++ b/src/main/kimi/kimi-hook-config-toml.ts @@ -11,6 +11,7 @@ import { } from '../agent-hooks/managed-toml-ownership' export const KIMI_HOOK_EVENTS = [ + 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', @@ -123,11 +124,13 @@ function tomlBasicString(value: string): string { export function buildManagedKimiHooksBlock(command: string, eol = '\n'): string { const commandLiteral = tomlBasicString(command) - // Omit matcher: Kimi treats it as regex, so * is invalid. Host timeout backs up the normal curl deadline. + // Kimi treats matcher as regex (so * is invalid); omit it except to scope SessionStart. Host timeout backs up the normal curl deadline. const entries = KIMI_HOOK_EVENTS.map((event) => [ HOOK_TABLE_HEADER, `event = "${event}"`, + // Why: Kimi awaits SessionStart during a new session's first prompt; only a resume is used. + ...(event === 'SessionStart' ? ['matcher = "^resume$"'] : []), `command = ${commandLiteral}`, `timeout = ${MANAGED_HOOK_TIMEOUT_SECONDS}` ].join(eol) diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 28d450ae0b3..9256d71eefb 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -116,6 +116,29 @@ describe('shared agent-hook-listener', () => { expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) }) + // #31311: a resumed Kimi pane must report its session before any prompt, or quit captures nothing. + it('lands a resumed Kimi SessionStart as an idle boundary carrying its session', () => { + const sessionStart = (source: string) => + normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', session_id: 'session_abc', source } + }, + 'production' + ) + + const resumed = sessionStart('resume') + expect(resumed?.payload).toMatchObject({ + agentType: 'kimi', + state: 'done', + sessionBoundary: true + }) + expect(resumed?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) + expect(sessionStart('startup')).toBeNull() + }) + // Why: Kimi shares Claude-compatible compact/harness hooks; cover the same sticky-working // guards so a Kimi-only regression cannot slip past the Claude-only tests (issue #11352). it('ignores harness-injected UserPromptSubmit for Kimi', () => { diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi-events.ts index c35bfef17d0..0283e2d52b2 100644 --- a/src/shared/agent-hook-listener/providers/kimi-events.ts +++ b/src/shared/agent-hook-listener/providers/kimi-events.ts @@ -27,6 +27,18 @@ export function normalizeKimiEvent( if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { return null } + if (eventName === 'SessionStart') { + // Why: a resumed session's only pre-prompt signal, so its id stays resumable at the next quit + // (#31311); `startup` fires mid-submit of the first message, so it is not an idle boundary. + return hookPayload['source'] === 'resume' + ? normalizeAgentStatusPayload({ + state: 'done', + prompt: '', + agentType: 'kimi', + sessionBoundary: true + }) + : null + } const toolName = readString(hookPayload, 'tool_name') const isUserInputTool = isKimiUserInputTool(toolName) diff --git a/src/shared/agent-node-entrypoint-identities.ts b/src/shared/agent-node-entrypoint-identities.ts index 8673b0a7239..d6741a022f7 100644 --- a/src/shared/agent-node-entrypoint-identities.ts +++ b/src/shared/agent-node-entrypoint-identities.ts @@ -49,5 +49,12 @@ export const EXACT_NODE_ENTRYPOINT_IDENTITIES: readonly { /(?:^|\/)(?:node_modules\/@qwen-code\/qwen-code\/(?:cli-entry|cli|scripts\/cli-entry|dist\/cli)|qwen-code\/lib\/cli(?:-entry)?)\.js$/, agent: 'qwen-code', processName: 'qwen' + }, + // Why: Kimi Code's npm bin is a generic `dist/main.mjs`, and on Windows its process stays + // node.exe instead of retitling to `kimi-code`, so only the package path identifies it. + { + pattern: /(?:^|\/)node_modules\/@moonshot-ai\/kimi-code\/dist\/main\.mjs$/, + agent: 'kimi', + processName: 'kimi' } ] diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index cee86127341..e8a53e16912 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -306,6 +306,16 @@ describe('agent process recognition', () => { expect(isRecognizedAgentType('kimi-code')).toBe(true) }) + // #31311: the npm install on Windows runs as node.exe, observed with this exact .bin shim path. + it('recognizes Kimi Code launched by its npm shim on Windows', () => { + expect( + recognizeAgentProcessFromCommandLine( + '"C:\\Program Files\\nodejs\\node.exe" C:\\Users\\dev\\proj\\node_modules\\.bin/../@moonshot-ai/kimi-code/dist/main.mjs --session session_1' + ) + ).toEqual({ agent: 'kimi', processName: 'kimi' }) + expect(recognizeAgentProcessFromCommandLine('node C:\\tools\\app\\dist\\main.mjs')).toBeNull() + }) + it('recognizes Qwen Code by its installed qwen executable', () => { expect(recognizeAgentProcess('/home/dev/.local/bin/qwen')).toEqual({ agent: 'qwen-code',