From 1e442fb3c0da50a8239bf30662c21d88fda511fc Mon Sep 17 00:00:00 2001 From: Foad Kesheh Date: Fri, 17 Jul 2026 09:33:28 -0300 Subject: [PATCH 1/3] feat: kimi-code as a first-class agent harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the standalone Kimi Code CLI as a harness alongside the existing kimi provider-flavor (distinct products — OAuth CLI vs claude-CLI-on- Moonshot). One registry entry + buildKimiCodeCommand drives every derived bridge union; UI/sibling-CLI mirrors updated by hand (picker, model aliases k3/kimi-for-coding[-highspeed], badge, command branch, LoopHarness). Launches the absolute $HOME/.kimi-code/bin/kimi (installer only adds it to .zshrc, which zsh -l -c skips), --yolo auto-approve, -m model; TUI takes no positional prompt (promptAsCliArg:false, typed after launch like opencode). grok-shaped limits: no hooks/workflow/provider-env; resume + usage extraction deferred to follow-ups. --- bridge/src/agent-commands.ts | 1 + bridge/src/ftown-sessions-cli.ts | 2 +- bridge/src/harness-registry.test.ts | 18 ++++++++-- bridge/src/harness-registry.ts | 20 +++++++++++ bridge/src/usage-collector.ts | 2 +- bridge/src/wire-types.ts | 2 +- e2e/helpers/loops.ts | 2 +- ui/src/components/LoopFormModal.tsx | 1 + ui/src/components/NewSessionModal.tsx | 50 ++++++++++++++++++++++++--- ui/src/components/SessionList.tsx | 8 ++--- ui/src/hooks/useSessions.ts | 6 +++- ui/src/lib/agent-commands.ts | 11 ++++++ ui/src/lib/format-usage.ts | 2 +- ui/src/types.ts | 4 +-- 14 files changed, 110 insertions(+), 19 deletions(-) diff --git a/bridge/src/agent-commands.ts b/bridge/src/agent-commands.ts index 84950e5..291fadb 100644 --- a/bridge/src/agent-commands.ts +++ b/bridge/src/agent-commands.ts @@ -10,6 +10,7 @@ export { buildCursorAgentCommand, buildCodexCommand, buildGrokCommand, + buildKimiCodeCommand, } from './harness-registry.js'; export interface BuildSessionCommandInput { diff --git a/bridge/src/ftown-sessions-cli.ts b/bridge/src/ftown-sessions-cli.ts index e707538..41e5cfb 100644 --- a/bridge/src/ftown-sessions-cli.ts +++ b/bridge/src/ftown-sessions-cli.ts @@ -171,7 +171,7 @@ function parseLoopSchedule(args: string[], required: boolean): LoopSchedule | un function parseLoopHarness(raw: string | undefined): LoopHarness { const harness = (raw ?? 'claude') as LoopHarness; - if (!['claude', 'cursor', 'codex', 'grok', 'opencode', 'shell'].includes(harness)) { + if (!['claude', 'cursor', 'codex', 'grok', 'kimi-code', 'opencode', 'shell'].includes(harness)) { throw new Error(`Invalid --shell "${raw}"`); } return harness; diff --git a/bridge/src/harness-registry.test.ts b/bridge/src/harness-registry.test.ts index 2b86c16..ee67721 100644 --- a/bridge/src/harness-registry.test.ts +++ b/bridge/src/harness-registry.test.ts @@ -23,7 +23,7 @@ type Equals = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const _shellTypeIsRegistryKeys: Equals = true; const _loopHarnessUnion: Equals< LoopHarness, - 'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'opencode' + 'claude' | 'cursor' | 'codex' | 'shell' | 'grok' | 'kimi-code' | 'opencode' > = true; const _workflowShellUnion: Equals< WorkflowShell, @@ -37,7 +37,7 @@ describe('harness registry', () => { it('contains exactly the historical ShellType set', () => { assert.deepEqual( [...SHELL_TYPES].sort(), - ['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'opencode', 'shell', 'zai'], + ['claude', 'codex', 'cursor', 'deepseek', 'fireworks', 'grok', 'kimi', 'kimi-code', 'opencode', 'shell', 'zai'], ); }); @@ -85,7 +85,7 @@ describe('harness registry', () => { it('derives the historical LOOP_HARNESSES set', () => { assert.deepEqual( [...LOOP_HARNESS_TYPES].sort(), - ['claude', 'codex', 'cursor', 'grok', 'opencode', 'shell'], + ['claude', 'codex', 'cursor', 'grok', 'kimi-code', 'opencode', 'shell'], ); }); @@ -145,6 +145,18 @@ describe('harness registry', () => { assert.equal(harnessAcceptsPromptAsCliArg('opencode', {}), false); }); + it('kimi-code: never (interactive TUI takes no positional prompt)', () => { + assert.equal(harnessAcceptsPromptAsCliArg('kimi-code', {}), false); + assert.equal( + harnessAcceptsPromptAsCliArg('kimi-code', { + claudeSessionId: 'a', + cursorSessionId: 'b', + codexSessionId: 'c', + }), + false, + ); + }); + it('blank resume ids do not suppress the CLI arg (trim semantics preserved)', () => { assert.equal(harnessAcceptsPromptAsCliArg('claude', { claudeSessionId: ' ' }), true); assert.equal(harnessAcceptsPromptAsCliArg('cursor', { cursorSessionId: '' }), true); diff --git a/bridge/src/harness-registry.ts b/bridge/src/harness-registry.ts index cad5910..e6e517f 100644 --- a/bridge/src/harness-registry.ts +++ b/bridge/src/harness-registry.ts @@ -131,6 +131,18 @@ export function buildGrokCommand(options: { return parts.join(' '); } +export function buildKimiCodeCommand(options: { model?: string }): string { + // Absolute path: the kimi-code installer adds ~/.kimi-code/bin to PATH only + // via .zshrc (interactive), but ftown launches agents with `zsh -l -c` + // (non-interactive login), which does NOT source .zshrc — so a bare `kimi` + // would fail to resolve. --yolo auto-approves all tool actions for unattended runs. + const parts = ['"$HOME/.kimi-code/bin/kimi"', '--yolo']; + if (options.model?.trim()) { + parts.push('-m', shellQuote(options.model.trim())); + } + return parts.join(' '); +} + /** claude CLI launch — shared by 'claude' and every claude-rebadged provider flavor. */ function buildClaudeCommand(input: BuildCommandInput): string { const parts = ['claude', '--allow-dangerously-skip-permissions']; @@ -231,6 +243,14 @@ export const HARNESSES = { validForLoop: true, validForWorkflow: true, }, + 'kimi-code': { + // Own binary (absolute path); interactive TUI, no positional prompt. + buildCommand: (input) => buildKimiCodeCommand({ model: input.model }), + hooked: false, + promptAsCliArg: false, + validForLoop: true, + validForWorkflow: false, + }, zai: PROVIDER_FLAVOR_SPEC, kimi: PROVIDER_FLAVOR_SPEC, deepseek: PROVIDER_FLAVOR_SPEC, diff --git a/bridge/src/usage-collector.ts b/bridge/src/usage-collector.ts index 84a562b..b8f60ba 100644 --- a/bridge/src/usage-collector.ts +++ b/bridge/src/usage-collector.ts @@ -14,7 +14,7 @@ import type { ModelUsage, Session, SessionUsage } from './types.js'; * claude CLI under the hood and get a claudeSessionId persisted by the hook * pipeline, so any session with a claudeSessionId uses the claude extractor * regardless of shellType. Sessions with neither a claude nor a codex id - * (cursor, grok, plain shell) have no structured usage source and yield null. + * (cursor, grok, kimi-code, plain shell) have no structured usage source and yield null. * * TODO(opencode): add an opencode extractor once Session carries an * opencodeSessionId (no such field exists yet). diff --git a/bridge/src/wire-types.ts b/bridge/src/wire-types.ts index b7e9a57..8a8f683 100644 --- a/bridge/src/wire-types.ts +++ b/bridge/src/wire-types.ts @@ -75,7 +75,7 @@ export interface MailMessage { deliveredVia?: string; } -export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'opencode' | 'shell'; +export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'kimi-code' | 'opencode' | 'shell'; export type LoopSchedule = | { kind: 'interval'; everyMs: number } diff --git a/e2e/helpers/loops.ts b/e2e/helpers/loops.ts index f6b19a9..70e62f9 100644 --- a/e2e/helpers/loops.ts +++ b/e2e/helpers/loops.ts @@ -6,7 +6,7 @@ import { expect, type Page } from "@playwright/test"; * targeted by their placeholder / options (stable, user-visible anchors). */ -export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "opencode" | "shell"; +export type LoopHarness = "claude" | "cursor" | "codex" | "grok" | "kimi-code" | "opencode" | "shell"; export interface CreateLoopViaUiInput { name: string; diff --git a/ui/src/components/LoopFormModal.tsx b/ui/src/components/LoopFormModal.tsx index 43f5505..8fe5bac 100644 --- a/ui/src/components/LoopFormModal.tsx +++ b/ui/src/components/LoopFormModal.tsx @@ -462,6 +462,7 @@ export function LoopFormModal({ isOpen, onClose, onSubmit, bridges, editingLoop + diff --git a/ui/src/components/NewSessionModal.tsx b/ui/src/components/NewSessionModal.tsx index fac7aea..bdfc4da 100644 --- a/ui/src/components/NewSessionModal.tsx +++ b/ui/src/components/NewSessionModal.tsx @@ -77,6 +77,15 @@ const GROK_MODEL_OPTIONS = [ "grok-composer-2.5-fast", ] as const; +// kimi-code is a standalone CLI (its own binary), NOT the `kimi` provider-flavor. +// The default alias k3 is the CLI's own default, so its option value is empty — +// selecting it omits the -m flag entirely (matching the bridge command builder). +const KIMI_CODE_MODEL_OPTIONS: readonly { value: string; label: string }[] = [ + { value: "", label: "k3 (default)" }, + { value: "kimi-code/kimi-for-coding", label: "kimi-for-coding" }, + { value: "kimi-code/kimi-for-coding-highspeed", label: "kimi-for-coding-highspeed" }, +] as const; + const AUTO_COMPACT_WINDOW_KEY = "ftown:autoCompactWindow"; const FLAVOR_AUTO_COMPACT_DEFAULTS: Record<"standard" | "zai" | "kimi" | "deepseek" | "fireworks", string> = { @@ -218,6 +227,7 @@ const VALID_SHELL_TYPES: ShellType[] = [ "cursor", "codex", "grok", + "kimi-code", "opencode", "shell", ]; @@ -229,13 +239,14 @@ interface LastSessionDefaults { model?: string; } -type TopShell = "claude" | "cursor" | "codex" | "grok" | "opencode" | "shell"; +type TopShell = "claude" | "cursor" | "codex" | "grok" | "kimi-code" | "opencode" | "shell"; type ClaudeFlavor = "standard" | "zai" | "kimi" | "deepseek" | "fireworks"; function shellTypeToTop(s: ShellType | undefined): { top: TopShell; flavor: ClaudeFlavor } { if (s === "cursor") return { top: "cursor", flavor: "standard" }; if (s === "codex") return { top: "codex", flavor: "standard" }; if (s === "grok") return { top: "grok", flavor: "standard" }; + if (s === "kimi-code") return { top: "kimi-code", flavor: "standard" }; if (s === "opencode") return { top: "opencode", flavor: "standard" }; if (s === "shell") return { top: "shell", flavor: "standard" }; if (s === "zai") return { top: "claude", flavor: "zai" }; @@ -246,7 +257,7 @@ function shellTypeToTop(s: ShellType | undefined): { top: TopShell; flavor: Clau } function resolveShellType(top: TopShell, flavor: ClaudeFlavor): ShellType { - if (top === "cursor" || top === "codex" || top === "grok" || top === "opencode" || top === "shell") return top; + if (top === "cursor" || top === "codex" || top === "grok" || top === "kimi-code" || top === "opencode" || top === "shell") return top; if (flavor === "standard") return "claude"; return flavor; } @@ -309,6 +320,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, const [fireworksModels, setFireworksModels] = useState(FIREWORKS_DEFAULT_MODELS); const [zaiModels, setZaiModels] = useState(ZAI_DEFAULT_MODELS); const [grokModel, setGrokModel] = useState(GROK_MODEL_OPTIONS[0]); + const [kimiCodeModel, setKimiCodeModel] = useState(KIMI_CODE_MODEL_OPTIONS[0].value); const [autoCompactWindow, setAutoCompactWindow] = useState(""); const [submitError, setSubmitError] = useState(null); const [missingWorkingDir, setMissingWorkingDir] = useState(null); @@ -348,6 +360,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setFireworksModels(getStoredFireworksModels()); setZaiModels(getStoredZaiModels()); setGrokModel(GROK_MODEL_OPTIONS[0]); + setKimiCodeModel(KIMI_CODE_MODEL_OPTIONS[0].value); setAutoCompactWindow(getStoredAutoCompactWindow()); setSubmitError(null); setMissingWorkingDir(null); @@ -387,6 +400,10 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setGrokModel(parsed.model); } + if (restoredShellType === "kimi-code" && typeof parsed.model === "string") { + setKimiCodeModel(parsed.model); + } + if (defaults?.workingDir === undefined && typeof parsed.workingDir === "string") { setWorkingDir(parsed.workingDir); } @@ -439,7 +456,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, try { await onSubmit("", { name: name.trim() || undefined, - model: shellType === "grok" ? grokModel : undefined, + model: shellType === "grok" ? grokModel : shellType === "kimi-code" ? kimiCodeModel : undefined, workingDir: workingDir.trim() || undefined, bridgeId: effectiveBridgeId || undefined, shellType, @@ -471,6 +488,9 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, if (shellType === "grok") { lastDefaults.model = grokModel; } + if (shellType === "kimi-code") { + lastDefaults.model = kimiCodeModel; + } localStorage.setItem(LAST_SESSION_DEFAULTS_KEY, JSON.stringify(lastDefaults)); } catch { // localStorage unavailable (private browsing, quota) — ignore @@ -481,6 +501,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setTopShell("claude"); setClaudeFlavor("standard"); setGrokModel(GROK_MODEL_OPTIONS[0]); + setKimiCodeModel(KIMI_CODE_MODEL_OPTIONS[0].value); setBridgeId(""); setShowSuggestions(false); setSelectedClaudeSessionId(null); @@ -488,7 +509,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, setSelectedCursorSessionId(null); setSelectedCursorSummary(null); onClose(); - }, [shellType, topShell, claudeFlavor, name, workingDir, effectiveBridgeId, hostname, selectedClaudeSessionId, selectedCursorSessionId, fireworksModels, zaiModels, grokModel, autoCompactWindow, onSubmit, onClose]); + }, [shellType, topShell, claudeFlavor, name, workingDir, effectiveBridgeId, hostname, selectedClaudeSessionId, selectedCursorSessionId, fireworksModels, zaiModels, grokModel, kimiCodeModel, autoCompactWindow, onSubmit, onClose]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -549,6 +570,7 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, + @@ -640,6 +662,26 @@ export function NewSessionModal({ isOpen, onClose, onSubmit, bridges, defaults, )} + {shellType === "kimi-code" && ( +
+ + +
+ )} + {topShell === "claude" && (