feat: add support for Kimi Code CLI and fixed some bugs (#659)

* Add Kimi agent support via ACP protocol

Add full integration for the Kimi Code CLI agent using the standard
Agent Client Protocol (ACP). Includes:

- kimi command and CLI registry wiring
- Local launcher spawning kimi directly
- Remote launcher with ACP stdio transport via AcpSdkBackend
- Session management with resume support
- Permission handler supporting all Kimi permission modes
- Terminal UI display component
- Runtime config resolving model from env and ~/.kimi/config.toml

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Fix Kimi ACP tool call input decoding on web

Kimi streams tool arguments as JSON text inside the content array
(e.g. {\"command\": \"df -h\"}) instead of rawInput/kind. The handler
now extracts input from three sources in priority order:

1. rawInput (Claude/Codex path)
2. kind + title fallback (Gemini path)
3. content JSON text (Kimi path)

Also handles:
- rawInput: null no longer blocks the kind+title fallback
- Title prefixes like \"Shell: free -h\" are stripped to extract args
- Stale placeholder inputs are re-derived when the title updates
- Normalized kind aliases (shell, run, read_file, write, etc.)

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* Add kimi support to web UI

* Fix some bugs

* fix(kimi): dedupe repeated tool_call display in terminal UI

* fix(web): keep tool block immutable so React detects input/state changes

* fix(web): recognise Kimi subagent titles like 'Agent: ...' as subagent tools

* fix(web): allow-for-session for ACP agents (kimi, cursor)

PermissionFooter treated all non-codex sessions as Claude, sending
Claude-specific acceptEdits/allowTools to ACP agents that don't
support them. Hub rejected acceptEdits for kimi, and the ACP
PermissionAdapter ignored allowTools.

- Only show 'allow all edits' for Claude sessions
- Send decision: approved_for_session for non-Claude ACP agents
- Update status display to check decision field

* fix(web): lookup subagent sidechains by tool-call id instead of msg id

* fix(web): don't trim newest messages when loading older history

fetchOlderMessages was using trimVisible(merged, 'prepend') which kept
the oldest 400 messages and dropped the newest ones. This caused:
1. Latest messages to disappear when user loaded older history
2. User to see no visible change when new old messages were drowned
   in the 400-message window.

Remove the incorrect trim so all fetched older messages are retained
alongside the current window. Subsequent ingestIncomingMessages
(append mode) will naturally keep the window bounded when new agent
messages arrive.

* fix(cli): route Kimi session resume to runKimi instead of runCursor

Kimi was present in AGENT_FLAVORS but dispatchLocalResume had no branch
for it, so resuming a Kimi session fell through to the Cursor launcher.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): pass selected model to Kimi ACP backend via KIMI_MODEL env

createKimiBackend was ignoring opts.model and only setting KIMI_PROJECT_DIR.
Use buildKimiEnv so the selected model reaches the subprocess as KIMI_MODEL.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): bound message window on older loads with dedicated larger cap

fetchOlderMessages was keeping all messages unbounded, causing
sessionStorage bloat on repeated pagination. Reintroduce trimming
with OLDER_LOAD_WINDOW_SIZE (800) so growth is capped while the
newest messages are still preserved for far longer than before.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(web): revert sidechain lookup to message id, matching tracer/grouping pipeline

tracer.ts sets sidechainId to the parent message id, and reducer.ts groups
by sidechainId. A prior commit changed reducerTimeline.ts to look up by
tool-call id (c.id), which broke sidechain attachment. Revert to msg.id
so the lookup matches the actual grouping key end-to-end.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(cli): gate ACP title prefix stripping to known tool-kind labels

extractTitleArgument stripped at the first colon unconditionally,
corrupting commands/paths like curl http://localhost:3000 or
Windows paths. Now it only strips when the prefix normalizes to
the same tool kind as the event, verified via regex.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

* fix(shared): include kimi in isCodexFamilyFlavor for ACP permission UI

Kimi is an ACP-style agent that supports the abort decision, but
isCodexFamilyFlavor excluded it, so PermissionFooter rendered the
non-Codex Allow/Deny UI without the Abort button.

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
2026-05-22 10:08:14 +08:00
committed by GitHub
co-authored by HAPI
parent 6aa7274851
commit 763f45acdd
35 changed files with 1697 additions and 54 deletions
+1 -1
View File
@@ -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<string, TracedMessage[]>()
groups.set('msg-agent', [sidechainChild])
+27 -18
View File
@@ -25,9 +25,12 @@ function getAgentRunCompletedAt(event: Record<string, unknown>): 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<string, unknown>, fallback: string): string {
@@ -319,9 +322,12 @@ export function reduceTimeline(
const patchAgentRunInput = (block: ToolCallBlock, patch: Record<string, unknown>): 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
}
+8 -6
View File
@@ -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,
+1 -1
View File
@@ -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:')
}
@@ -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)
}
+3
View File
@@ -27,6 +27,9 @@ export const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]
{ value: 'auto', label: 'Default' },
],
cursor: [],
kimi: [
{ value: 'auto', label: 'Default' },
],
gemini: [
{ value: 'auto', label: 'Default' },
...modelPresetOptions(GEMINI_MODEL_PRESETS, GEMINI_MODEL_LABELS),
+4
View File
@@ -505,6 +505,10 @@ const FLAVOR_BADGES: Record<string, { label: string; colors: string }> = {
label: 'Gm',
colors: 'bg-[#2563eb] text-white',
},
kimi: {
label: 'Km',
colors: 'bg-[#7c3aed] text-white',
},
opencode: {
label: 'Op',
colors: 'bg-[#15803d] text-white',
@@ -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<string | null>(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)
}
+2 -1
View File
@@ -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,