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,