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
@@ -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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_call' }> => 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<AgentMessage, { type: 'tool_result' }> => 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));
+134 -14
View File
@@ -39,6 +39,40 @@ function deriveToolNameFromUpdate(update: Record<string, unknown>): 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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',
+1
View File
@@ -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
+7 -3
View File
@@ -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' };
}