diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3b1c156b3..de92b6c81 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -225,6 +225,11 @@ They are distinct from Cline SDK plugin runtime hooks such as `beforeRun`, - `PreToolUse` for active tools like `Read`, `Grep`, `Glob`, `FetchUrl`, `WebSearch`, `Execute`, `Task`, `Edit`, and `Create` emits `to_in_progress` - `PreToolUse` for `AskUser` and `Stop` emit `to_review` - `PostToolUse` for `AskUser` and `UserPromptSubmit` emit `to_in_progress` +- Kimi Code + - Kanban launches with a generated `--config-file` that preserves the user's default TOML config and appends Kanban hook entries + - `UserPromptSubmit` and `PreToolUse` emit `to_in_progress` + - `Stop`, `StopFailure`, and approval/attention notifications emit `to_review` + - the initial task prompt is bracketed-pasted into the interactive TUI after the first terminal output Important behavior details: diff --git a/docs/architecture.md b/docs/architecture.md index 9fb5e9cab..e6f42aabc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,7 +124,7 @@ Kanban currently supports three runtime modes. | Runtime mode | Used for | Scope | Backing implementation | Why it exists | | --- | --- | --- | --- | --- | | Native Cline chat | Cline | task-scoped, plus a project-scoped sidebar surface | Cline SDK session host | Cline exposes richer chat semantics, provider settings, OAuth, and persisted session history | -| CLI-backed task terminal | Claude Code, Codex, Gemini, OpenCode, Droid, and similar agents | task-scoped | PTY-backed process runtime | these agents are command-driven CLIs and already fit the terminal model well | +| CLI-backed task terminal | Claude Code, Codex, Gemini, OpenCode, Droid, Kimi Code, and similar agents | task-scoped | PTY-backed process runtime | these agents are command-driven CLIs and already fit the terminal model well | | Workspace shell terminal | the bottom shell panel | workspace-scoped | PTY-backed shell process | this is for manual commands in the repo, not task execution | The crucial point is that Cline is not just "another agent command". It is a native runtime path. Treating it like a terminal process would throw away useful structure that the SDK already gives us. @@ -201,7 +201,7 @@ The `src/terminal/` area owns everything process-oriented: - translating process lifecycle into Kanban runtime summaries - handling the workspace shell terminal -This is the path for Claude Code, Codex, Gemini, OpenCode, Droid, and any other command-driven agent. +This is the path for Claude Code, Codex, Gemini, OpenCode, Droid, Kimi Code, and any other command-driven agent. ### Native Cline integration @@ -391,7 +391,7 @@ When you are making a change, this table is often more useful than a file list. | If you are changing... | Think about this first | Common mistake to avoid | | --- | --- | --- | -| task startup for Claude Code, Codex, Gemini, OpenCode, or Droid | the PTY runtime and agent launch path | accidentally adding special logic to the Cline path | +| task startup for Claude Code, Codex, Gemini, OpenCode, Droid, or Kimi Code | the PTY runtime and agent launch path | accidentally adding special logic to the Cline path | | Cline provider settings, models, or OAuth | the Cline provider service and SDK provider boundary | storing secrets in Kanban config or duplicating OAuth policy | | Cline message rendering or send/cancel behavior | the shared Cline hooks and task-session service | making detail view and sidebar behave differently | | live board updates | the runtime state hub and browser stream consumers | falling back to polling or duplicating summary logic | diff --git a/src/commands/task.ts b/src/commands/task.ts index db2bc653b..7ad0645d9 100644 --- a/src/commands/task.ts +++ b/src/commands/task.ts @@ -1125,7 +1125,10 @@ export function registerTaskCommand(program: Command): void { .option("--start-in-plan-mode [value]", "Set plan mode (true|false). Flag-only implies true.") .option("--auto-review-enabled [value]", "Enable auto-review behavior (true|false). Flag-only implies true.") .option("--auto-review-mode ", "Auto-review mode: commit | pr.", parseAutoReviewMode) - .option("--agent-id ", "Agent override: cline | claude | codex | droid | gemini | opencode | default.") + .option( + "--agent-id ", + "Agent override: cline | claude | codex | droid | kiro | kimi | gemini | opencode | default.", + ) .option( "--cline-provider ", 'Cline provider override (e.g. anthropic, openai, cline). Use "default" for workspace default.', @@ -1187,7 +1190,7 @@ export function registerTaskCommand(program: Command): void { .option("--auto-review-mode ", "Auto-review mode: commit | pr.", parseAutoReviewMode) .option( "--agent-id ", - 'Agent override: cline | claude | codex | droid | gemini | opencode. Use "default" to clear.', + 'Agent override: cline | claude | codex | droid | kiro | kimi | gemini | opencode. Use "default" to clear.', ) .option( "--cline-provider ", diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 215228cd5..89ff1cc4a 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -54,7 +54,7 @@ const PROJECT_CONFIG_PARENT_DIR = ".cline"; const PROJECT_CONFIG_DIR = "kanban"; const PROJECT_CONFIG_FILENAME = "config.json"; const DEFAULT_AGENT_ID: RuntimeAgentId = "cline"; -const AUTO_SELECT_AGENT_PRIORITY: readonly RuntimeAgentId[] = ["claude", "codex", "droid", "kiro"]; +const AUTO_SELECT_AGENT_PRIORITY: readonly RuntimeAgentId[] = ["claude", "codex", "droid", "kiro", "kimi"]; const DEFAULT_AGENT_AUTONOMOUS_MODE_ENABLED = true; const DEFAULT_READY_FOR_REVIEW_NOTIFICATIONS_ENABLED = true; const DEFAULT_COMMIT_PROMPT_TEMPLATE = `You are in a worktree on a detached HEAD. When you are finished with the task, commit the working changes onto {{base_ref}}. @@ -124,6 +124,7 @@ function normalizeAgentId(agentId: RuntimeAgentId | string | null | undefined): agentId === "opencode" || agentId === "droid" || agentId === "kiro" || + agentId === "kimi" || agentId === "cline") && isRuntimeAgentLaunchSupported(agentId) ) { diff --git a/src/core/agent-catalog.ts b/src/core/agent-catalog.ts index 8f92b7114..098c69381 100644 --- a/src/core/agent-catalog.ts +++ b/src/core/agent-catalog.ts @@ -58,6 +58,14 @@ export const RUNTIME_AGENT_CATALOG: RuntimeAgentCatalogEntry[] = [ autonomousArgs: ["--trust-all-tools"], installUrl: "https://kiro.dev", }, + { + id: "kimi", + label: "Kimi Code", + binary: "kimi", + baseArgs: [], + autonomousArgs: ["--yolo"], + installUrl: "https://moonshotai.github.io/kimi-code/en/guides/getting-started", + }, { id: "gemini", label: "Gemini CLI", @@ -76,6 +84,7 @@ export const RUNTIME_LAUNCH_SUPPORTED_AGENT_IDS: readonly RuntimeAgentId[] = [ "codex", "droid", "kiro", + "kimi", // "opencode", // "gemini", ]; diff --git a/src/core/api-contract.ts b/src/core/api-contract.ts index 774ecf857..7c6f03d6e 100644 --- a/src/core/api-contract.ts +++ b/src/core/api-contract.ts @@ -71,7 +71,7 @@ export const runtimeSlashCommandsResponseSchema = z.object({ }); export type RuntimeSlashCommandsResponse = z.infer; -export const runtimeAgentIdSchema = z.enum(["claude", "codex", "gemini", "opencode", "droid", "kiro", "cline"]); +export const runtimeAgentIdSchema = z.enum(["claude", "codex", "gemini", "opencode", "droid", "kiro", "kimi", "cline"]); export type RuntimeAgentId = z.infer; const runtimeBoardColumnIdEnum = z.enum(["backlog", "in_progress", "review", "trash"]); diff --git a/src/prompts/append-system-prompt.ts b/src/prompts/append-system-prompt.ts index 97591a329..a83578793 100644 --- a/src/prompts/append-system-prompt.ts +++ b/src/prompts/append-system-prompt.ts @@ -32,6 +32,7 @@ const APPEND_PROMPT_AGENT_IDS: readonly RuntimeAgentId[] = [ "kiro", "gemini", "opencode", + "kimi", ]; function isRuntimeAgentId(value: string): value is RuntimeAgentId { @@ -66,6 +67,8 @@ function renderLinearSetupGuidanceForAgent(agentId: RuntimeAgentId | null): stri return "- If Linear MCP is not available in the current agent (Droid), suggest running: `droid mcp add linear https://mcp.linear.app/mcp --type http`"; case "kiro": return "- If Linear MCP is not available in the current agent (Kiro CLI), suggest running: `kiro-cli mcp add --name linear --url https://mcp.linear.app/mcp --scope global`"; + case "kimi": + return "- If Linear MCP is not available in the current agent (Kimi Code), suggest checking Kimi Code MCP setup and adding the Linear MCP server from `https://linear.app/docs/mcp`."; default: return "- If Linear MCP is not available, provide setup instructions for the active agent only, then continue once OAuth is complete."; } diff --git a/src/terminal/agent-session-adapters.ts b/src/terminal/agent-session-adapters.ts index 75a0d856a..dab384c07 100644 --- a/src/terminal/agent-session-adapters.ts +++ b/src/terminal/agent-session-adapters.ts @@ -16,6 +16,8 @@ import { resolveHomeAgentAppendSystemPrompt } from "../prompts/append-system-pro import { getRuntimeHomePath } from "../state/workspace-state"; import { configureCodexHooks, hasCodexConfigOverride } from "./codex-hook-config"; import { createHookRuntimeEnv } from "./hook-runtime-context"; +import { ensureKimiKanbanAgentFile, getKimiKanbanAgentFilePath } from "./kimi-agent-config"; +import { ensureKimiKanbanConfig, getKimiKanbanConfigPath } from "./kimi-config"; import { getOpenCodeAuthPathCandidates, getOpenCodeConfigPathCandidates, @@ -127,6 +129,10 @@ function hasCliOption(args: string[], optionName: string): boolean { return false; } +function hasAnyCliOption(args: string[], optionNames: readonly string[]): boolean { + return optionNames.some((optionName) => hasCliOption(args, optionName)); +} + function getClineHookScriptPath( hooksDir: string, hookName: "Notification" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse", @@ -1370,6 +1376,62 @@ const kiroAdapter: AgentSessionAdapter = { }, }; +const kimiAdapter: AgentSessionAdapter = { + async prepare(input) { + const args = [...input.args]; + const env: Record = {}; + const appendedSystemPrompt = resolveHomeAgentAppendSystemPrompt(input.taskId); + + if (!hasAnyCliOption(args, ["--continue", "-C", "--session", "-S", "--resume", "-r"]) && input.resumeFromTrash) { + args.push("--continue"); + } + + if ( + input.autonomousModeEnabled && + !input.resumeFromTrash && + !hasAnyCliOption(args, ["--yolo", "-y", "--yes", "--auto-approve", "--afk"]) + ) { + args.push("--yolo"); + } + + if (input.startInPlanMode && !hasCliOption(args, "--plan")) { + args.push("--plan"); + } + + if (appendedSystemPrompt && !hasAnyCliOption(args, ["--agent", "--agent-file"])) { + const agentFilePath = await ensureKimiKanbanAgentFile({ + additionalSystemPrompt: appendedSystemPrompt, + agentFilePath: getKimiKanbanAgentFilePath(getRuntimeHomePath()), + }); + args.push("--agent-file", agentFilePath); + } + + const hooks = resolveHookContext(input); + if (hooks && !hasAnyCliOption(args, ["--config", "--config-file"])) { + const configPath = await ensureKimiKanbanConfig({ + buildHookCommand: (event, metadata) => buildHookCommand(event, metadata), + configPath: getKimiKanbanConfigPath(getRuntimeHomePath()), + env: input.env, + }); + args.push("--config-file", configPath); + Object.assign( + env, + createHookRuntimeEnv({ + taskId: hooks.taskId, + workspaceId: hooks.workspaceId, + }), + ); + } + + const trimmedPrompt = input.prompt.trim(); + return { + args, + env, + deferredStartupInput: trimmedPrompt ? toBracketedPasteSubmission(trimmedPrompt) : undefined, + }; + }, +}; + const clineAdapter: AgentSessionAdapter = { async prepare(input) { const args = [...input.args]; @@ -1434,6 +1496,7 @@ const ADAPTERS: Record = { opencode: opencodeAdapter, droid: droidAdapter, kiro: kiroAdapter, + kimi: kimiAdapter, cline: clineAdapter, }; diff --git a/src/terminal/kimi-agent-config.ts b/src/terminal/kimi-agent-config.ts new file mode 100644 index 000000000..d725f3c91 --- /dev/null +++ b/src/terminal/kimi-agent-config.ts @@ -0,0 +1,37 @@ +import { join } from "node:path"; + +import { lockedFileSystem } from "../fs/locked-file-system"; + +export interface EnsureKimiKanbanAgentFileInput { + additionalSystemPrompt: string; + agentFilePath: string; +} + +export function getKimiKanbanAgentFilePath(runtimeHomePath: string): string { + return join(runtimeHomePath, "hooks", "kimi", "agent.yaml"); +} + +export function buildKimiKanbanAgentFileContent(additionalSystemPrompt: string): string { + return `${JSON.stringify( + { + version: 1, + agent: { + extend: "default", + name: "kanban", + system_prompt_args: { + ROLE_ADDITIONAL: additionalSystemPrompt, + }, + }, + }, + null, + 2, + )}\n`; +} + +export async function ensureKimiKanbanAgentFile(input: EnsureKimiKanbanAgentFileInput): Promise { + await lockedFileSystem.writeTextFileAtomic( + input.agentFilePath, + buildKimiKanbanAgentFileContent(input.additionalSystemPrompt), + ); + return input.agentFilePath; +} diff --git a/src/terminal/kimi-config.ts b/src/terminal/kimi-config.ts new file mode 100644 index 000000000..4ed9d5059 --- /dev/null +++ b/src/terminal/kimi-config.ts @@ -0,0 +1,316 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +import type { RuntimeHookEvent } from "../core/api-contract"; +import { lockedFileSystem } from "../fs/locked-file-system"; + +const KIMI_KANBAN_CONFIG_REGION_START = "# "; +const KIMI_KANBAN_CONFIG_REGION_END = "# "; + +type KimiHookEvent = + | "UserPromptSubmit" + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "Notification" + | "Stop" + | "StopFailure" + | "SubagentStop"; + +export interface KimiHookCommandMetadata { + activityText?: string; + hookEventName: KimiHookEvent; + source: "kimi"; +} + +export type KimiHookCommandBuilder = (event: RuntimeHookEvent, metadata: KimiHookCommandMetadata) => string; + +interface KimiHookDefinition { + event: KimiHookEvent; + command: string; + matcher?: string; +} + +export interface EnsureKimiKanbanConfigInput { + buildHookCommand: KimiHookCommandBuilder; + configPath: string; + env?: Record; +} + +export function getKimiKanbanConfigPath(runtimeHomePath: string): string { + return join(runtimeHomePath, "hooks", "kimi", "config.toml"); +} + +export function getKimiDefaultConfigPath(env: Record | undefined): string { + const explicitHome = + env?.KIMI_SHARE_DIR?.trim() || + process.env.KIMI_SHARE_DIR?.trim() || + env?.KIMI_CODE_HOME?.trim() || + process.env.KIMI_CODE_HOME?.trim(); + return join(explicitHome || join(homedir(), ".kimi"), "config.toml"); +} + +async function readTextFileIfExists(filePath: string): Promise { + try { + return await readFile(filePath, "utf8"); + } catch { + return null; + } +} + +function tomlBasicString(value: string): string { + return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n").replaceAll("\r", "\\r")}"`; +} + +function stripKimiKanbanConfigRegion(content: string): string { + const startIndex = content.indexOf(KIMI_KANBAN_CONFIG_REGION_START); + if (startIndex < 0) { + return content.trimEnd(); + } + const endIndex = content.indexOf(KIMI_KANBAN_CONFIG_REGION_END, startIndex); + if (endIndex < 0) { + return content.slice(0, startIndex).trimEnd(); + } + return `${content.slice(0, startIndex)}${content.slice(endIndex + KIMI_KANBAN_CONFIG_REGION_END.length)}`.trimEnd(); +} + +function countTomlArrayBracketDelta(line: string): number { + let delta = 0; + let quote: '"' | "'" | null = null; + let isEscaped = false; + for (const char of line) { + if (quote) { + if (quote === '"' && char === "\\" && !isEscaped) { + isEscaped = true; + continue; + } + if (char === quote && !isEscaped) { + quote = null; + } + isEscaped = false; + continue; + } + if (char === "#") { + break; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === "[") { + delta += 1; + } else if (char === "]") { + delta -= 1; + } + } + return delta; +} + +function findTomlCommentStartIndex(line: string): number { + let quote: '"' | "'" | null = null; + let isEscaped = false; + for (let index = 0; index < line.length; index += 1) { + const char = line[index] ?? ""; + if (quote) { + if (quote === '"' && char === "\\" && !isEscaped) { + isEscaped = true; + continue; + } + if (char === quote && !isEscaped) { + quote = null; + } + isEscaped = false; + continue; + } + if (char === "#") { + return index; + } + if (char === '"' || char === "'") { + quote = char; + } + } + return -1; +} + +function isTomlTableHeaderLine(line: string): boolean { + return /^\s*\[\[?\s*(?:"|'|[A-Za-z0-9_-])/.test(line); +} + +function findFirstTomlTableLineIndex(lines: readonly string[]): number { + let bracketDepth = 0; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index] ?? ""; + if (bracketDepth <= 0 && isTomlTableHeaderLine(line)) { + return index; + } + bracketDepth += countTomlArrayBracketDelta(line); + } + return -1; +} + +function extractTomlArrayBody(block: string): string { + const startIndex = block.indexOf("["); + const endIndex = block.lastIndexOf("]"); + if (startIndex < 0 || endIndex <= startIndex) { + return ""; + } + return block.slice(startIndex + 1, endIndex).trim(); +} + +function removeTopLevelKimiHooksAssignment(content: string): { content: string; hooksBody: string } { + const lines = content.split("\n"); + const firstTableLineIndex = findFirstTomlTableLineIndex(lines); + const topLevelEndIndex = firstTableLineIndex < 0 ? lines.length : firstTableLineIndex; + const startIndex = lines.findIndex((line, index) => index < topLevelEndIndex && /^\s*hooks\s*=/.test(line)); + if (startIndex < 0) { + return { + content: content.trimEnd(), + hooksBody: "", + }; + } + + let endIndex = startIndex; + let bracketDepth = 0; + for (let index = startIndex; index < topLevelEndIndex; index += 1) { + bracketDepth += countTomlArrayBracketDelta(lines[index] ?? ""); + endIndex = index; + if (bracketDepth <= 0) { + break; + } + } + + const hooksBlock = lines.slice(startIndex, endIndex + 1).join("\n"); + const contentWithoutHooks = [...lines.slice(0, startIndex), ...lines.slice(endIndex + 1)].join("\n").trimEnd(); + return { + content: contentWithoutHooks, + hooksBody: extractTomlArrayBody(hooksBlock), + }; +} + +function ensureTomlArrayBodyTrailingComma(lines: string[]): string[] { + let lastValueIndex = -1; + for (let index = lines.length - 1; index >= 0; index -= 1) { + const trimmed = lines[index]?.trim() ?? ""; + if (trimmed !== "" && !trimmed.startsWith("#")) { + lastValueIndex = index; + break; + } + } + if (lastValueIndex < 0) { + return lines; + } + const lastLine = lines[lastValueIndex] ?? ""; + const commentStartIndex = findTomlCommentStartIndex(lastLine); + const valuePart = commentStartIndex < 0 ? lastLine : lastLine.slice(0, commentStartIndex); + const commentPart = commentStartIndex < 0 ? "" : lastLine.slice(commentStartIndex); + const trimmedValuePart = valuePart.trimEnd(); + if (trimmedValuePart.endsWith(",")) { + return lines; + } + const nextLines = [...lines]; + nextLines[lastValueIndex] = `${trimmedValuePart},${valuePart.slice(trimmedValuePart.length)}${commentPart}`; + return nextLines; +} + +function indentTomlArrayBody(body: string): string[] { + if (!body.trim()) { + return []; + } + return ensureTomlArrayBodyTrailingComma(body.trim().split("\n")).map((line) => ` ${line.trimEnd()}`); +} + +function formatKimiHookDefinition(hook: KimiHookDefinition): string { + const fields = [`event = ${tomlBasicString(hook.event)}`]; + if (hook.matcher) { + fields.push(`matcher = ${tomlBasicString(hook.matcher)}`); + } + fields.push(`command = ${tomlBasicString(hook.command)}`, "timeout = 5"); + return `{ ${fields.join(", ")} }`; +} + +function buildKimiKanbanHookDefinitions(buildHookCommand: KimiHookCommandBuilder): KimiHookDefinition[] { + return [ + { + event: "UserPromptSubmit", + command: buildHookCommand("to_in_progress", { source: "kimi", hookEventName: "UserPromptSubmit" }), + }, + { + event: "PreToolUse", + command: buildHookCommand("activity", { source: "kimi", hookEventName: "PreToolUse" }), + }, + { + event: "PreToolUse", + command: buildHookCommand("to_in_progress", { source: "kimi", hookEventName: "PreToolUse" }), + }, + { + event: "PostToolUse", + command: buildHookCommand("activity", { source: "kimi", hookEventName: "PostToolUse" }), + }, + { + event: "PostToolUseFailure", + command: buildHookCommand("activity", { source: "kimi", hookEventName: "PostToolUseFailure" }), + }, + { + event: "Notification", + matcher: "permission|approval|attention", + command: buildHookCommand("to_review", { source: "kimi", hookEventName: "Notification" }), + }, + { + event: "Notification", + command: buildHookCommand("activity", { source: "kimi", hookEventName: "Notification" }), + }, + { + event: "Stop", + command: buildHookCommand("to_review", { + source: "kimi", + hookEventName: "Stop", + activityText: "Waiting for review", + }), + }, + { + event: "StopFailure", + command: buildHookCommand("to_review", { source: "kimi", hookEventName: "StopFailure" }), + }, + { + event: "SubagentStop", + command: buildHookCommand("activity", { source: "kimi", hookEventName: "SubagentStop" }), + }, + ]; +} + +export function buildKimiKanbanConfigContent(baseContent: string, buildHookCommand: KimiHookCommandBuilder): string { + const baseConfig = stripKimiKanbanConfigRegion(baseContent); + const existingHooks = removeTopLevelKimiHooksAssignment(baseConfig); + const hooksLines = [ + "hooks = [", + ...indentTomlArrayBody(existingHooks.hooksBody), + ...(existingHooks.hooksBody.trim() ? [""] : []), + ` ${KIMI_KANBAN_CONFIG_REGION_START}`, + ...buildKimiKanbanHookDefinitions(buildHookCommand).map((hook) => ` ${formatKimiHookDefinition(hook)},`), + ` ${KIMI_KANBAN_CONFIG_REGION_END}`, + "]", + ]; + const tableLineIndex = findFirstTomlTableLineIndex(existingHooks.content.split("\n")); + if (tableLineIndex < 0) { + const prefix = existingHooks.content.trimEnd(); + return prefix ? `${prefix}\n${hooksLines.join("\n")}\n` : `${hooksLines.join("\n")}\n`; + } + const lines = existingHooks.content.split("\n"); + const topLevelLines = lines.slice(0, tableLineIndex).join("\n").trimEnd(); + const tableLines = lines.slice(tableLineIndex).join("\n").trimEnd(); + const prefix = topLevelLines ? `${topLevelLines}\n` : ""; + const suffix = tableLines ? `\n\n${tableLines}\n` : "\n"; + return `${prefix}${hooksLines.join("\n")}${suffix}`; +} + +export async function ensureKimiKanbanConfig(input: EnsureKimiKanbanConfigInput): Promise { + const baseConfigPath = getKimiDefaultConfigPath(input.env); + const baseContent = + (await readTextFileIfExists(baseConfigPath)) ?? (await readTextFileIfExists(input.configPath)) ?? ""; + await lockedFileSystem.writeTextFileAtomic( + input.configPath, + buildKimiKanbanConfigContent(baseContent, input.buildHookCommand), + ); + return input.configPath; +} diff --git a/src/terminal/session-manager.ts b/src/terminal/session-manager.ts index b196c7255..a1128f6ae 100644 --- a/src/terminal/session-manager.ts +++ b/src/terminal/session-manager.ts @@ -226,6 +226,21 @@ export class TerminalSessionManager implements TerminalSessionService { return true; } + private trySendDeferredKimiStartupInput(taskId: string, agentId: string): boolean { + const entry = this.entries.get(taskId); + const active = entry?.active; + if (!entry || !active || agentId !== "kimi") { + return false; + } + if (active.deferredStartupInput === null) { + return false; + } + const deferredInput = active.deferredStartupInput; + active.deferredStartupInput = null; + active.session.write(deferredInput); + return true; + } + private hasLiveOutputListener(entry: SessionEntry): boolean { for (const listener of entry.listeners.values()) { if (listener.onOutput) { @@ -370,6 +385,7 @@ export class TerminalSessionManager implements TerminalSessionService { const needsDecodedOutput = entry.active.workspaceTrustBuffer !== null || + (entry.active.deferredStartupInput !== null && request.agentId === "kimi") || (entry.active.detectOutputTransition !== null && (entry.active.shouldInspectOutputForTransition?.(entry.summary) ?? true)); const data = needsDecodedOutput ? filteredChunk.toString("utf8") : ""; @@ -420,6 +436,10 @@ export class TerminalSessionManager implements TerminalSessionService { this.trySendDeferredCodexStartupInput(request.taskId); } + if (request.agentId === "kimi" && entry.active.deferredStartupInput !== null && data.length > 0) { + this.trySendDeferredKimiStartupInput(request.taskId, request.agentId); + } + const adapterEvent = entry.active.detectOutputTransition?.(data, entry.summary) ?? null; if (adapterEvent) { const requiresEnterForCodex = diff --git a/test/runtime/config/runtime-config.test.ts b/test/runtime/config/runtime-config.test.ts index 884b383d7..4232f77a4 100644 --- a/test/runtime/config/runtime-config.test.ts +++ b/test/runtime/config/runtime-config.test.ts @@ -70,6 +70,8 @@ describe.sequential("runtime-config auto agent selection", () => { expect(pickBestInstalledAgentIdFromDetected(["codex", "opencode", "gemini"])).toBe("codex"); expect(pickBestInstalledAgentIdFromDetected(["opencode", "droid", "gemini"])).toBe("droid"); expect(pickBestInstalledAgentIdFromDetected(["kiro-cli", "gemini"])).toBe("kiro"); + expect(pickBestInstalledAgentIdFromDetected(["kimi", "gemini"])).toBe("kimi"); + expect(pickBestInstalledAgentIdFromDetected(["droid", "kimi"])).toBe("droid"); expect(pickBestInstalledAgentIdFromDetected(["droid", "gemini", "cline"])).toBe("droid"); expect(pickBestInstalledAgentIdFromDetected(["gemini", "cline"])).toBeNull(); expect(pickBestInstalledAgentIdFromDetected(["claude", "codex", "cline"])).toBe("claude"); diff --git a/test/runtime/terminal/agent-registry.test.ts b/test/runtime/terminal/agent-registry.test.ts index 0c54bd147..25e91ee00 100644 --- a/test/runtime/terminal/agent-registry.test.ts +++ b/test/runtime/terminal/agent-registry.test.ts @@ -47,7 +47,7 @@ describe("agent-registry", () => { const detected = detectInstalledCommands(); expect(detected).toEqual(["claude"]); - expect(commandDiscoveryMocks.isBinaryAvailableOnPath).toHaveBeenCalledTimes(8); + expect(commandDiscoveryMocks.isBinaryAvailableOnPath).toHaveBeenCalledTimes(9); }); it("treats shell-only agents as unavailable", () => { @@ -78,12 +78,13 @@ describe("buildRuntimeConfigResponse", () => { }); expect(response.agentAutonomousModeEnabled).toBe(true); - expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cline", "droid", "kiro"]); + expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cline", "droid", "kiro", "kimi"]); expect(response.agents.find((agent) => agent.id === "claude")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "codex")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "cline")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "droid")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "kiro")?.defaultArgs).toEqual(["chat"]); + expect(response.agents.find((agent) => agent.id === "kimi")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "cline")?.installed).toBe(true); }); @@ -106,17 +107,19 @@ describe("buildRuntimeConfigResponse", () => { }); expect(response.agentAutonomousModeEnabled).toBe(false); - expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cline", "droid", "kiro"]); + expect(response.agents.map((agent) => agent.id)).toEqual(["claude", "codex", "cline", "droid", "kiro", "kimi"]); expect(response.agents.find((agent) => agent.id === "claude")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "codex")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "cline")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "droid")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "kiro")?.defaultArgs).toEqual(["chat"]); + expect(response.agents.find((agent) => agent.id === "kimi")?.defaultArgs).toEqual([]); expect(response.agents.find((agent) => agent.id === "cline")?.installed).toBe(true); expect(response.agents.find((agent) => agent.id === "claude")?.command).toBe("claude"); expect(response.agents.find((agent) => agent.id === "codex")?.command).toBe("codex"); expect(response.agents.find((agent) => agent.id === "droid")?.command).toBe("droid"); expect(response.agents.find((agent) => agent.id === "kiro")?.command).toBe("kiro-cli chat"); + expect(response.agents.find((agent) => agent.id === "kimi")?.command).toBe("kimi"); }); it("sets debug mode from runtime environment variables", () => { diff --git a/test/runtime/terminal/agent-session-adapters.test.ts b/test/runtime/terminal/agent-session-adapters.test.ts index 864a69ae2..767423016 100644 --- a/test/runtime/terminal/agent-session-adapters.test.ts +++ b/test/runtime/terminal/agent-session-adapters.test.ts @@ -9,6 +9,8 @@ import { prepareAgentLaunch } from "../../../src/terminal/agent-session-adapters const originalHome = process.env.HOME; const originalAppData = process.env.APPDATA; const originalLocalAppData = process.env.LOCALAPPDATA; +const originalKimiShareDir = process.env.KIMI_SHARE_DIR; +const originalKimiCodeHome = process.env.KIMI_CODE_HOME; let tempHome: string | null = null; const originalArgv = [...process.argv]; const originalExecArgv = [...process.execArgv]; @@ -72,6 +74,16 @@ afterEach(() => { } else { process.env.LOCALAPPDATA = originalLocalAppData; } + if (originalKimiShareDir === undefined) { + delete process.env.KIMI_SHARE_DIR; + } else { + process.env.KIMI_SHARE_DIR = originalKimiShareDir; + } + if (originalKimiCodeHome === undefined) { + delete process.env.KIMI_CODE_HOME; + } else { + process.env.KIMI_CODE_HOME = originalKimiCodeHome; + } process.argv = [...originalArgv]; process.execArgv = [...originalExecArgv]; Object.defineProperty(process, "execPath", { @@ -159,6 +171,54 @@ describe("prepareAgentLaunch hook strategies", () => { expect(getCodexConfigOverrideValues(launch.args, "check_for_update_on_startup")).toEqual(["false"]); }); + it("appends Kanban sidebar instructions for home Kimi Code sessions", async () => { + const homePath = setupTempHome(); + setKanbanProcessContext(); + const launch = await prepareAgentLaunch({ + taskId: "__home_agent__:workspace-1:kimi", + agentId: "kimi", + binary: "kimi", + args: [], + cwd: "/tmp", + prompt: "", + }); + + const agentFileIndex = launch.args.indexOf("--agent-file"); + expect(agentFileIndex).toBeGreaterThanOrEqual(0); + const agentFilePath = launch.args[agentFileIndex + 1] ?? ""; + expect(agentFilePath).toBe(join(homePath, ".cline", "kanban", "hooks", "kimi", "agent.yaml")); + + const agentFile = JSON.parse(readFileSync(agentFilePath, "utf8")) as { + agent?: { + extend?: string; + system_prompt_args?: { + ROLE_ADDITIONAL?: string; + }; + }; + }; + const roleAdditional = agentFile.agent?.system_prompt_args?.ROLE_ADDITIONAL ?? ""; + expect(agentFile.agent?.extend).toBe("default"); + expect(roleAdditional).toContain("Kanban sidebar agent"); + expect(roleAdditional).toContain("'/usr/local/bin/node' '/Users/example/repo/dist/cli.js' task create"); + }); + + it("preserves explicit Kimi Code agent selection for home sessions", async () => { + setupTempHome(); + setKanbanProcessContext(); + const launch = await prepareAgentLaunch({ + taskId: "__home_agent__:workspace-1:kimi", + agentId: "kimi", + binary: "kimi", + args: ["--agent", "okabe"], + cwd: "/tmp", + prompt: "", + }); + + expect(launch.args).toContain("--agent"); + expect(launch.args).toContain("okabe"); + expect(launch.args).not.toContain("--agent-file"); + }); + it("disables Codex startup update checks for Kanban-launched sessions", async () => { setupTempHome(); const launch = await prepareAgentLaunch({ @@ -390,6 +450,64 @@ describe("prepareAgentLaunch hook strategies", () => { expect(config.hooks?.stop?.[0]?.command).toContain("Waiting for review"); }); + it("writes Kimi Code hook config and defers the first prompt into the interactive session", async () => { + const homePath = setupTempHome(); + const kimiHome = join(homePath, ".kimi"); + mkdirSync(kimiHome, { recursive: true }); + writeFileSync( + join(kimiHome, "config.toml"), + [ + 'default_model = "kimi-code/kimi-for-coding"', + "telemetry = false", + "", + "hooks = [", + ' { event = "PostToolUse", command = "echo user-hook", timeout = 5 }', + "]", + "", + "[models]", + ].join("\n"), + "utf8", + ); + + const launch = await prepareAgentLaunch({ + taskId: "task-kimi-1", + agentId: "kimi", + binary: "kimi", + args: [], + autonomousModeEnabled: true, + cwd: "/tmp", + prompt: "Implement Kimi support", + startInPlanMode: true, + workspaceId: "workspace-1", + }); + + expect(launch.env.KANBAN_HOOK_TASK_ID).toBe("task-kimi-1"); + expect(launch.env.KANBAN_HOOK_WORKSPACE_ID).toBe("workspace-1"); + expect(launch.args).toContain("--yolo"); + expect(launch.args).toContain("--plan"); + + const configFileArgIndex = launch.args.indexOf("--config-file"); + expect(configFileArgIndex).toBeGreaterThanOrEqual(0); + const configPath = launch.args[configFileArgIndex + 1] ?? ""; + expect(configPath).toBe(join(homePath, ".cline", "kanban", "hooks", "kimi", "config.toml")); + + const config = readFileSync(configPath, "utf8"); + expect(config).toContain('default_model = "kimi-code/kimi-for-coding"'); + expect(config).toContain('command = "echo user-hook"'); + expect(config.match(/^hooks = \[/gm)).toHaveLength(1); + expect(config).not.toContain("[[hooks]]"); + expect(config).toContain("# "); + expect(config).toContain('event = "UserPromptSubmit"'); + expect(config).toContain('event = "Stop"'); + expect(config).toContain("'--source' 'kimi'"); + expect(config).toContain("to_review"); + expect(config).toContain("to_in_progress"); + + expect(launch.deferredStartupInput).toContain("\u001b[200~"); + expect(launch.deferredStartupInput).toContain("Implement Kimi support"); + expect(launch.deferredStartupInput?.endsWith("\r")).toBe(true); + }); + it("materializes task images for CLI prompts", async () => { setupTempHome(); const launch = await prepareAgentLaunch({ @@ -594,6 +712,17 @@ describe("prepareAgentLaunch hook strategies", () => { }); expect(kiroLaunch.args).toContain("--resume"); + const kimiLaunch = await prepareAgentLaunch({ + taskId: "task-kimi", + agentId: "kimi", + binary: "kimi", + args: [], + cwd: "/tmp", + prompt: "", + resumeFromTrash: true, + }); + expect(kimiLaunch.args).toContain("--continue"); + const clineLaunch = await prepareAgentLaunch({ taskId: "task-cline", agentId: "cline", @@ -683,6 +812,17 @@ describe("prepareAgentLaunch hook strategies", () => { }); expect(kiroLaunch.args).toContain("--trust-all-tools"); + const kimiLaunch = await prepareAgentLaunch({ + taskId: "task-kimi-auto", + agentId: "kimi", + binary: "kimi", + args: [], + autonomousModeEnabled: true, + cwd: "/tmp", + prompt: "", + }); + expect(kimiLaunch.args).toContain("--yolo"); + const clineLaunch = await prepareAgentLaunch({ taskId: "task-cline-auto", agentId: "cline", @@ -752,5 +892,16 @@ describe("prepareAgentLaunch hook strategies", () => { prompt: "", }); expect(kiroLaunch.args).toContain("--trust-all-tools"); + + const kimiLaunch = await prepareAgentLaunch({ + taskId: "task-kimi-no-auto", + agentId: "kimi", + binary: "kimi", + args: ["--yolo"], + autonomousModeEnabled: false, + cwd: "/tmp", + prompt: "", + }); + expect(kimiLaunch.args).toContain("--yolo"); }); }); diff --git a/test/runtime/terminal/kimi-agent-config.test.ts b/test/runtime/terminal/kimi-agent-config.test.ts new file mode 100644 index 000000000..edf107be9 --- /dev/null +++ b/test/runtime/terminal/kimi-agent-config.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { buildKimiKanbanAgentFileContent } from "../../../src/terminal/kimi-agent-config"; + +describe("buildKimiKanbanAgentFileContent", () => { + it("extends the default Kimi Code agent and injects Kanban sidebar instructions", () => { + const content = buildKimiKanbanAgentFileContent("Kanban sidebar agent\nUse task create."); + const parsed = JSON.parse(content) as { + agent?: { + extend?: string; + name?: string; + system_prompt_args?: { + ROLE_ADDITIONAL?: string; + }; + }; + version?: number; + }; + + expect(parsed.version).toBe(1); + expect(parsed.agent?.extend).toBe("default"); + expect(parsed.agent?.name).toBe("kanban"); + expect(parsed.agent?.system_prompt_args?.ROLE_ADDITIONAL).toBe("Kanban sidebar agent\nUse task create."); + }); +}); diff --git a/test/runtime/terminal/kimi-config.test.ts b/test/runtime/terminal/kimi-config.test.ts new file mode 100644 index 000000000..322bc8d2e --- /dev/null +++ b/test/runtime/terminal/kimi-config.test.ts @@ -0,0 +1,149 @@ +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { + buildKimiKanbanConfigContent, + getKimiDefaultConfigPath, + type KimiHookCommandBuilder, +} from "../../../src/terminal/kimi-config"; + +const originalKimiCodeHome = process.env.KIMI_CODE_HOME; +const originalKimiShareDir = process.env.KIMI_SHARE_DIR; + +const buildCommand: KimiHookCommandBuilder = (event, metadata) => + [event, metadata.source, metadata.hookEventName, metadata.activityText ?? ""] + .filter((part) => part !== "") + .join(":"); + +function countOccurrences(content: string, pattern: RegExp): number { + return content.match(pattern)?.length ?? 0; +} + +afterEach(() => { + if (originalKimiShareDir === undefined) { + delete process.env.KIMI_SHARE_DIR; + } else { + process.env.KIMI_SHARE_DIR = originalKimiShareDir; + } + if (originalKimiCodeHome === undefined) { + delete process.env.KIMI_CODE_HOME; + } else { + process.env.KIMI_CODE_HOME = originalKimiCodeHome; + } +}); + +describe("buildKimiKanbanConfigContent", () => { + it("writes one top-level hooks array before TOML tables", () => { + const config = buildKimiKanbanConfigContent( + ['default_model = ""', "hooks = []", "telemetry = true", "", "[models]", "", "[providers]"].join("\n"), + buildCommand, + ); + + expect(countOccurrences(config, /^hooks = \[/gm)).toBe(1); + expect(config).not.toContain("[[hooks]]"); + expect(config.indexOf("hooks = [")).toBeLessThan(config.indexOf("[models]")); + expect(config).toContain('event = "UserPromptSubmit"'); + expect(config).toContain('command = "to_in_progress:kimi:UserPromptSubmit"'); + }); + + it("adds hooks when the user config has no hooks key", () => { + const config = buildKimiKanbanConfigContent( + ['default_model = "kimi-code/kimi-for-coding"', "telemetry = false", "", "[models]"].join("\n"), + buildCommand, + ); + + expect(config).toContain('default_model = "kimi-code/kimi-for-coding"'); + expect(config).toContain("hooks = ["); + expect(config.indexOf("hooks = [")).toBeLessThan(config.indexOf("[models]")); + }); + + it("preserves user hooks and appends Kanban hooks in the same array", () => { + const config = buildKimiKanbanConfigContent( + [ + "hooks = [", + ' { event = "PostToolUse", command = "echo user-hook", timeout = 5 }', + "]", + "", + "[models]", + ].join("\n"), + buildCommand, + ); + + expect(config).toContain('{ event = "PostToolUse", command = "echo user-hook", timeout = 5 },'); + expect(config.indexOf("echo user-hook")).toBeLessThan(config.indexOf("# ")); + expect(countOccurrences(config, /^hooks = \[/gm)).toBe(1); + }); + + it("adds a separator comma before a trailing comment on the last user hook", () => { + const config = buildKimiKanbanConfigContent( + [ + "hooks = [", + ' { event = "Stop", command = "echo done", timeout = 5 } # stop hook', + "]", + "", + "[models]", + ].join("\n"), + buildCommand, + ); + + expect(config).toContain('{ event = "Stop", command = "echo done", timeout = 5 }, # stop hook'); + expect(config).not.toContain("# stop hook,"); + expect(config).toContain('event = "UserPromptSubmit"'); + }); + + it("does not treat indented inline array entries as TOML table headers", () => { + const config = buildKimiKanbanConfigContent( + ["allowed_values = [", ' ["inner", "value"]', "]", "", "[models]"].join("\n"), + buildCommand, + ); + + expect(config).toContain(["allowed_values = [", ' ["inner", "value"]', "]", "hooks = ["].join("\n")); + expect(config.indexOf("hooks = [")).toBeLessThan(config.indexOf("[models]")); + }); + + it("replaces an existing Kanban-managed hook region", () => { + const config = buildKimiKanbanConfigContent( + [ + "hooks = [", + ' { event = "PostToolUse", command = "echo user-hook", timeout = 5 },', + " # ", + ' { event = "Stop", command = "old-kanban-hook", timeout = 5 },', + " # ", + "]", + ].join("\n"), + buildCommand, + ); + + expect(config).toContain("echo user-hook"); + expect(config).not.toContain("old-kanban-hook"); + expect(countOccurrences(config, /# /g)).toBe(1); + expect(countOccurrences(config, /# <\/kanban-kimi-hooks>/g)).toBe(1); + }); + + it("keeps bracket and comment characters inside existing hook strings", () => { + const config = buildKimiKanbanConfigContent( + ["hooks = [", ' { event = "Stop", command = "echo [ok] # not a comment", timeout = 5 }', "]"].join("\n"), + buildCommand, + ); + + expect(config).toContain('command = "echo [ok] # not a comment"'); + expect(config).toContain('event = "UserPromptSubmit"'); + }); +}); + +describe("getKimiDefaultConfigPath", () => { + it("uses KIMI_SHARE_DIR when provided", () => { + process.env.KIMI_SHARE_DIR = "/tmp/custom-kimi"; + + expect(getKimiDefaultConfigPath(undefined)).toBe(join("/tmp/custom-kimi", "config.toml")); + expect(getKimiDefaultConfigPath({ KIMI_SHARE_DIR: "/tmp/env-kimi" })).toBe(join("/tmp/env-kimi", "config.toml")); + }); + + it("falls back to KIMI_CODE_HOME for older local overrides", () => { + process.env.KIMI_CODE_HOME = "/tmp/custom-kimi"; + + expect(getKimiDefaultConfigPath(undefined)).toBe(join("/tmp/custom-kimi", "config.toml")); + expect(getKimiDefaultConfigPath({ KIMI_CODE_HOME: "/tmp/env-kimi" })).toBe(join("/tmp/env-kimi", "config.toml")); + }); +}); diff --git a/test/runtime/terminal/session-manager-auto-restart.test.ts b/test/runtime/terminal/session-manager-auto-restart.test.ts index 61282b19a..44be2e73f 100644 --- a/test/runtime/terminal/session-manager-auto-restart.test.ts +++ b/test/runtime/terminal/session-manager-auto-restart.test.ts @@ -195,4 +195,44 @@ describe("TerminalSessionManager auto-restart", () => { expect(session.write).toHaveBeenCalledWith(deferredStartupInput); expect(session.write).toHaveBeenCalledTimes(1); }); + + it("sends deferred Kimi startup input on first terminal output", async () => { + const deferredStartupInput = "\u001b[200~Implement Kimi support\u001b[201~\r"; + prepareAgentLaunchMock.mockResolvedValue({ + binary: "kimi", + args: [], + env: {}, + deferredStartupInput, + }); + + const spawnedSessions: Array> = []; + ptySessionSpawnMock.mockImplementation((request: MockSpawnRequest) => { + const session = createMockPtySession(111, request); + spawnedSessions.push(session); + return session; + }); + + const manager = new TerminalSessionManager(); + await manager.startTaskSession({ + taskId: "task-1", + agentId: "kimi", + binary: "kimi", + args: [], + cwd: "/tmp/task-1", + prompt: "Implement Kimi support", + }); + + const session = spawnedSessions[0]; + expect(session).toBeDefined(); + if (!session) { + return; + } + + session.triggerData("Kimi Code\n"); + expect(session.write).toHaveBeenCalledWith(deferredStartupInput); + expect(session.write).toHaveBeenCalledTimes(1); + + session.triggerData("More output\n"); + expect(session.write).toHaveBeenCalledTimes(1); + }); }); diff --git a/web-ui/src/components/runtime-settings-dialog.tsx b/web-ui/src/components/runtime-settings-dialog.tsx index 314e80c68..37e4bfd68 100644 --- a/web-ui/src/components/runtime-settings-dialog.tsx +++ b/web-ui/src/components/runtime-settings-dialog.tsx @@ -92,7 +92,7 @@ const GIT_PROMPT_VARIANT_OPTIONS: Array<{ value: TaskGitAction; label: string }> export type RuntimeSettingsSection = "shortcuts"; -const SETTINGS_AGENT_ORDER: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro"]; +const SETTINGS_AGENT_ORDER: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro", "kimi"]; type SettingsNavId = "general" | "cline" | "git-prompts" | "notifications" | "appearance" | "project"; diff --git a/web-ui/src/components/task-start-agent-onboarding-carousel.tsx b/web-ui/src/components/task-start-agent-onboarding-carousel.tsx index 795594deb..d8f98ee51 100644 --- a/web-ui/src/components/task-start-agent-onboarding-carousel.tsx +++ b/web-ui/src/components/task-start-agent-onboarding-carousel.tsx @@ -83,7 +83,7 @@ export const TASK_START_ONBOARDING_SLIDES: OnboardingSlide[] = [ }, ]; -const ONBOARDING_AGENT_IDS: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro"]; +const ONBOARDING_AGENT_IDS: readonly RuntimeAgentId[] = ["cline", "claude", "codex", "droid", "kiro", "kimi"]; const FALLBACK_ONBOARDING_SLIDE: OnboardingSlide = { kind: "agent-selection", title: "", @@ -305,6 +305,9 @@ function resolveInstallInstructions(agentId: RuntimeAgentId): string { if (agentId === "kiro") { return "Amazon's coding agent with access to the latest frontier models."; } + if (agentId === "kimi") { + return "Moonshot AI's terminal coding agent with Kimi Code models."; + } return "Install from the official docs."; } @@ -321,6 +324,9 @@ function getInstallLinkLabel(agentId: RuntimeAgentId): string { if (agentId === "kiro") { return "Learn more"; } + if (agentId === "kimi") { + return "Learn more"; + } return "Install guide"; }