From 763f45acddcfef77ca8d741e50344bc5b4279623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=87=8C?= Date: Fri, 22 May 2026 10:08:14 +0800 Subject: [PATCH] feat: add support for Kimi Code CLI and fixed some bugs (#659) * Add Kimi agent support via ACP protocol Add full integration for the Kimi Code CLI agent using the standard Agent Client Protocol (ACP). Includes: - kimi command and CLI registry wiring - Local launcher spawning kimi directly - Remote launcher with ACP stdio transport via AcpSdkBackend - Session management with resume support - Permission handler supporting all Kimi permission modes - Terminal UI display component - Runtime config resolving model from env and ~/.kimi/config.toml via [HAPI](https://hapi.run) Co-Authored-By: HAPI * Fix Kimi ACP tool call input decoding on web Kimi streams tool arguments as JSON text inside the content array (e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler now extracts input from three sources in priority order: 1. rawInput (Claude/Codex path) 2. kind + title fallback (Gemini path) 3. content JSON text (Kimi path) Also handles: - rawInput: null no longer blocks the kind+title fallback - Title prefixes like \"Shell: free -h\" are stripped to extract args - Stale placeholder inputs are re-derived when the title updates - Normalized kind aliases (shell, run, read_file, write, etc.) via [HAPI](https://hapi.run) Co-Authored-By: HAPI * Add kimi support to web UI * Fix some bugs * fix(kimi): dedupe repeated tool_call display in terminal UI * fix(web): keep tool block immutable so React detects input/state changes * fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools * fix(web): allow-for-session for ACP agents (kimi, cursor) PermissionFooter treated all non-codex sessions as Claude, sending Claude-specific acceptEdits/allowTools to ACP agents that don't support them. Hub rejected acceptEdits for kimi, and the ACP PermissionAdapter ignored allowTools. - Only show 'allow all edits' for Claude sessions - Send decision: approved_for_session for non-Claude ACP agents - Update status display to check decision field * fix(web): lookup subagent sidechains by tool-call id instead of msg id * fix(web): don't trim newest messages when loading older history fetchOlderMessages was using trimVisible(merged, 'prepend') which kept the oldest 400 messages and dropped the newest ones. This caused: 1. Latest messages to disappear when user loaded older history 2. User to see no visible change when new old messages were drowned in the 400-message window. Remove the incorrect trim so all fetched older messages are retained alongside the current window. Subsequent ingestIncomingMessages (append mode) will naturally keep the window bounded when new agent messages arrive. * fix(cli): route Kimi session resume to runKimi instead of runCursor Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch for it, so resuming a Kimi session fell through to the Cursor launcher. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR. Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): bound message window on older loads with dedicated larger cap fetchOlderMessages was keeping all messages unbounded, causing sessionStorage bloat on repeated pagination. Reintroduce trimming with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the newest messages are still preserved for far longer than before. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline tracer.ts sets sidechainId to the parent message id, and reducer.ts groups by sidechainId. A prior commit changed reducerTimeline.ts to look up by tool-call id (c.id), which broke sidechain attachment. Revert to msg.id so the lookup matches the actual grouping key end-to-end. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(cli): gate ACP title prefix stripping to known tool-kind labels extractTitleArgument stripped at the first colon unconditionally, corrupting commands/paths like curl http://localhost:3000 or Windows paths. Now it only strips when the prefix normalizes to the same tool kind as the event, verified via regex. via [HAPI](https://hapi.run) Co-Authored-By: HAPI * fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI Kimi is an ACP-style agent that supports the abort decision, but isCodexFamilyFlavor excluded it, so PermissionFooter rendered the non-Codex Allow/Deny UI without the Abort button. via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- bun.lock | 2 + .../backends/acp/AcpMessageHandler.test.ts | 153 +++++++++ .../agent/backends/acp/AcpMessageHandler.ts | 148 ++++++++- cli/src/agent/sessionFactory.ts | 1 + cli/src/agent/utils.ts | 10 +- cli/src/commands/kimi.ts | 73 +++++ cli/src/commands/registry.ts | 2 + cli/src/commands/resume.ts | 15 + cli/src/kimi/kimiLocal.ts | 47 +++ cli/src/kimi/kimiLocalLauncher.ts | 49 +++ cli/src/kimi/kimiRemoteLauncher.ts | 300 ++++++++++++++++++ cli/src/kimi/loop.ts | 64 ++++ cli/src/kimi/runKimi.ts | 189 +++++++++++ cli/src/kimi/session.ts | 76 +++++ cli/src/kimi/types.ts | 8 + cli/src/kimi/utils/config.ts | 103 ++++++ cli/src/kimi/utils/kimiBackend.ts | 30 ++ cli/src/kimi/utils/permissionHandler.ts | 170 ++++++++++ cli/src/runner/run.ts | 8 +- cli/src/ui/ink/KimiDisplay.tsx | 187 +++++++++++ hub/src/sync/syncEngine.ts | 1 + shared/src/flavors.ts | 4 +- shared/src/modes.ts | 9 +- shared/src/schemas.ts | 1 + shared/src/sessionSummary.ts | 1 + shared/src/types.ts | 1 + web/src/chat/reducerTimeline.test.ts | 2 +- web/src/chat/reducerTimeline.ts | 45 +-- web/src/chat/reducerTools.ts | 14 +- web/src/chat/subagentTool.ts | 2 +- .../components/AssistantChat/modelOptions.ts | 7 + web/src/components/NewSession/types.ts | 3 + web/src/components/SessionList.tsx | 4 + .../components/ToolCard/PermissionFooter.tsx | 19 +- web/src/lib/message-window-store.ts | 3 +- 35 files changed, 1697 insertions(+), 54 deletions(-) create mode 100644 cli/src/commands/kimi.ts create mode 100644 cli/src/kimi/kimiLocal.ts create mode 100644 cli/src/kimi/kimiLocalLauncher.ts create mode 100644 cli/src/kimi/kimiRemoteLauncher.ts create mode 100644 cli/src/kimi/loop.ts create mode 100644 cli/src/kimi/runKimi.ts create mode 100644 cli/src/kimi/session.ts create mode 100644 cli/src/kimi/types.ts create mode 100644 cli/src/kimi/utils/config.ts create mode 100644 cli/src/kimi/utils/kimiBackend.ts create mode 100644 cli/src/kimi/utils/permissionHandler.ts create mode 100644 cli/src/ui/ink/KimiDisplay.tsx diff --git a/bun.lock b/bun.lock index c63de863..e3d59194 100644 --- a/bun.lock +++ b/bun.lock @@ -1065,6 +1065,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.18.4", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-VHdCKlJ3G1JOqVG7lDpeY7RJflfRp1qHb6WMuXf/a5g7g/UljPmLH8QfF/H/DbOXjuLVcBeqihEqBngYu+CIlw=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.18.3", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-kEG8tH5OcY/dRBpqbLmb3q2CS0UiM9L3OQX0LWvCn1aI3JQKaaZvzXElCXuwiIml0AI4fBAfofzUB/E31lxffw=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts index 0922ce0c..ed699f59 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.test.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.test.ts @@ -405,6 +405,159 @@ describe('AcpMessageHandler', () => { expect(calls[1].name).toBe('hapi_change_title'); }); + it('falls back to kind+title derivation when rawInput is explicitly null', () => { + // Kimi ACP sends rawInput: null on tool_call events. It must not be + // treated as a valid input — the kind+title fallback should still run. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'tool-null-1', + title: 'df -hT', + kind: 'execute', + rawInput: null, + status: 'in_progress' + }); + + const toolCall = messages.find( + (m): m is Extract => m.type === 'tool_call' + ); + expect(toolCall).toBeDefined(); + expect(toolCall!.input).toEqual({ command: 'df -hT' }); + }); + + it('strips "Shell: " prefix from title when deriving execute input (Kimi)', () => { + // Kimi sends titles like "Shell: free -h" where the part after the colon + // is the actual command. The prefix must be stripped so the derived input + // contains the command, not the label. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'kimi-shell-1', + title: 'Shell: free -h', + kind: 'shell', + rawInput: null, + status: 'in_progress' + }); + + const toolCall = messages.find( + (m): m is Extract => m.type === 'tool_call' + ); + expect(toolCall).toBeDefined(); + expect(toolCall!.input).toEqual({ command: 'free -h' }); + }); + + it('re-derives input when title changes from generic to concrete (Kimi)', () => { + // Kimi sends an initial tool_call with a generic title ("Shell") and later + // updates it to a concrete one ("Shell: free -h"). The input must be + // re-derived from the new title, not left as the stale placeholder. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'kimi-shell-2', + title: 'Shell', + kind: 'shell', + rawInput: null, + status: 'in_progress' + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'kimi-shell-2', + title: 'Shell: free -h', + kind: 'shell', + rawInput: null, + status: 'completed' + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + expect(calls).toHaveLength(2); + // Initial call: derived from generic title (placeholder) + expect(calls[0].input).toEqual({ command: 'Shell' }); + // Updated call: re-derived from concrete title + expect(calls[1].input).toEqual({ command: 'free -h' }); + }); + + it('extracts tool input from content JSON text (Kimi ACP)', () => { + // Kimi ACP does not send rawInput or kind. Instead it streams tool + // arguments as JSON text inside the content array. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'kimi-json-1', + title: 'Shell', + status: 'in_progress', + content: [{ type: 'content', content: { type: 'text', text: '' } }] + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'kimi-json-1', + title: 'Shell: df -h', + status: 'in_progress', + content: [{ type: 'content', content: { type: 'text', text: '{"command": "df -h"}' } }] + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + expect(calls).toHaveLength(2); + // Initial call has empty content → input is null + expect(calls[0].input).toBeNull(); + // Update has JSON content → input is parsed + expect(calls[1].input).toEqual({ command: 'df -h' }); + }); + + it('falls back to kind+title on tool_call_update when rawInput is null', () => { + // Initial tool_call has no rawInput key at all → input is derived. + // Subsequent update sends rawInput: null → falls through to enrichment + // branch, but since input was already derived, no re-emit is needed. + const messages: AgentMessage[] = []; + const handler = new AcpMessageHandler((message) => messages.push(message)); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCall, + toolCallId: 'tool-null-2', + title: 'cat README.md', + kind: 'read', + status: 'in_progress' + }); + + handler.handleUpdate({ + sessionUpdate: ACP_SESSION_UPDATE_TYPES.toolCallUpdate, + toolCallId: 'tool-null-2', + title: 'cat README.md', + kind: 'read', + rawInput: null, + status: 'completed' + }); + + const calls = messages.filter( + (m): m is Extract => m.type === 'tool_call' + ); + // Only one tool_call emitted (the initial one); the completed update + // does not re-emit because the input was already derived. + expect(calls).toHaveLength(1); + expect(calls[0].input).toEqual({ file_path: 'cat README.md' }); + expect(calls[0].status).toBe('in_progress'); + + // The tool_result should still be emitted + const results = messages.filter( + (m): m is Extract => m.type === 'tool_result' + ); + expect(results).toHaveLength(1); + expect(results[0].status).toBe('completed'); + }); + it('intercepts rate_limit_event chunk before it enters the text buffer', () => { const messages: AgentMessage[] = []; const handler = new AcpMessageHandler((message) => messages.push(message)); diff --git a/cli/src/agent/backends/acp/AcpMessageHandler.ts b/cli/src/agent/backends/acp/AcpMessageHandler.ts index 8998d2e7..68a9ac00 100644 --- a/cli/src/agent/backends/acp/AcpMessageHandler.ts +++ b/cli/src/agent/backends/acp/AcpMessageHandler.ts @@ -39,6 +39,40 @@ function deriveToolNameFromUpdate(update: Record): DerivedToolN }); } +/** + * Normalises a kind string to a canonical category. Different ACP agents + * (Gemini, OpenCode, Kimi) use different vocabulary for the same semantic + * operation; mapping them here keeps the rest of the handler agent-agnostic. + */ +function normalizeToolKind(kind: string | null): 'read' | 'execute' | 'search' | 'edit' | 'think' | null { + if (!kind) return null; + const k = kind.toLowerCase().trim(); + if (k === 'read' || k === 'read_file' || k === 'file_read' || k === 'view') return 'read'; + if (k === 'execute' || k === 'shell' || k === 'bash' || k === 'run' || k === 'run_shell' || k === 'run_shell_command' || k === 'cmd' || k === 'terminal') return 'execute'; + if (k === 'search' || k === 'grep' || k === 'find' || k === 'glob') return 'search'; + if (k === 'edit' || k === 'write' || k === 'write_file' || k === 'replace' || k === 'file_edit' || k === 'modify') return 'edit'; + if (k === 'think' || k === 'thought' || k === 'reasoning') return 'think'; + return null; +} + +/** + * Extracts the argument from a title that uses a "Category: argument" pattern. + * Many ACP agents (notably Kimi) emit titles like "Shell: free -h" or + * "Read: README.md" where the part after the colon is the actual tool argument. + * + * Only strips the prefix when the label before the colon normalizes to the + * same tool kind, so valid commands/paths that contain colons (e.g. + * curl http://localhost:3000, git commit -m "feat: add Kimi") are not corrupted. + * Returns the raw title when no matching prefix is found. + */ +function extractTitleArgument(title: string, kind: string | null): string { + const normalizedKind = normalizeToolKind(kind); + const match = title.match(/^([A-Za-z][A-Za-z _-]{0,31}):\s+(.+)$/); + if (!match) return title; + const labelKind = normalizeToolKind(match[1]); + return labelKind && labelKind === normalizedKind ? match[2] : title; +} + /** * Fallback for ACP agents that omit `rawInput` and emit prose thoughts * (no JSON-form to hoist). The `tool_call` event still carries a @@ -62,25 +96,86 @@ function deriveInputFromKindAndTitle( title: string | null, locations: unknown ): Record | null { - if (kind === 'edit') { + const normalizedKind = normalizeToolKind(kind); + if (normalizedKind === 'edit') { const arr = Array.isArray(locations) ? locations : []; const first = arr[0]; const path = isObject(first) ? asString(first.path) : null; return path ? { file_path: path } : null; } if (!title) return null; - switch (kind) { + const arg = extractTitleArgument(title, kind); + switch (normalizedKind) { case 'read': - return { file_path: title }; + return { file_path: arg }; case 'execute': - return { command: title }; + return { command: arg }; case 'search': - return { pattern: title }; + return { pattern: arg }; default: return null; } } +/** + * Kimi ACP streams tool arguments as JSON text inside the `content` array + * (e.g. `[{type:'content', content:{type:'text', text:'{"command":"df -h"}'}}]`) + * instead of using `rawInput`. This helper extracts and parses that JSON. + * + * Returns the parsed object when the content is a single text block whose text + * is valid JSON object / array. Returns null for anything else so callers can + * keep their existing fallback. + */ +function extractJsonInputFromContent(content: unknown): Record | unknown[] | null { + if (!Array.isArray(content) || content.length !== 1) return null; + const block = content[0]; + if (!isObject(block)) return null; + if (block.type !== 'content') return null; + const inner = block.content; + if (!isObject(inner)) return null; + if (inner.type !== 'text') return null; + const text = typeof inner.text === 'string' ? inner.text : null; + if (!text || text.trim().length === 0) return null; + // Defensive: only parse when it looks like JSON (starts with { or [) + const trimmed = text.trim(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null; + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === 'object' && parsed !== null) { + return parsed as Record | unknown[]; + } + return null; + } catch { + return null; + } +} + +/** + * Detects whether an existing tool input was derived from a placeholder title + * that did not yet contain the actual argument. This happens with agents like + * Kimi that send an initial tool_call with a generic title ("Shell") and later + * update it to a concrete one ("Shell: free -h"). + * + * Returns true when: + * - the update title contains a colon (indicating it carries the real arg) + * - the existing input is a derived object whose value matches the OLD title + */ +function isStaleDerivedInput(existingInput: unknown, updateTitle: string | null, kind: string | null): boolean { + if (!updateTitle) return false; + const arg = extractTitleArgument(updateTitle, kind); + // No colon in title — nothing to extract, not stale + if (arg === updateTitle) return false; + if (!isObject(existingInput)) return false; + const values = Object.values(existingInput); + for (const value of values) { + if (typeof value === 'string' && value.trim() === arg) { + // Input already matches the new argument — not stale + return false; + } + } + return true; +} + type HoistedDiff = | { name: 'Write'; input: { file_path: string; content: string } } | { name: 'Edit'; input: { file_path: string; old_string: string; new_string: string } }; @@ -548,11 +643,21 @@ export class AcpMessageHandler { metaKind: null }); const name = derivedName.name; - // Priority: rawInput > kind+title fallback. - // Use `in` to distinguish "rawInput key absent" from "rawInput is {}". - const input = 'rawInput' in update - ? update.rawInput - : deriveInputFromKindAndTitle(asString(update.kind), asString(update.title), update.locations); + // Priority: rawInput > kind+title fallback > content JSON fallback. + // Kimi ACP streams tool arguments as JSON text in the content array + // instead of rawInput/kind. Try all three sources. + let input: unknown; + if (update.rawInput != null) { + input = update.rawInput; + } else { + const fromKindTitle = deriveInputFromKindAndTitle(asString(update.kind), asString(update.title), update.locations); + if (fromKindTitle) { + input = fromKindTitle; + } else { + const fromContent = extractJsonInputFromContent(update.content); + input = fromContent; + } + } const status = normalizeStatus(update.status); this.toolCalls.set(toolCallId, { name, input }); @@ -573,7 +678,7 @@ export class AcpMessageHandler { const status = normalizeStatus(update.status); const existing = this.toolCalls.get(toolCallId); - if (update.rawInput !== undefined) { + if (update.rawInput != null) { const derivedName = deriveToolNameFromUpdate(update); const name = this.selectToolNameForUpdate(existing?.name ?? null, derivedName); const input = update.rawInput; @@ -591,16 +696,31 @@ export class AcpMessageHandler { // enriched the input or when the call is still active. let input = existing.input; let name = existing.name; - if (input == null) { - const fallback = deriveInputFromKindAndTitle(asString(update.kind), asString(update.title), update.locations); + let rederived = false; + const updateTitle = asString(update.title); + if (input == null || isStaleDerivedInput(input, updateTitle, asString(update.kind))) { + const fallback = deriveInputFromKindAndTitle(asString(update.kind), updateTitle, update.locations); if (fallback) { input = fallback; const derivedName = deriveToolNameFromUpdate(update); name = this.selectToolNameForUpdate(existing.name ?? null, derivedName); this.toolCalls.set(toolCallId, { name, input }); + rederived = true; } } - const justEnriched = existing.input == null && input != null; + // Kimi ACP streams tool arguments as JSON text in the content array. + // If we still don't have a useful input, try to parse the content. + if (!rederived && (input == null || isStaleDerivedInput(input, updateTitle, asString(update.kind)))) { + const fromContent = extractJsonInputFromContent(update.content); + if (fromContent && isObject(fromContent)) { + input = fromContent; + const derivedName = deriveToolNameFromUpdate(update); + name = this.selectToolNameForUpdate(existing.name ?? null, derivedName); + this.toolCalls.set(toolCallId, { name, input }); + rederived = true; + } + } + const justEnriched = (existing.input == null && input != null) || rederived; if (status === 'in_progress' || status === 'pending' || justEnriched) { this.onMessage({ type: 'tool_call', diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index ebe2a4cc..a71a2be1 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -98,6 +98,7 @@ function pickExistingSessionMetadata(metadata: Metadata | null | undefined): Par if (metadata.geminiSessionId !== undefined) preserved.geminiSessionId = metadata.geminiSessionId if (metadata.opencodeSessionId !== undefined) preserved.opencodeSessionId = metadata.opencodeSessionId if (metadata.cursorSessionId !== undefined) preserved.cursorSessionId = metadata.cursorSessionId + if (metadata.kimiSessionId !== undefined) preserved.kimiSessionId = metadata.kimiSessionId if (metadata.tools !== undefined) preserved.tools = metadata.tools if (metadata.slashCommands !== undefined) preserved.slashCommands = metadata.slashCommands if (metadata.worktree !== undefined) preserved.worktree = metadata.worktree diff --git a/cli/src/agent/utils.ts b/cli/src/agent/utils.ts index de5915c1..110cba3c 100644 --- a/cli/src/agent/utils.ts +++ b/cli/src/agent/utils.ts @@ -38,9 +38,13 @@ export function deriveToolNameWithSource(input: { } } - // Gemini ACP: kind=edit with _meta.kind distinguishes write_file (add) from replace (modify). - // Map to the canonical Claude tool names so existing Write/Edit registry entries are reused. - if (input.kind === 'edit') { + // ACP agents (Gemini, Kimi) use kind=edit/write/replace with _meta.kind to + // distinguish write_file (add) from replace (modify). Normalise the kind + // so aliases like 'write', 'replace', 'modify' are handled the same way. + const normalizedKind = typeof input.kind === 'string' + ? input.kind.toLowerCase().trim() + : null; + if (normalizedKind === 'edit' || normalizedKind === 'write' || normalizedKind === 'write_file' || normalizedKind === 'replace' || normalizedKind === 'modify' || normalizedKind === 'file_edit') { if (input.metaKind === 'add') { return { name: 'Write', source: 'kind' }; } diff --git a/cli/src/commands/kimi.ts b/cli/src/commands/kimi.ts new file mode 100644 index 00000000..06a10e2f --- /dev/null +++ b/cli/src/commands/kimi.ts @@ -0,0 +1,73 @@ +import chalk from 'chalk' +import { authAndSetupMachineIfNeeded } from '@/ui/auth' +import { initializeToken } from '@/ui/tokenInit' +import { maybeAutoStartServer } from '@/utils/autoStartServer' +import type { CommandDefinition } from './types' +import { KIMI_PERMISSION_MODES } from '@hapi/protocol/modes' +import type { KimiPermissionMode } from '@hapi/protocol/types' + +export const kimiCommand: CommandDefinition = { + name: 'kimi', + requiresRuntimeAssets: true, + run: async ({ commandArgs }) => { + try { + const options: { + startedBy?: 'runner' | 'terminal' + startingMode?: 'local' | 'remote' + permissionMode?: KimiPermissionMode + model?: string + resumeSessionId?: string + } = {} + + let hasExplicitPermissionMode = false + + for (let i = 0; i < commandArgs.length; i++) { + const arg = commandArgs[i] + if (arg === '--started-by') { + options.startedBy = commandArgs[++i] as 'runner' | 'terminal' + } else if (arg === '--hapi-starting-mode') { + const value = commandArgs[++i] + if (value === 'local' || value === 'remote') { + options.startingMode = value + } else { + throw new Error('Invalid --hapi-starting-mode (expected local or remote)') + } + } else if (arg === '--permission-mode') { + const mode = commandArgs[++i] + if (!mode || !(KIMI_PERMISSION_MODES as readonly string[]).includes(mode)) { + throw new Error(`Invalid --permission-mode value: ${mode ?? '(missing)'}`) + } + options.permissionMode = mode as KimiPermissionMode + hasExplicitPermissionMode = true + } else if (arg === '--yolo' && !hasExplicitPermissionMode) { + options.permissionMode = 'yolo' + } else if (arg === '--resume') { + const sessionId = commandArgs[++i] + if (!sessionId) { + throw new Error('Missing --resume value') + } + options.resumeSessionId = sessionId + } else if (arg === '--model') { + const model = commandArgs[++i] + if (!model) { + throw new Error('Missing --model value') + } + options.model = model + } + } + + await initializeToken() + await maybeAutoStartServer() + await authAndSetupMachineIfNeeded() + + const { runKimi } = await import('@/kimi/runKimi') + await runKimi(options) + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error') + if (process.env.DEBUG) { + console.error(error) + } + process.exit(1) + } + } +} diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts index 9ffef857..6ff36916 100644 --- a/cli/src/commands/registry.ts +++ b/cli/src/commands/registry.ts @@ -7,6 +7,7 @@ import { runnerCommand } from './runner' import { resumeCommand } from './resume' import { doctorCommand } from './doctor' import { geminiCommand } from './gemini' +import { kimiCommand } from './kimi' import { opencodeCommand } from './opencode' import { hookForwarderCommand } from './hookForwarder' import { mcpCommand } from './mcp' @@ -20,6 +21,7 @@ const COMMANDS: CommandDefinition[] = [ codexCommand, cursorCommand, geminiCommand, + kimiCommand, opencodeCommand, mcpCommand, hubCommand, diff --git a/cli/src/commands/resume.ts b/cli/src/commands/resume.ts index a14e5caa..353f7cab 100644 --- a/cli/src/commands/resume.ts +++ b/cli/src/commands/resume.ts @@ -8,6 +8,7 @@ import type { CodexPermissionMode, CursorPermissionMode, GeminiPermissionMode, + KimiPermissionMode, OpencodePermissionMode } from '@hapi/protocol/types' import { ApiClient } from '@/api/api' @@ -130,6 +131,20 @@ async function dispatchLocalResume(target: LocalResumeTarget): Promise { return } + if (target.flavor === 'kimi') { + const { runKimi } = await import('@/kimi/runKimi') + await runKimi({ + existingSessionId: base.existingSessionId, + workingDirectory: base.workingDirectory, + resumeSessionId: base.resumeSessionId, + startedBy: base.startedBy, + permissionMode: base.permissionMode as KimiPermissionMode | undefined, + startingMode: 'local', + model: target.model ?? undefined + }) + return + } + const { runCursor } = await import('@/cursor/runCursor') await runCursor({ existingSessionId: base.existingSessionId, diff --git a/cli/src/kimi/kimiLocal.ts b/cli/src/kimi/kimiLocal.ts new file mode 100644 index 00000000..1e732bc6 --- /dev/null +++ b/cli/src/kimi/kimiLocal.ts @@ -0,0 +1,47 @@ +import { logger } from '@/ui/logger'; +import { spawnWithTerminalGuard } from '@/utils/spawnWithTerminalGuard'; + +export async function kimiLocal(opts: { + path: string; + sessionId: string | null; + abort: AbortSignal; + model?: string; + yolo?: boolean; + plan?: boolean; +}): Promise { + const args: string[] = []; + + if (opts.sessionId) { + args.push('--session', opts.sessionId); + } + if (opts.model) { + args.push('--model', opts.model); + } + if (opts.yolo) { + args.push('--yolo'); + } + if (opts.plan) { + args.push('--plan'); + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + KIMI_PROJECT_DIR: opts.path + }; + + logger.debug(`[KimiLocal] Spawning kimi with args: ${JSON.stringify(args)}`); + + await spawnWithTerminalGuard({ + command: 'kimi', + args, + cwd: opts.path, + env, + signal: opts.abort, + shell: process.platform === 'win32', + logLabel: 'KimiLocal', + spawnName: 'kimi', + installHint: 'Kimi CLI', + includeCause: true, + logExit: true + }); +} diff --git a/cli/src/kimi/kimiLocalLauncher.ts b/cli/src/kimi/kimiLocalLauncher.ts new file mode 100644 index 00000000..c70ab775 --- /dev/null +++ b/cli/src/kimi/kimiLocalLauncher.ts @@ -0,0 +1,49 @@ +import { kimiLocal } from './kimiLocal'; +import { KimiSession } from './session'; +import type { PermissionMode } from './types'; +import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; + +function mapApprovalMode(mode: PermissionMode | undefined): { yolo: boolean; plan: boolean } { + if (!mode || mode === 'default' || mode === 'read-only') { + return { yolo: false, plan: false }; + } + if (mode === 'yolo' || mode === 'safe-yolo') { + return { yolo: true, plan: false }; + } + return { yolo: false, plan: false }; +} + +export async function kimiLocalLauncher( + session: KimiSession, + opts: { + model?: string; + } +): Promise<'switch' | 'exit'> { + const launcher = new BaseLocalLauncher({ + label: 'kimi-local', + failureLabel: 'Local Kimi process failed', + queue: session.queue, + rpcHandlerManager: session.client.rpcHandlerManager, + startedBy: session.startedBy, + startingMode: session.startingMode, + launch: async (abortSignal) => { + const approval = mapApprovalMode(session.getPermissionMode() as PermissionMode | undefined); + await kimiLocal({ + path: session.path, + sessionId: session.sessionId, + abort: abortSignal, + model: opts.model, + yolo: approval.yolo, + plan: approval.plan + }); + }, + sendFailureMessage: (message) => { + session.sendSessionEvent({ type: 'message', message }); + }, + recordLocalLaunchFailure: (message, exitReason) => { + session.recordLocalLaunchFailure(message, exitReason); + } + }); + + return await launcher.run(); +} diff --git a/cli/src/kimi/kimiRemoteLauncher.ts b/cli/src/kimi/kimiRemoteLauncher.ts new file mode 100644 index 00000000..cd84e8a6 --- /dev/null +++ b/cli/src/kimi/kimiRemoteLauncher.ts @@ -0,0 +1,300 @@ +import React from 'react'; +import { logger } from '@/ui/logger'; +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; +import { convertAgentMessage } from '@/agent/messageConverter'; +import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types'; +import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; +import { KimiDisplay } from '@/ui/ink/KimiDisplay'; +import type { KimiSession } from './session'; +import type { PermissionMode } from './types'; +import { createKimiBackend } from './utils/kimiBackend'; +import { KimiPermissionHandler } from './utils/permissionHandler'; +import { resolveKimiRuntimeConfig } from './utils/config'; + +class KimiRemoteLauncher extends RemoteLauncherBase { + private readonly session: KimiSession; + private readonly model?: string; + private backend: ReturnType | null = null; + private permissionHandler: KimiPermissionHandler | null = null; + private happyServer: { stop: () => void } | null = null; + private abortController = new AbortController(); + private displayModel: string | null = null; + private displayPermissionMode: PermissionMode | null = null; + private currentBackendModel: string | null = null; + private setModelSupported: boolean | undefined = undefined; + private lastDisplayedToolCall = new Map(); + + constructor(session: KimiSession, opts: { model?: string }) { + super(process.env.DEBUG ? session.logPath : undefined); + this.session = session; + this.model = opts.model; + } + + public async launch(): Promise { + return this.start({ + onExit: () => this.handleExitFromUi(), + onSwitchToLocal: () => this.handleSwitchFromUi() + }); + } + + protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { + return React.createElement(KimiDisplay, context); + } + + protected async runMainLoop(): Promise { + const session = this.session; + const messageBuffer = this.messageBuffer; + + const { server: happyServer, mcpServers } = await buildHapiMcpBridge(session.client); + this.happyServer = happyServer; + + const runtimeConfig = resolveKimiRuntimeConfig({ model: this.model }); + this.displayModel = runtimeConfig.model; + messageBuffer.addMessage(`[MODEL:${runtimeConfig.model}]`, 'system'); + + const backend = createKimiBackend({ + model: runtimeConfig.model, + cwd: session.path, + permissionMode: session.getPermissionMode() as string | undefined + }); + this.backend = backend; + + backend.onStderrError((error) => { + logger.debug('[kimi-remote] stderr error', error); + session.sendSessionEvent({ type: 'message', message: error.message }); + messageBuffer.addMessage(error.message, 'status'); + }); + + await backend.initialize(); + + const resumeSessionId = session.sessionId; + const acpMcpServers = toAcpMcpServers(mcpServers); + let acpSessionId: string; + if (resumeSessionId) { + try { + acpSessionId = await backend.loadSession({ + sessionId: resumeSessionId, + cwd: session.path, + mcpServers: acpMcpServers + }); + } catch (error) { + logger.warn('[kimi-remote] resume failed, starting new session', error); + session.sendSessionEvent({ + type: 'message', + message: 'Kimi resume failed; starting a new session.' + }); + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }); + } + } else { + acpSessionId = await backend.newSession({ + cwd: session.path, + mcpServers: acpMcpServers + }); + } + session.onSessionFound(acpSessionId); + + this.permissionHandler = new KimiPermissionHandler( + session.client, + backend, + () => session.getPermissionMode() as PermissionMode | undefined + ); + this.currentBackendModel = runtimeConfig.model; + this.applyDisplayMode(session.getPermissionMode() as PermissionMode, this.currentBackendModel); + + this.setupAbortHandlers(session.client.rpcHandlerManager, { + onAbort: () => this.handleAbort(), + onSwitch: () => this.handleSwitchRequest() + }); + + const sendReady = () => { + session.sendSessionEvent({ type: 'ready' }); + }; + + while (!this.shouldExit) { + const batch = await session.queue.waitForMessagesAndGetAsString(this.abortController.signal); + if (!batch) { + if (this.abortController.signal.aborted && !this.shouldExit) { + continue; + } + break; + } + + if (batch.mode.model && batch.mode.model !== this.currentBackendModel) { + if (!backend.setModel || this.setModelSupported === false) { + batch.mode.model = this.currentBackendModel!; + } else { + logger.debug(`[kimi-remote] Switching model inline: ${this.currentBackendModel} -> ${batch.mode.model}`); + try { + await backend.setModel(acpSessionId, batch.mode.model); + this.currentBackendModel = batch.mode.model; + this.setModelSupported = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const methodNotFound = /method not found/i.test(message); + if (methodNotFound && this.setModelSupported === undefined) { + this.setModelSupported = false; + logger.warn('[kimi-remote] Kimi CLI build does not support set_session_model; inline switching disabled for this session'); + session.sendSessionEvent({ + type: 'message', + message: 'This Kimi CLI build does not support inline model switching. Restart the session to apply a different model.' + }); + } else { + logger.warn('[kimi-remote] Inline model switch failed', error); + session.sendSessionEvent({ + type: 'message', + message: `Failed to switch model to ${batch.mode.model}. Continuing with ${this.currentBackendModel}.` + }); + } + batch.mode.model = this.currentBackendModel!; + } + } + } + + this.applyDisplayMode(batch.mode.permissionMode, batch.mode.model); + messageBuffer.addMessage(batch.message, 'user'); + + const promptContent: PromptContent[] = [{ + type: 'text', + text: batch.message + }]; + + session.onThinkingChange(true); + + try { + await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { + this.handleAgentMessage(message); + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.warn('[kimi-remote] prompt failed', { message: errorMessage }); + session.sendSessionEvent({ + type: 'message', + message: `Kimi prompt failed: ${errorMessage}` + }); + messageBuffer.addMessage(`Kimi prompt failed: ${errorMessage}`, 'status'); + } finally { + session.onThinkingChange(false); + await this.permissionHandler?.cancelAll('Prompt finished'); + if (session.queue.size() === 0 && !this.shouldExit) { + sendReady(); + } + } + } + } + + protected async cleanup(): Promise { + this.clearAbortHandlers(this.session.client.rpcHandlerManager); + + if (this.permissionHandler) { + await this.permissionHandler.cancelAll('Session ended'); + this.permissionHandler = null; + } + + if (this.backend) { + await this.backend.disconnect(); + this.backend = null; + } + + if (this.happyServer) { + this.happyServer.stop(); + this.happyServer = null; + } + } + + private handleAgentMessage(message: AgentMessage): void { + const converted = convertAgentMessage(message); + if (converted) { + this.session.sendAgentMessage(converted); + } + + switch (message.type) { + case 'text': + this.messageBuffer.addMessage(message.text, 'assistant'); + break; + case 'reasoning': + this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system'); + break; + case 'tool_call': { + const lastName = this.lastDisplayedToolCall.get(message.id); + if (lastName !== message.name) { + this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool'); + this.lastDisplayedToolCall.set(message.id, message.name); + } + break; + } + case 'tool_result': + this.messageBuffer.addMessage('Tool result received', 'result'); + break; + case 'plan': + this.messageBuffer.addMessage('Plan updated', 'status'); + break; + case 'error': + this.messageBuffer.addMessage(message.message, 'status'); + break; + case 'turn_complete': + this.messageBuffer.addMessage('Turn complete', 'status'); + break; + default: { + const _exhaustive: never = message; + return _exhaustive; + } + } + } + + private applyDisplayMode(permissionMode: PermissionMode | undefined, model?: string): void { + if (permissionMode && permissionMode !== this.displayPermissionMode) { + this.displayPermissionMode = permissionMode; + this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system'); + } + if (model && model !== this.displayModel) { + this.displayModel = model; + this.messageBuffer.addMessage(`[MODEL:${model}]`, 'system'); + } + } + + private async handleAbort(): Promise { + const backend = this.backend; + if (backend && this.session.sessionId) { + await backend.cancelPrompt(this.session.sessionId); + } + await this.permissionHandler?.cancelAll('User aborted'); + this.session.sendSessionEvent({ type: 'message', message: 'Session aborted' }); + this.session.queue.reset(); + this.session.onThinkingChange(false); + this.abortController.abort(); + this.abortController = new AbortController(); + this.messageBuffer.addMessage('Turn aborted', 'status'); + } + + private async handleExitFromUi(): Promise { + await this.requestExit('exit', () => this.handleAbort()); + } + + private async handleSwitchFromUi(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } + + private async handleSwitchRequest(): Promise { + await this.requestExit('switch', () => this.handleAbort()); + } +} + +function toAcpMcpServers(config: Record): McpServerStdio[] { + return Object.entries(config).map(([name, entry]) => ({ + name, + command: entry.command, + args: entry.args, + env: [] + })); +} + +export async function kimiRemoteLauncher( + session: KimiSession, + opts: { model?: string } +): Promise<'switch' | 'exit'> { + const launcher = new KimiRemoteLauncher(session, opts); + return launcher.launch(); +} diff --git a/cli/src/kimi/loop.ts b/cli/src/kimi/loop.ts new file mode 100644 index 00000000..29477d40 --- /dev/null +++ b/cli/src/kimi/loop.ts @@ -0,0 +1,64 @@ +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { logger } from '@/ui/logger'; +import { runLocalRemoteSession } from '@/agent/loopBase'; +import { KimiSession } from './session'; +import { kimiLocalLauncher } from './kimiLocalLauncher'; +import { kimiRemoteLauncher } from './kimiRemoteLauncher'; +import { ApiClient, ApiSessionClient } from '@/lib'; +import type { KimiMode, PermissionMode } from './types'; + +interface KimiLoopOptions { + path: string; + startingMode?: 'local' | 'remote'; + startedBy?: 'runner' | 'terminal'; + onModeChange: (mode: 'local' | 'remote') => void; + messageQueue: MessageQueue2; + session: ApiSessionClient; + api: ApiClient; + permissionMode?: PermissionMode; + model?: string; + resumeSessionId?: string; + onSessionReady?: (session: KimiSession) => void; +} + +export async function kimiLoop(opts: KimiLoopOptions): Promise { + const logPath = logger.getLogPath(); + const startedBy = opts.startedBy ?? 'terminal'; + const startingMode = opts.startingMode ?? 'local'; + + const session = new KimiSession({ + api: opts.api, + client: opts.session, + path: opts.path, + sessionId: opts.resumeSessionId ?? null, + logPath, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: startingMode, + startedBy, + startingMode, + permissionMode: opts.permissionMode ?? 'default' + }); + + if (opts.resumeSessionId) { + session.onSessionFound(opts.resumeSessionId); + } + + const getCurrentModel = (): string | undefined => { + const sessionModel = session.getModel(); + return sessionModel != null ? sessionModel : opts.model; + }; + + await runLocalRemoteSession({ + session, + startingMode: opts.startingMode, + logTag: 'kimi-loop', + runLocal: (instance) => kimiLocalLauncher(instance, { + model: getCurrentModel() + }), + runRemote: (instance) => kimiRemoteLauncher(instance, { + model: getCurrentModel() + }), + onSessionReady: opts.onSessionReady + }); +} diff --git a/cli/src/kimi/runKimi.ts b/cli/src/kimi/runKimi.ts new file mode 100644 index 00000000..97cc3703 --- /dev/null +++ b/cli/src/kimi/runKimi.ts @@ -0,0 +1,189 @@ +import { logger } from '@/ui/logger'; +import { kimiLoop } from './loop'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { hashObject } from '@/utils/deterministicJson'; +import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler'; +import type { AgentState } from '@/api/types'; +import type { KimiSession } from './session'; +import type { KimiMode, PermissionMode } from './types'; +import { bootstrapExistingSession, bootstrapSession } from '@/agent/sessionFactory'; +import { registerLocalHandoffHandler } from '@/agent/localHandoff'; +import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; +import { getInvokedCwd } from '@/utils/invokedCwd'; +import { resolveKimiRuntimeConfig } from './utils/config'; + +export async function runKimi(opts: { + startedBy?: 'runner' | 'terminal'; + startingMode?: 'local' | 'remote'; + permissionMode?: PermissionMode; + model?: string; + resumeSessionId?: string; + existingSessionId?: string; + workingDirectory?: string; +} = {}): Promise { + const workingDirectory = opts.workingDirectory ?? getInvokedCwd(); + const startedBy = opts.startedBy ?? 'terminal'; + + logger.debug(`[kimi] Starting with options: startedBy=${startedBy}, startingMode=${opts.startingMode}`); + + if (startedBy === 'runner' && opts.startingMode === 'local') { + logger.debug('[kimi] Runner spawn requested with local mode; forcing remote mode'); + opts.startingMode = 'remote'; + } + + const initialState: AgentState = { + controlledByUser: false + }; + + const machineDefault = resolveKimiRuntimeConfig().model; + const runtimeConfig = resolveKimiRuntimeConfig({ model: opts.model }); + const persistedModel = runtimeConfig.modelSource === 'default' + ? undefined + : runtimeConfig.model; + + const bootstrap = opts.existingSessionId + ? await bootstrapExistingSession({ + sessionId: opts.existingSessionId, + flavor: 'kimi', + startedBy, + workingDirectory + }) + : await bootstrapSession({ + flavor: 'kimi', + startedBy, + workingDirectory, + agentState: initialState, + model: persistedModel + }); + const { api, session } = bootstrap; + + const startingMode: 'local' | 'remote' = opts.startingMode + ?? (startedBy === 'runner' ? 'remote' : 'local'); + + setControlledByUser(session, startingMode); + + const messageQueue = new MessageQueue2((mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model + })); + + const sessionWrapperRef: { current: KimiSession | null } = { current: null }; + let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; + let sessionModel: string | null = persistedModel ?? null; + let resolvedModel = sessionModel ?? machineDefault; + + const lifecycle = createRunnerLifecycle({ + session, + logTag: 'kimi', + stopKeepAlive: () => sessionWrapperRef.current?.stopKeepAlive() + }); + + lifecycle.registerProcessHandlers(); + registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); + registerLocalHandoffHandler(session.rpcHandlerManager, lifecycle); + + const syncSessionMode = () => { + const sessionInstance = sessionWrapperRef.current; + if (!sessionInstance) { + return; + } + sessionInstance.setPermissionMode(currentPermissionMode); + sessionInstance.setModel(sessionModel); + sessionInstance.pushKeepAlive(); + + logger.debug(`[kimi] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); + }; + + session.onUserMessage((message, localId) => { + const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); + const mode: KimiMode = { + permissionMode: currentPermissionMode, + model: resolvedModel + }; + messageQueue.push(formattedText, mode, localId); + }); + + session.onCancelQueuedMessage((localId) => { + const removed = messageQueue.cancelByLocalId(localId); + logger.debug(`[kimi] cancelByLocalId(${localId}): ${removed ? 'removed' : 'not found (best-effort)'}`); + return removed; + }); + + const resolvePermissionMode = (value: unknown): PermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, 'kimi')) { + throw new Error('Invalid permission mode'); + } + return parsed.data as PermissionMode; + }; + + const resolveModel = (value: unknown): string | null => { + if (value === null) { + return null; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid model'); + } + return value.trim(); + }; + + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: unknown; model?: unknown }; + const applied: Record = {}; + + if (config.permissionMode !== undefined) { + currentPermissionMode = resolvePermissionMode(config.permissionMode); + applied.permissionMode = currentPermissionMode; + } + + if (config.model !== undefined) { + sessionModel = resolveModel(config.model); + resolvedModel = sessionModel ?? machineDefault; + applied.model = sessionModel; + } + + syncSessionMode(); + return { applied }; + }); + + let crashed = false; + + try { + await kimiLoop({ + path: workingDirectory, + startingMode, + startedBy, + messageQueue, + session, + api, + permissionMode: currentPermissionMode, + model: machineDefault, + resumeSessionId: opts.resumeSessionId, + onModeChange: createModeChangeHandler(session), + onSessionReady: (instance) => { + sessionWrapperRef.current = instance; + syncSessionMode(); + } + }); + } catch (error) { + crashed = true; + lifecycle.markCrash(error); + logger.debug('[kimi] Loop error:', error); + } finally { + const localFailure = sessionWrapperRef.current?.localLaunchFailure; + if (localFailure?.exitReason === 'exit') { + lifecycle.setExitCode(1); + lifecycle.setArchiveReason(`Local launch failed: ${localFailure.message.slice(0, 200)}`); + lifecycle.setSessionEndReason('error'); + } else if (!crashed) { + lifecycle.setSessionEndReason('completed'); + } + await lifecycle.cleanupAndExit(); + } +} diff --git a/cli/src/kimi/session.ts b/cli/src/kimi/session.ts new file mode 100644 index 00000000..b47c3d9b --- /dev/null +++ b/cli/src/kimi/session.ts @@ -0,0 +1,76 @@ +import { ApiClient, ApiSessionClient } from '@/lib'; +import { MessageQueue2 } from '@/utils/MessageQueue2'; +import { AgentSessionBase } from '@/agent/sessionBase'; +import type { KimiMode, PermissionMode } from './types'; +import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; + +type LocalLaunchFailure = { + message: string; + exitReason: LocalLaunchExitReason; +}; + +export class KimiSession extends AgentSessionBase { + readonly startedBy: 'runner' | 'terminal'; + readonly startingMode: 'local' | 'remote'; + localLaunchFailure: LocalLaunchFailure | null = null; + + constructor(opts: { + api: ApiClient; + client: ApiSessionClient; + path: string; + logPath: string; + sessionId: string | null; + messageQueue: MessageQueue2; + onModeChange: (mode: 'local' | 'remote') => void; + mode?: 'local' | 'remote'; + startedBy: 'runner' | 'terminal'; + startingMode: 'local' | 'remote'; + permissionMode?: PermissionMode; + }) { + super({ + api: opts.api, + client: opts.client, + path: opts.path, + logPath: opts.logPath, + sessionId: opts.sessionId, + messageQueue: opts.messageQueue, + onModeChange: opts.onModeChange, + mode: opts.mode, + sessionLabel: 'KimiSession', + sessionIdLabel: 'Kimi', + applySessionIdToMetadata: (metadata, sessionId) => ({ + ...metadata, + kimiSessionId: sessionId + }), + permissionMode: opts.permissionMode + }); + + this.startedBy = opts.startedBy; + this.startingMode = opts.startingMode; + this.permissionMode = opts.permissionMode; + } + + setPermissionMode = (mode: PermissionMode): void => { + this.permissionMode = mode; + }; + + setModel = (model: string | null): void => { + this.model = model; + }; + + recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => { + this.localLaunchFailure = { message, exitReason }; + }; + + sendAgentMessage = (message: unknown): void => { + this.client.sendAgentMessage(message); + }; + + sendUserMessage = (text: string): void => { + this.client.sendUserMessage(text); + }; + + sendSessionEvent = (event: Parameters[0]): void => { + this.client.sendSessionEvent(event); + }; +} diff --git a/cli/src/kimi/types.ts b/cli/src/kimi/types.ts new file mode 100644 index 00000000..13b2ccb8 --- /dev/null +++ b/cli/src/kimi/types.ts @@ -0,0 +1,8 @@ +import type { KimiPermissionMode } from '@hapi/protocol/types'; + +export type PermissionMode = KimiPermissionMode; + +export interface KimiMode { + permissionMode: PermissionMode; + model?: string; +} diff --git a/cli/src/kimi/utils/config.ts b/cli/src/kimi/utils/config.ts new file mode 100644 index 00000000..e3ae7ebd --- /dev/null +++ b/cli/src/kimi/utils/config.ts @@ -0,0 +1,103 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { logger } from '@/ui/logger'; + +export const KIMI_MODEL_ENV = 'KIMI_MODEL'; + +export type KimiLocalConfig = { + model?: string; +}; + +export type KimiModelSource = 'explicit' | 'env' | 'local' | 'default'; + +const KIMI_DIR = join(homedir(), '.kimi'); +const CONFIG_PATH = join(KIMI_DIR, 'config.toml'); + +function readTomlFile(path: string): Record | null { + if (!existsSync(path)) { + return null; + } + + try { + const raw = readFileSync(path, 'utf-8'); + // Very basic TOML parsing for simple key = "value" lines + const result: Record = {}; + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const match = trimmed.match(/^([\w_]+)\s*=\s*"([^"]*)"/); + if (match) { + result[match[1]] = match[2]; + } + // Handle bare keys: key = true / key = false / key = 123 + const bareMatch = trimmed.match(/^([\w_]+)\s*=\s*(true|false|\d+)/); + if (bareMatch) { + const val = bareMatch[2]; + result[bareMatch[1]] = val === 'true' ? true : val === 'false' ? false : Number(val); + } + } + return result; + } catch (error) { + logger.debug(`[kimi-config] Failed to read ${path}:`, error); + } + + return null; +} + +function extractModel(config: Record): string | undefined { + const model = config.default_model; + if (typeof model === 'string' && model.trim().length > 0) { + return model.trim(); + } + return undefined; +} + +export function readKimiLocalConfig(): KimiLocalConfig { + const configFile = readTomlFile(CONFIG_PATH); + + return { + model: configFile ? extractModel(configFile) : undefined + }; +} + +export function resolveKimiRuntimeConfig(opts: { + model?: string; +} = {}): { model: string; modelSource: KimiModelSource } { + const local = readKimiLocalConfig(); + + let modelSource: KimiModelSource = 'default'; + let model: string = 'kimi-k2'; + + if (opts.model) { + model = opts.model; + modelSource = 'explicit'; + } else if (process.env[KIMI_MODEL_ENV]) { + model = process.env[KIMI_MODEL_ENV]!; + modelSource = 'env'; + } else if (local.model) { + model = local.model; + modelSource = 'local'; + } + + return { model, modelSource }; +} + +export function buildKimiEnv(opts: { + model?: string; + cwd?: string; +}): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env + }; + + if (opts.model) { + env[KIMI_MODEL_ENV] = opts.model; + } + + if (opts.cwd) { + env.KIMI_PROJECT_DIR = opts.cwd; + } + + return env; +} diff --git a/cli/src/kimi/utils/kimiBackend.ts b/cli/src/kimi/utils/kimiBackend.ts new file mode 100644 index 00000000..e99563b0 --- /dev/null +++ b/cli/src/kimi/utils/kimiBackend.ts @@ -0,0 +1,30 @@ +import { AcpSdkBackend } from '@/agent/backends/acp'; +import { buildKimiEnv } from './config'; + +function filterEnv(env: NodeJS.ProcessEnv): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + result[key] = value; + } + } + return result; +} + +export function createKimiBackend(opts: { + model?: string; + resumeSessionId?: string | null; + cwd?: string; + permissionMode?: string; +}): AcpSdkBackend { + const env = filterEnv(buildKimiEnv({ + model: opts.model, + cwd: opts.cwd + })); + + return new AcpSdkBackend({ + command: 'kimi', + args: ['acp'], + env + }); +} diff --git a/cli/src/kimi/utils/permissionHandler.ts b/cli/src/kimi/utils/permissionHandler.ts new file mode 100644 index 00000000..04cd0bb0 --- /dev/null +++ b/cli/src/kimi/utils/permissionHandler.ts @@ -0,0 +1,170 @@ +import type { ApiSessionClient } from '@/api/apiSession'; +import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types'; +import type { KimiPermissionMode } from '@hapi/protocol/types'; +import { deriveToolName } from '@/agent/utils'; +import { logger } from '@/ui/logger'; +import { + BasePermissionHandler, + type AutoApprovalDecision, + type PendingPermissionRequest, + type PermissionCompletion +} from '@/modules/common/permission/BasePermissionHandler'; + +interface PermissionResponseMessage { + id: string; + approved: boolean; + decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort'; + reason?: string; +} + +function deriveToolInput(request: PermissionRequest): unknown { + if (request.rawInput !== undefined) { + return request.rawInput; + } + return request.rawOutput; +} + +function pickOptionId(request: PermissionRequest, preferredKinds: string[]): string | null { + for (const kind of preferredKinds) { + const match = request.options.find((option) => option.kind === kind); + if (match) { + return match.optionId; + } + } + return request.options.length > 0 ? request.options[0].optionId : null; +} + +function mapDecisionToOutcome(request: PermissionRequest, decision: PermissionResponseMessage['decision']): PermissionResponse { + if (decision === 'abort') { + return { outcome: 'cancelled' }; + } + + if (decision === 'approved_for_session') { + const optionId = pickOptionId(request, ['allow_always', 'allow_once']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + if (decision === 'approved') { + const optionId = pickOptionId(request, ['allow_once', 'allow_always']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; + } + + const optionId = pickOptionId(request, ['reject_once', 'reject_always']); + return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' }; +} + +export class KimiPermissionHandler extends BasePermissionHandler { + private readonly pendingBackendRequests = new Map(); + + constructor( + session: ApiSessionClient, + private readonly backend: AgentBackend, + private readonly getPermissionMode: () => KimiPermissionMode | undefined + ) { + super(session); + this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); + } + + private handlePermissionRequest(request: PermissionRequest): void { + const toolName = deriveToolName({ + title: request.title, + kind: request.kind, + rawInput: request.rawInput + }); + const toolInput = deriveToolInput(request); + const mode = this.getPermissionMode() ?? 'default'; + + const autoDecision = this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId); + if (autoDecision) { + void this.autoApprove(request, toolName, toolInput, autoDecision); + return; + } + + this.pendingBackendRequests.set(request.id, request); + this.addPendingRequest(request.id, toolName, toolInput, { + resolve: () => {}, + reject: () => {} + }); + + logger.debug(`[Kimi] Permission request queued for ${toolName} (${request.id})`); + } + + private async autoApprove( + request: PermissionRequest, + toolName: string, + toolInput: unknown, + decision: AutoApprovalDecision + ): Promise { + const outcome = mapDecisionToOutcome(request, decision); + await this.backend.respondToPermission(request.sessionId, request, outcome); + + this.client.updateAgentState((currentState) => ({ + ...currentState, + completedRequests: { + ...currentState.completedRequests, + [request.id]: { + tool: toolName, + arguments: toolInput, + createdAt: Date.now(), + completedAt: Date.now(), + status: 'approved', + decision + } + } + })); + + logger.debug(`[Kimi] Auto-approved ${toolName} (${request.id}) mode=${decision}`); + } + + protected async handlePermissionResponse( + response: PermissionResponseMessage, + pending: PendingPermissionRequest + ): Promise { + const pendingRequest = this.pendingBackendRequests.get(response.id); + if (pendingRequest) { + this.pendingBackendRequests.delete(response.id); + } else { + logger.debug('[Kimi] Permission response missing backend request', response.id); + } + + const decision = response.decision ?? (response.approved ? 'approved' : 'denied'); + + if (decision === 'abort' && pendingRequest) { + await this.backend.cancelPrompt(pendingRequest.sessionId); + } + + if (pendingRequest) { + const outcome = mapDecisionToOutcome(pendingRequest, decision); + await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome); + } + + pending.resolve(); + + logger.debug(`[Kimi] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`); + + return { + status: response.approved ? 'approved' : 'denied', + decision, + reason: response.reason + }; + } + + protected handleMissingPendingResponse(response: PermissionResponseMessage): void { + logger.debug('[Kimi] Permission response received for unknown request', response.id); + } + + async cancelAll(reason: string): Promise { + const pending = Array.from(this.pendingBackendRequests.values()); + this.pendingBackendRequests.clear(); + + for (const request of pending) { + await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' }); + } + + this.cancelPendingRequests({ + completedReason: reason, + rejectMessage: reason, + decision: 'abort' + }); + } +} diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 3373036c..51dc3712 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -911,9 +911,11 @@ export function buildCliArgs( ? 'cursor' : agent === 'gemini' ? 'gemini' - : agent === 'opencode' - ? 'opencode' - : 'claude'; + : agent === 'kimi' + ? 'kimi' + : agent === 'opencode' + ? 'opencode' + : 'claude'; const args = [agentCommand]; if (options.resumeSessionId) { if (agent === 'codex') { diff --git a/cli/src/ui/ink/KimiDisplay.tsx b/cli/src/ui/ink/KimiDisplay.tsx new file mode 100644 index 00000000..a6f413ac --- /dev/null +++ b/cli/src/ui/ink/KimiDisplay.tsx @@ -0,0 +1,187 @@ +import React, { useEffect, useState } from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { MessageBuffer, type BufferedMessage } from './messageBuffer'; +import { useSwitchControls } from './useSwitchControls'; + +interface KimiDisplayProps { + messageBuffer: MessageBuffer; + logPath?: string; + onExit?: () => void; + onSwitchToLocal?: () => void; +} + +function extractTag(messages: BufferedMessage[], tag: 'MODEL' | 'MODE'): string | null { + const prefix = `[${tag}:`; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.type !== 'system') { + continue; + } + if (!message.content.startsWith(prefix)) { + continue; + } + const match = message.content.match(/\[\w+:(.+?)\]/); + if (match && match[1]) { + return match[1]; + } + } + return null; +} + +export const KimiDisplay: React.FC = ({ + messageBuffer, + logPath, + onExit, + onSwitchToLocal +}) => { + const [messages, setMessages] = useState([]); + const [model, setModel] = useState(null); + const [permissionMode, setPermissionMode] = useState(null); + const { confirmationMode, actionInProgress } = useSwitchControls({ + onExit, + onSwitch: onSwitchToLocal + }); + const { stdout } = useStdout(); + const terminalWidth = stdout.columns || 80; + const terminalHeight = stdout.rows || 24; + + useEffect(() => { + setMessages(messageBuffer.getMessages()); + + const unsubscribe = messageBuffer.onUpdate((newMessages) => { + setMessages(newMessages); + const nextModel = extractTag(newMessages, 'MODEL'); + if (nextModel) { + setModel(nextModel); + } + const nextMode = extractTag(newMessages, 'MODE'); + if (nextMode) { + setPermissionMode(nextMode); + } + }); + + return () => { + unsubscribe(); + }; + }, [messageBuffer]); + + const getMessageColor = (type: BufferedMessage['type']): string => { + switch (type) { + case 'user': return 'magenta'; + case 'assistant': return 'cyan'; + case 'system': return 'blue'; + case 'tool': return 'yellow'; + case 'result': return 'green'; + case 'status': return 'gray'; + default: return 'white'; + } + }; + + const formatMessage = (msg: BufferedMessage): string => { + const lines = msg.content.split('\n'); + const maxLineLength = Math.max(1, terminalWidth - 10); + return lines.map(line => { + if (line.length <= maxLineLength) return line; + const chunks: string[] = []; + for (let i = 0; i < line.length; i += maxLineLength) { + chunks.push(line.slice(i, i + maxLineLength)); + } + return chunks.join('\n'); + }).join('\n'); + }; + + const visibleMessages = messages.filter((msg) => { + if (msg.type === 'system' && msg.content.startsWith('[MODEL:')) { + return false; + } + if (msg.type === 'system' && msg.content.startsWith('[MODE:')) { + return false; + } + return true; + }); + + return ( + + + + Kimi Agent Messages + {'-'.repeat(Math.min(terminalWidth - 4, 60))} + + + + {visibleMessages.length === 0 ? ( + Waiting for messages... + ) : ( + visibleMessages + .slice(-Math.max(1, terminalHeight - 10)) + .map((msg) => ( + + + {formatMessage(msg)} + + + )) + )} + + + + + + {actionInProgress === 'exiting' ? ( + + Exiting agent... + + ) : actionInProgress === 'switching' ? ( + + Switching to local mode... + + ) : confirmationMode === 'exit' ? ( + + Press Ctrl-C again to exit the agent + + ) : confirmationMode === 'switch' ? ( + + Press space again to switch to local mode + + ) : ( + + Kimi running {onSwitchToLocal ? '(Space to switch to local, Ctrl-C to exit)' : '(Ctrl-C to exit)'} + + )} + {(model || permissionMode) && ( + + {model ? `Model: ${model}` : 'Model: default'} + {permissionMode ? ` | Permission: ${permissionMode}` : ''} + + )} + {process.env.DEBUG && logPath && ( + + Debug logs: {logPath} + + )} + + + + ); +}; diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 07796c64..d55563ae 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -501,6 +501,7 @@ export class SyncEngine { if (flavor === 'gemini') return metadata.geminiSessionId ?? null if (flavor === 'opencode') return metadata.opencodeSessionId ?? null if (flavor === 'cursor') return metadata.cursorSessionId ?? null + if (flavor === 'kimi') return metadata.kimiSessionId ?? null return metadata.claudeSessionId ?? this.recoverClaudeSessionIdFromMessages(session.id, namespace) } diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 35c02e4a..d1a99017 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -12,6 +12,7 @@ export type Capability = typeof Capabilities[keyof typeof Capabilities] const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), + kimi: new Set([Capabilities.ModelChange]), codex: new Set([Capabilities.ModelChange]), cursor: new Set([]), opencode: new Set([Capabilities.ModelChange]), @@ -21,6 +22,7 @@ const FLAVOR_CAPS: Record> = { const FLAVOR_LABELS: Record = { claude: 'Claude', gemini: 'Gemini', + kimi: 'Kimi', codex: 'Codex', cursor: 'Cursor', opencode: 'OpenCode', @@ -51,5 +53,5 @@ export function supportsEffort(flavor: string | null | undefined): boolean { } export function isCodexFamilyFlavor(flavor: string | null | undefined): boolean { - return flavor === 'codex' || flavor === 'gemini' || flavor === 'opencode' + return flavor === 'codex' || flavor === 'gemini' || flavor === 'kimi' || flavor === 'opencode' } diff --git a/shared/src/modes.ts b/shared/src/modes.ts index ac6a04df..0c3008b1 100644 --- a/shared/src/modes.ts +++ b/shared/src/modes.ts @@ -7,7 +7,7 @@ import { z } from 'zod' */ export const AGENT_MESSAGE_PAYLOAD_TYPE = 'codex' as const -export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'opencode'] as const +export const AGENT_FLAVORS = ['claude', 'codex', 'cursor', 'gemini', 'kimi', 'opencode'] as const export type AgentFlavor = typeof AGENT_FLAVORS[number] export const AgentFlavorSchema = z.enum(AGENT_FLAVORS) @@ -23,6 +23,9 @@ export type CodexCollaborationMode = typeof CODEX_COLLABORATION_MODES[number] export const GEMINI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const export type GeminiPermissionMode = typeof GEMINI_PERMISSION_MODES[number] +export const KIMI_PERMISSION_MODES = ['default', 'read-only', 'safe-yolo', 'yolo'] as const +export type KimiPermissionMode = typeof KIMI_PERMISSION_MODES[number] + export const OPENCODE_PERMISSION_MODES = ['default', 'yolo'] as const export type OpencodePermissionMode = typeof OPENCODE_PERMISSION_MODES[number] @@ -41,6 +44,7 @@ export const PERMISSION_MODES = [ ] as const export type PermissionMode = typeof PERMISSION_MODES[number] + export const PERMISSION_MODE_LABELS: Record = { default: 'Default', acceptEdits: 'Accept Edits', @@ -100,6 +104,9 @@ export function getPermissionModesForFlavor(flavor?: string | null): readonly Pe if (flavor === 'gemini') { return GEMINI_PERMISSION_MODES } + if (flavor === 'kimi') { + return KIMI_PERMISSION_MODES + } if (flavor === 'opencode') { return OPENCODE_PERMISSION_MODES } diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index d9c3dfdd..6fa2ad5c 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -38,6 +38,7 @@ export const MetadataSchema = z.object({ geminiSessionId: z.string().optional(), opencodeSessionId: z.string().optional(), cursorSessionId: z.string().optional(), + kimiSessionId: z.string().optional(), tools: z.array(z.string()).optional(), slashCommands: z.array(z.string()).optional(), homeDir: z.string().optional(), diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 86298208..13e580fc 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -38,6 +38,7 @@ export function toSessionSummary(session: Session): SessionSummary { ?? session.metadata.geminiSessionId ?? session.metadata.opencodeSessionId ?? session.metadata.cursorSessionId + ?? session.metadata.kimiSessionId ?? undefined } : null diff --git a/shared/src/types.ts b/shared/src/types.ts index d195af19..aaeab1b1 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -35,6 +35,7 @@ export type { CodexPermissionMode, CursorPermissionMode, GeminiPermissionMode, + KimiPermissionMode, OpencodePermissionMode, PermissionMode, PermissionModeOption, diff --git a/web/src/chat/reducerTimeline.test.ts b/web/src/chat/reducerTimeline.test.ts index d88b7bac..20e57835 100644 --- a/web/src/chat/reducerTimeline.test.ts +++ b/web/src/chat/reducerTimeline.test.ts @@ -402,7 +402,7 @@ describe('reduceTimeline', () => { sidechainId: 'msg-agent' } as TracedMessage - // Build groups map the way the real pipeline does it + // Build groups map the way the real pipeline does it (keyed by message id) const groups = new Map() groups.set('msg-agent', [sidechainChild]) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 4a872f15..d13cd34a 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -25,9 +25,12 @@ function getAgentRunCompletedAt(event: Record): number | null { function setEarliestStartedAt(block: ToolCallBlock, startedAt: number | null): void { if (startedAt === null) return - block.tool.startedAt = block.tool.startedAt === null + const nextStartedAt = block.tool.startedAt === null ? startedAt : Math.min(block.tool.startedAt, startedAt) + if (nextStartedAt !== block.tool.startedAt) { + block.tool = { ...block.tool, startedAt: nextStartedAt } + } } function getAgentRunCardId(event: Record, fallback: string): string { @@ -319,9 +322,12 @@ export function reduceTimeline( const patchAgentRunInput = (block: ToolCallBlock, patch: Record): void => { const current = isObject(block.tool.input) ? block.tool.input : {} - block.tool.input = { - ...current, - ...patch + block.tool = { + ...block.tool, + input: { + ...current, + ...patch + } } } @@ -530,8 +536,9 @@ export function reduceTimeline( statusText: getEventString(event, 'statusText') ?? getEventString(event, 'status_text') ?? 'Starting', ...getAgentRunDisplayPatch(event) }) - block.tool.state = mapAgentRunStatusToToolState(status) - if (block.tool.state === 'running') { + const nextState = mapAgentRunStatusToToolState(status) + block.tool = { ...block.tool, state: nextState } + if (nextState === 'running') { setEarliestStartedAt(block, startedAt) } continue @@ -553,20 +560,20 @@ export function reduceTimeline( statusText: getEventString(event, 'statusText') ?? getEventString(event, 'status_text') ?? status, ...getAgentRunDisplayPatch(event) }) - block.tool.state = nextState - if (block.tool.state === 'running') { + block.tool = { ...block.tool, state: nextState } + if (nextState === 'running') { setEarliestStartedAt(block, startedAt ?? msg.createdAt) } - if (block.tool.state === 'completed' || block.tool.state === 'error') { + if (nextState === 'completed' || nextState === 'error') { setEarliestStartedAt(block, startedAt) - block.tool.completedAt = getAgentRunCompletedAt(event) ?? msg.createdAt + block.tool = { ...block.tool, completedAt: getAgentRunCompletedAt(event) ?? msg.createdAt } } if ('result' in event) { - block.tool.result = event.result + block.tool = { ...block.tool, result: event.result } } else if ('error' in event) { - block.tool.result = event.error + block.tool = { ...block.tool, result: event.error } } else if ('spawnResult' in event) { - block.tool.result = event.spawnResult + block.tool = { ...block.tool, result: event.spawnResult } } continue } @@ -840,8 +847,7 @@ export function reduceTimeline( }) if (block.tool.state === 'pending') { - block.tool.state = 'running' - block.tool.startedAt = msg.createdAt + block.tool = { ...block.tool, state: 'running', startedAt: msg.createdAt } } if (isSubagentToolName(c.name) && !context.consumedGroupIds.has(msg.id)) { @@ -909,9 +915,12 @@ export function reduceTimeline( permission }) - block.tool.result = c.content - block.tool.completedAt = msg.createdAt - block.tool.state = c.is_error ? 'error' : 'completed' + block.tool = { + ...block.tool, + result: c.content, + completedAt: msg.createdAt, + state: c.is_error ? 'error' : 'completed' + } continue } diff --git a/web/src/chat/reducerTools.ts b/web/src/chat/reducerTools.ts index 918028fd..132017ed 100644 --- a/web/src/chat/reducerTools.ts +++ b/web/src/chat/reducerTools.ts @@ -78,22 +78,24 @@ export function ensureToolBlock( // Preserve earliest createdAt for stable ordering. if (seed.createdAt < existing.createdAt) { existing.createdAt = seed.createdAt - existing.tool.createdAt = seed.createdAt + existing.tool = { ...existing.tool, createdAt: seed.createdAt } } if (seed.permission) { - existing.tool.permission = { ...existing.tool.permission, ...seed.permission } + const nextPermission = { ...existing.tool.permission, ...seed.permission } + let nextState = existing.tool.state if (existing.tool.state === 'running' && seed.permission.status === 'pending') { - existing.tool.state = 'pending' + nextState = 'pending' } + existing.tool = { ...existing.tool, permission: nextPermission, state: nextState } } if (seed.name && (!isPlaceholderToolName(seed.name) || isPlaceholderToolName(existing.tool.name))) { - existing.tool.name = seed.name + existing.tool = { ...existing.tool, name: seed.name } } if (seed.input !== null && seed.input !== undefined) { - existing.tool.input = seed.input + existing.tool = { ...existing.tool, input: seed.input } } if (seed.description !== null) { - existing.tool.description = seed.description + existing.tool = { ...existing.tool, description: seed.description } } // The first call (tool_use) records when the tool was invoked. The // second call (tool_result) carries the result message's invokedAt, diff --git a/web/src/chat/subagentTool.ts b/web/src/chat/subagentTool.ts index a18c60d7..b1ad68ac 100644 --- a/web/src/chat/subagentTool.ts +++ b/web/src/chat/subagentTool.ts @@ -10,5 +10,5 @@ * Keeping both ensures sessions recorded under either name continue to work. */ export function isSubagentToolName(name: string): boolean { - return name === 'Task' || name === 'Agent' + return name === 'Task' || name === 'Agent' || name.startsWith('Agent:') || name.startsWith('Task:') } diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index bdbf8167..c4416cca 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -62,6 +62,10 @@ export function getModelOptionsForFlavor( if (flavor === 'opencode') { return [] } + // Kimi has no predefined model list — show just the auto/default option. + if (flavor === 'kimi') { + return withCurrentModelOption([{ value: null, label: 'Default' }], currentModel) + } return getClaudeComposerModelOptions(currentModel) } @@ -89,5 +93,8 @@ export function getNextModelForFlavor( if (flavor === 'opencode') { return normalizeCurrentModel(currentModel) } + if (flavor === 'kimi') { + return normalizeCurrentModel(currentModel) + } return getNextClaudeComposerModel(currentModel) } diff --git a/web/src/components/NewSession/types.ts b/web/src/components/NewSession/types.ts index b22f050e..e504b261 100644 --- a/web/src/components/NewSession/types.ts +++ b/web/src/components/NewSession/types.ts @@ -27,6 +27,9 @@ export const MODEL_OPTIONS: Record = { label: 'Gm', colors: 'bg-[#2563eb] text-white', }, + kimi: { + label: 'Km', + colors: 'bg-[#7c3aed] text-white', + }, opencode: { label: 'Op', colors: 'bg-[#15803d] text-white', diff --git a/web/src/components/ToolCard/PermissionFooter.tsx b/web/src/components/ToolCard/PermissionFooter.tsx index 30ab9e1f..9864e874 100644 --- a/web/src/components/ToolCard/PermissionFooter.tsx +++ b/web/src/components/ToolCard/PermissionFooter.tsx @@ -29,6 +29,10 @@ function isCodexSession(metadata: SessionMetadataSummary | null, toolName: strin || toolName.startsWith('OpenCode') } +function isClaudeSession(metadata: SessionMetadataSummary | null): boolean { + return metadata?.flavor === 'claude' +} + function formatPermissionSummary(permission: ToolPermission, toolName: string, toolInput: unknown, codex: boolean, t: (key: string) => string): string { if (permission.status === 'pending') return t('tool.waitingForApproval') if (permission.status === 'canceled') return permission.reason ? `${t('tool.canceled')}: ${permission.reason}` : t('tool.canceled') @@ -43,7 +47,7 @@ function formatPermissionSummary(permission: ToolPermission, toolName: string, t if (permission.status === 'approved') { if (permission.mode === 'acceptEdits') return t('tool.approvedAllowAllEdits') - if (isToolAllowedForSession(toolName, toolInput, permission.allowedTools)) return t('tool.approvedForSession') + if (permission.decision === 'approved_for_session' || isToolAllowedForSession(toolName, toolInput, permission.allowedTools)) return t('tool.approvedForSession') return t('tool.approved') } @@ -106,6 +110,7 @@ export function PermissionFooter(props: { const [error, setError] = useState(null) const codex = useMemo(() => isCodexSession(props.metadata, props.tool.name), [props.metadata, props.tool.name]) + const claude = useMemo(() => isClaudeSession(props.metadata), [props.metadata]) if (!permission) return null @@ -138,7 +143,7 @@ export function PermissionFooter(props: { || toolName === 'ExitPlanMode' const canAllowForSession = !codex && isPending && !hideAllowForSession - const canAllowAllEdits = !codex && isPending && isEditTool + const canAllowAllEdits = claude && isPending && isEditTool const approve = async () => { if (!isPending || loading || loadingAllEdits || loadingForSession) return @@ -157,9 +162,13 @@ export function PermissionFooter(props: { const approveForSession = async () => { if (!canAllowForSession || loading || loadingAllEdits || loadingForSession) return setLoadingForSession(true) - const command = toolName === 'Bash' ? getInputStringAny(props.tool.input, ['command', 'cmd']) : null - const toolIdentifier = toolName === 'Bash' && command ? `Bash(${command})` : toolName - await run(() => props.api.approvePermission(props.sessionId, permission.id, { allowTools: [toolIdentifier] }), 'success') + if (claude) { + const command = toolName === 'Bash' ? getInputStringAny(props.tool.input, ['command', 'cmd']) : null + const toolIdentifier = toolName === 'Bash' && command ? `Bash(${command})` : toolName + await run(() => props.api.approvePermission(props.sessionId, permission.id, { allowTools: [toolIdentifier] }), 'success') + } else { + await run(() => props.api.approvePermission(props.sessionId, permission.id, { decision: 'approved_for_session' }), 'success') + } setLoadingForSession(false) } diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index 23032a55..1afc5a89 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -21,6 +21,7 @@ export type MessageWindowState = { export const VISIBLE_WINDOW_SIZE = 400 export const PENDING_WINDOW_SIZE = 200 const AGENT_RUN_WINDOW_SIZE = 800 +const OLDER_LOAD_WINDOW_SIZE = VISIBLE_WINDOW_SIZE * 2 const PAGE_SIZE = 50 const COLD_LOAD_BACKFILL_PAGE_SIZE = 200 const COLD_LOAD_REGULAR_TARGET = PAGE_SIZE @@ -840,7 +841,7 @@ export async function fetchOlderMessages(api: ApiClient, sessionId: string): Pro updateStateForGeneration(sessionId, 'older', generation, (prev) => { const merged = mergeMessages(response.messages, prev.messages) - const trimmed = trimVisible(merged, 'prepend') + const trimmed = trimPreservingQueued(merged, OLDER_LOAD_WINDOW_SIZE, 'prepend').kept return buildState(prev, { messages: trimmed, hasMore: response.page.hasMore,