diff --git a/packages/views/runtimes/components/provider-logo.tsx b/packages/views/runtimes/components/provider-logo.tsx
index da7c102d2..ab449bd2b 100644
--- a/packages/views/runtimes/components/provider-logo.tsx
+++ b/packages/views/runtimes/components/provider-logo.tsx
@@ -111,6 +111,20 @@ function CursorLogo({ className }: { className: string }) {
);
}
+// Kimi (Moonshot AI) — wordmark "K" mark in Moonshot brand purple, simple
+// rounded-square logotype suitable for small icon sizes.
+function KimiLogo({ className }: { className: string }) {
+ return (
+
+ );
+}
+
export function ProviderLogo({
provider,
className = "h-4 w-4",
@@ -135,6 +149,8 @@ export function ProviderLogo({
return ;
case "cursor":
return ;
+ case "kimi":
+ return ;
default:
return ;
}
diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go
index 5eaae63f2..5bd70e1cc 100644
--- a/server/internal/daemon/config.go
+++ b/server/internal/daemon/config.go
@@ -34,7 +34,7 @@ type Config struct {
CLIVersion string // multica CLI version (e.g. "0.1.13")
LaunchedBy string // "desktop" when spawned by the Electron app, empty for standalone
Profile string // profile name (empty = default)
- Agents map[string]AgentEntry // keyed by provider: claude, codex, opencode, openclaw, hermes, gemini, pi
+ Agents map[string]AgentEntry // keyed by provider: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi
WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces)
KeepEnvAfterTask bool // preserve env after task for debugging
HealthPort int // local HTTP port for health checks (default: 19514)
@@ -142,8 +142,15 @@ func LoadConfig(overrides Overrides) (Config, error) {
Model: strings.TrimSpace(os.Getenv("MULTICA_COPILOT_MODEL")),
}
}
+ kimiPath := envOrDefault("MULTICA_KIMI_PATH", "kimi")
+ if _, err := exec.LookPath(kimiPath); err == nil {
+ agents["kimi"] = AgentEntry{
+ Path: kimiPath,
+ Model: strings.TrimSpace(os.Getenv("MULTICA_KIMI_MODEL")),
+ }
+ }
if len(agents) == 0 {
- return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, or cursor-agent and ensure it is on PATH")
+ return Config{}, fmt.Errorf("no agent CLI found: install claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor-agent, or kimi and ensure it is on PATH")
}
// Host info
diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go
index 442c00788..04a05a4da 100644
--- a/server/internal/daemon/execenv/context.go
+++ b/server/internal/daemon/execenv/context.go
@@ -17,6 +17,7 @@ import (
// OpenCode: skills → {workDir}/.config/opencode/skills/{name}/SKILL.md (native discovery)
// Pi: skills → {workDir}/.pi/agent/skills/{name}/SKILL.md (native discovery)
// Cursor: skills → {workDir}/.cursor/skills/{name}/SKILL.md (native discovery)
+// Kimi: skills → {workDir}/.kimi/skills/{name}/SKILL.md (native discovery)
// Default: skills → {workDir}/.agent_context/skills/{name}/SKILL.md
func writeContextFiles(workDir, provider string, ctx TaskContextForEnv) error {
contextDir := filepath.Join(workDir, ".agent_context")
@@ -69,6 +70,10 @@ func resolveSkillsDir(workDir, provider string) (string, error) {
case "cursor":
// Cursor natively discovers skills from .cursor/skills/ in the workdir.
skillsDir = filepath.Join(workDir, ".cursor", "skills")
+ case "kimi":
+ // Kimi Code CLI auto-discovers project-level skills from .kimi/skills/
+ // in the workdir. See https://moonshotai.github.io/kimi-cli/en/customization/skills.html
+ skillsDir = filepath.Join(workDir, ".kimi", "skills")
default:
// Fallback: write to .agent_context/skills/ (referenced by meta config).
skillsDir = filepath.Join(workDir, ".agent_context", "skills")
diff --git a/server/internal/daemon/execenv/runtime_config.go b/server/internal/daemon/execenv/runtime_config.go
index 306e23154..15185ad7f 100644
--- a/server/internal/daemon/execenv/runtime_config.go
+++ b/server/internal/daemon/execenv/runtime_config.go
@@ -18,13 +18,14 @@ import (
// For Gemini: writes {workDir}/GEMINI.md (discovered natively by the Gemini CLI)
// For Pi: writes {workDir}/AGENTS.md (skills discovered natively from ~/.pi/agent/skills/)
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
+// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
content := buildMetaSkillContent(provider, ctx)
switch provider {
case "claude":
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
- case "codex", "copilot", "opencode", "openclaw", "pi", "cursor":
+ case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi":
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
case "gemini":
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
@@ -151,8 +152,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
case "claude":
// Claude discovers skills natively from .claude/skills/ — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
- case "codex", "copilot", "opencode", "openclaw", "pi", "cursor":
- // Codex, Copilot, OpenCode, OpenClaw, Pi, and Cursor discover skills natively from their respective paths — just list names.
+ case "codex", "copilot", "opencode", "openclaw", "pi", "cursor", "kimi":
+ // Codex, Copilot, OpenCode, OpenClaw, Pi, Cursor, and Kimi discover skills natively from their respective paths — just list names.
b.WriteString("You have the following skills installed (discovered automatically):\n\n")
case "gemini":
// Gemini reads GEMINI.md directly; point it at the fallback skills dir.
diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go
index 6ac6b7704..255061d79 100644
--- a/server/pkg/agent/agent.go
+++ b/server/pkg/agent/agent.go
@@ -1,5 +1,6 @@
// Package agent provides a unified interface for executing prompts via
-// coding agents (Claude Code, Codex, OpenCode, OpenClaw, Hermes, Pi). It mirrors the happy-cli AgentBackend
+// coding agents (Claude Code, Codex, Copilot, OpenCode, OpenClaw, Hermes,
+// Gemini, Pi, Cursor, Kimi). It mirrors the happy-cli AgentBackend
// pattern, translated to idiomatic Go.
package agent
@@ -85,13 +86,13 @@ type Result struct {
// Config configures a Backend instance.
type Config struct {
- ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, or pi)
+ ExecutablePath string // path to CLI binary (claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi)
Env map[string]string // extra environment variables
Logger *slog.Logger
}
// New creates a Backend for the given agent type.
-// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor".
+// Supported types: "claude", "codex", "copilot", "opencode", "openclaw", "hermes", "gemini", "pi", "cursor", "kimi".
func New(agentType string, cfg Config) (Backend, error) {
if cfg.Logger == nil {
cfg.Logger = slog.Default()
@@ -116,8 +117,10 @@ func New(agentType string, cfg Config) (Backend, error) {
return &piBackend{cfg: cfg}, nil
case "cursor":
return &cursorBackend{cfg: cfg}, nil
+ case "kimi":
+ return &kimiBackend{cfg: cfg}, nil
default:
- return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor)", agentType)
+ return nil, fmt.Errorf("unknown agent type: %q (supported: claude, codex, copilot, opencode, openclaw, hermes, gemini, pi, cursor, kimi)", agentType)
}
}
@@ -142,6 +145,7 @@ var launchHeaders = map[string]string{
"openclaw": "openclaw agent (json)",
"opencode": "opencode run (json)",
"pi": "pi (json mode)",
+ "kimi": "kimi acp",
}
// LaunchHeader returns the user-visible launch skeleton for agentType, or an
diff --git a/server/pkg/agent/agent_test.go b/server/pkg/agent/agent_test.go
index 1fa3a07a4..ce28fa1ee 100644
--- a/server/pkg/agent/agent_test.go
+++ b/server/pkg/agent/agent_test.go
@@ -72,7 +72,7 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
// entry to launchHeaders in agent.go and extend this list.
supported := []string{
"claude", "codex", "copilot", "cursor", "gemini",
- "hermes", "openclaw", "opencode", "pi",
+ "hermes", "kimi", "openclaw", "opencode", "pi",
}
for _, t_ := range supported {
if header := LaunchHeader(t_); header == "" {
diff --git a/server/pkg/agent/hermes.go b/server/pkg/agent/hermes.go
index ba9c5d656..077f9cdfa 100644
--- a/server/pkg/agent/hermes.go
+++ b/server/pkg/agent/hermes.go
@@ -8,6 +8,7 @@ import (
"io"
"os/exec"
"regexp"
+ "strconv"
"strings"
"sync"
"time"
@@ -73,7 +74,7 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
// without this we'd report a misleading "empty output" and hide
// the real cause (wrong model for the current provider, bad
// credentials, rate limit, …) in the daemon log.
- providerErr := newHermesProviderErrorSniffer()
+ providerErr := newACPProviderErrorSniffer("hermes")
cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[hermes:stderr] "), providerErr)
if err := cmd.Start(); err != nil {
@@ -92,9 +93,10 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
promptDone := make(chan hermesPromptResult, 1)
c := &hermesClient{
- cfg: b.cfg,
- stdin: stdin,
- pending: make(map[int]*pendingRPC),
+ cfg: b.cfg,
+ stdin: stdin,
+ pending: make(map[int]*pendingRPC),
+ pendingTools: make(map[string]*pendingToolCall),
onMessage: func(msg Message) {
if msg.Type == MessageText {
outputMu.Lock()
@@ -188,7 +190,7 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
- sessionID = extractHermesSessionID(result)
+ sessionID = extractACPSessionID(result)
if sessionID == "" {
finalStatus = "failed"
finalError = "hermes session/new returned no session ID"
@@ -336,6 +338,7 @@ type hermesPromptResult struct {
type hermesClient struct {
cfg Config
stdin interface{ Write([]byte) (int, error) }
+ writeMu sync.Mutex // serialises stdin.Write calls across goroutines
mu sync.Mutex
nextID int
pending map[int]*pendingRPC
@@ -343,10 +346,39 @@ type hermesClient struct {
onMessage func(Message)
onPromptDone func(hermesPromptResult)
+ // pendingTools buffers the args for tool calls whose input streams in
+ // across multiple ACP tool_call_update messages (kimi does this —
+ // tokens from the LLM arrive one at a time, and each update carries
+ // the cumulative args JSON so far). We defer emitting MessageToolUse
+ // until we either see status=completed/failed or have a full arg set,
+ // so the UI never sees a half-written command like `{"comma`.
+ toolMu sync.Mutex
+ pendingTools map[string]*pendingToolCall
+
usageMu sync.Mutex
usage TokenUsage
}
+// pendingToolCall buffers state for a tool call while its arguments
+// are streaming in. One entry per ACP toolCallId.
+type pendingToolCall struct {
+ toolName string // already mapped via hermesToolNameFromTitle
+ input map[string]any // from rawInput when the agent sends it up front (hermes)
+ argsText string // accumulated `content[].text` args (kimi, cumulative)
+ emitted bool // whether we've already sent MessageToolUse
+}
+
+// writeLine serialises concurrent JSON-RPC writes so request() (main
+// goroutine) and handleAgentRequest() (reader goroutine) don't
+// interleave frames. The pipe itself is atomic for small writes, but
+// we also want deterministic ordering under contention.
+func (c *hermesClient) writeLine(data []byte) error {
+ c.writeMu.Lock()
+ defer c.writeMu.Unlock()
+ _, err := c.stdin.Write(data)
+ return err
+}
+
func (c *hermesClient) request(ctx context.Context, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
id := c.nextID
@@ -369,7 +401,7 @@ func (c *hermesClient) request(ctx context.Context, method string, params any) (
return nil, err
}
data = append(data, '\n')
- if _, err := c.stdin.Write(data); err != nil {
+ if err := c.writeLine(data); err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
@@ -402,7 +434,11 @@ func (c *hermesClient) handleLine(line string) {
return
}
- // Check if it's a response to our request (has id + result or error).
+ // Agent → client request: has id + method (no result / error yet).
+ // Kimi uses this for session/request_permission; if we don't answer,
+ // the agent blocks for 300s and the task hangs. Hermes doesn't send
+ // these when launched with HERMES_YOLO_MODE=1, but we still handle
+ // the case generically for any future ACP backend we bolt on.
if _, hasID := raw["id"]; hasID {
if _, hasResult := raw["result"]; hasResult {
c.handleResponse(raw)
@@ -412,6 +448,10 @@ func (c *hermesClient) handleLine(line string) {
c.handleResponse(raw)
return
}
+ if _, hasMethod := raw["method"]; hasMethod {
+ c.handleAgentRequest(raw)
+ return
+ }
}
// Notification (no id, has method) — session updates from Hermes.
@@ -420,6 +460,62 @@ func (c *hermesClient) handleLine(line string) {
}
}
+// handleAgentRequest replies to JSON-RPC requests the agent sends
+// us (agent → client direction). The only one we care about today is
+// `session/request_permission`: the daemon is headless and cannot
+// actually prompt a user, so we auto-approve every action. Using
+// `approve_for_session` rather than `approve` means subsequent
+// identical actions (every Shell invocation, every file write) don't
+// round-trip through us — the agent remembers them locally.
+func (c *hermesClient) handleAgentRequest(raw map[string]json.RawMessage) {
+ var method string
+ _ = json.Unmarshal(raw["method"], &method)
+
+ rawID, ok := raw["id"]
+ if !ok {
+ return
+ }
+
+ var resp map[string]any
+ switch method {
+ case "session/request_permission":
+ resp = map[string]any{
+ "jsonrpc": "2.0",
+ "id": json.RawMessage(rawID),
+ "result": map[string]any{
+ "outcome": map[string]any{
+ "outcome": "selected",
+ "optionId": "approve_for_session",
+ },
+ },
+ }
+ c.cfg.Logger.Debug("auto-approved agent permission request", "method", method)
+ default:
+ // Unknown agent→client method — reply with standard "method
+ // not found" so the agent doesn't block waiting for us. Better
+ // than silence: the agent can decide how to proceed.
+ resp = map[string]any{
+ "jsonrpc": "2.0",
+ "id": json.RawMessage(rawID),
+ "error": map[string]any{
+ "code": -32601,
+ "message": "method not found: " + method,
+ },
+ }
+ c.cfg.Logger.Debug("unhandled agent→client request", "method", method)
+ }
+
+ data, err := json.Marshal(resp)
+ if err != nil {
+ c.cfg.Logger.Warn("marshal agent-request response", "method", method, "error", err)
+ return
+ }
+ data = append(data, '\n')
+ if err := c.writeLine(data); err != nil {
+ c.cfg.Logger.Warn("write agent-request response", "method", method, "error", err)
+ }
+}
+
func (c *hermesClient) handleResponse(raw map[string]json.RawMessage) {
var id int
if err := json.Unmarshal(raw["id"], &id); err != nil {
@@ -560,51 +656,283 @@ func (c *hermesClient) handleAgentThought(data json.RawMessage) {
func (c *hermesClient) handleToolCallStart(data json.RawMessage) {
var msg struct {
- ToolCallID string `json:"toolCallId"`
- Title string `json:"title"`
- Kind string `json:"kind"`
- RawInput map[string]any `json:"rawInput"`
+ ToolCallID string `json:"toolCallId"`
+ Title string `json:"title"`
+ Kind string `json:"kind"`
+ RawInput map[string]any `json:"rawInput"`
+ Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
toolName := hermesToolNameFromTitle(msg.Title, msg.Kind)
- if c.onMessage != nil {
- c.onMessage(Message{
- Type: MessageToolUse,
- Tool: toolName,
- CallID: msg.ToolCallID,
- Input: msg.RawInput,
+
+ // Hermes pre-populates rawInput on the initial tool_call — emit
+ // MessageToolUse immediately so the UI can show the tool invocation
+ // live. Record the emission so handleToolCallUpdate doesn't re-emit
+ // on completion.
+ if msg.RawInput != nil {
+ c.trackTool(msg.ToolCallID, &pendingToolCall{
+ toolName: toolName,
+ input: msg.RawInput,
+ emitted: true,
})
+ if c.onMessage != nil {
+ c.onMessage(Message{
+ Type: MessageToolUse,
+ Tool: toolName,
+ CallID: msg.ToolCallID,
+ Input: msg.RawInput,
+ })
+ }
+ return
}
+
+ // Kimi streams args token-by-token across tool_call_update messages;
+ // the initial tool_call often carries an empty content block. Buffer
+ // the tool and defer MessageToolUse emission to avoid the UI seeing
+ // a command with `{""` as its input.
+ c.trackTool(msg.ToolCallID, &pendingToolCall{
+ toolName: toolName,
+ argsText: extractACPToolCallText(msg.Content),
+ emitted: false,
+ })
}
func (c *hermesClient) handleToolCallUpdate(data json.RawMessage) {
var msg struct {
- ToolCallID string `json:"toolCallId"`
- Status string `json:"status"`
- Kind string `json:"kind"`
- RawOutput string `json:"rawOutput"`
+ ToolCallID string `json:"toolCallId"`
+ Status string `json:"status"`
+ Title string `json:"title"`
+ Kind string `json:"kind"`
+ RawInput map[string]any `json:"rawInput"`
+ RawOutput string `json:"rawOutput"`
+ Content []json.RawMessage `json:"content"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
- // Only emit tool result when the call is completed.
+ // Mid-stream: only buffer updates. Kimi emits many of these per
+ // tool call, each carrying the cumulative args JSON so far.
if msg.Status != "completed" && msg.Status != "failed" {
+ if pending := c.getPendingTool(msg.ToolCallID); pending != nil && !pending.emitted {
+ if text := extractACPToolCallText(msg.Content); text != "" {
+ // kimi streams the full cumulative args on every frame;
+ // overwrite rather than concatenate.
+ pending.argsText = text
+ }
+ }
return
}
+ // Completion: emit any deferred MessageToolUse first, then the result.
+ pending := c.takePendingTool(msg.ToolCallID)
+ c.emitDeferredToolUse(pending, msg.ToolCallID, msg.Title, msg.Kind, msg.RawInput)
+
+ output := msg.RawOutput
+ if output == "" {
+ output = extractACPToolCallText(msg.Content)
+ }
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolResult,
CallID: msg.ToolCallID,
- Output: msg.RawOutput,
+ Output: output,
})
}
}
+// trackTool stores pending-tool state for a given callID. Lazy-inits
+// the map so zero-value hermesClient values (common in tests) don't
+// panic on the first tool call.
+func (c *hermesClient) trackTool(callID string, p *pendingToolCall) {
+ c.toolMu.Lock()
+ defer c.toolMu.Unlock()
+ if c.pendingTools == nil {
+ c.pendingTools = make(map[string]*pendingToolCall)
+ }
+ c.pendingTools[callID] = p
+}
+
+// getPendingTool returns the pending entry (may be nil) without
+// removing it. Safe to call on a zero-value hermesClient.
+func (c *hermesClient) getPendingTool(callID string) *pendingToolCall {
+ c.toolMu.Lock()
+ defer c.toolMu.Unlock()
+ if c.pendingTools == nil {
+ return nil
+ }
+ return c.pendingTools[callID]
+}
+
+// takePendingTool removes and returns the pending entry, or nil if
+// none was tracked (e.g. the tool completed before we saw its start,
+// or we missed the start frame).
+func (c *hermesClient) takePendingTool(callID string) *pendingToolCall {
+ c.toolMu.Lock()
+ defer c.toolMu.Unlock()
+ if c.pendingTools == nil {
+ return nil
+ }
+ p := c.pendingTools[callID]
+ delete(c.pendingTools, callID)
+ return p
+}
+
+// emitDeferredToolUse emits a buffered MessageToolUse right before the
+// matching MessageToolResult. Handles three cases:
+// - hermes tool: already emitted on tool_call → skip
+// - kimi tool with streamed args → parse accumulated JSON as Input
+// - unknown tool (completed arrived without a start frame) →
+// synthesize minimal info from the update's own fields
+func (c *hermesClient) emitDeferredToolUse(
+ p *pendingToolCall,
+ callID, updateTitle, updateKind string,
+ updateRawInput map[string]any,
+) {
+ if p != nil && p.emitted {
+ return
+ }
+
+ var toolName string
+ var input map[string]any
+
+ switch {
+ case p != nil && p.input != nil:
+ // Pre-buffered rawInput path — shouldn't happen because we set
+ // emitted=true in that case, but handle defensively.
+ toolName = p.toolName
+ input = p.input
+ case p != nil:
+ toolName = p.toolName
+ input = parseToolArgsJSON(p.argsText)
+ default:
+ // No record of the start frame — fall back to the update's own
+ // title/kind/rawInput so the UI at least sees the tool name.
+ toolName = hermesToolNameFromTitle(updateTitle, updateKind)
+ input = updateRawInput
+ }
+
+ if c.onMessage == nil {
+ return
+ }
+ c.onMessage(Message{
+ Type: MessageToolUse,
+ Tool: toolName,
+ CallID: callID,
+ Input: input,
+ })
+}
+
+// parseToolArgsJSON turns kimi's accumulated args string into the
+// structured map the UI expects under Message.Input. Kimi sends args
+// as a JSON-encoded object (`{"command":"echo hi"}`), so a full JSON
+// parse recovers the original tool-arg shape. On malformed input
+// (streaming glitch, non-JSON tool) we preserve the raw text under a
+// `text` key so the UI still has something to render.
+func parseToolArgsJSON(argsText string) map[string]any {
+ argsText = strings.TrimSpace(argsText)
+ if argsText == "" {
+ return nil
+ }
+ var m map[string]any
+ if err := json.Unmarshal([]byte(argsText), &m); err == nil {
+ return m
+ }
+ return map[string]any{"text": argsText}
+}
+
+// extractACPToolCallText concatenates the rendered text of every ACP
+// block in a tool_call / tool_call_update's `content` array.
+//
+// Handles the two block types kimi emits:
+// - {type:"content", content:{type:"text", text:"..."}} — plain text
+// (shell output, tool args). Text is concatenated verbatim.
+// - {type:"diff", path, oldText, newText} — FileEdit output. Rendered
+// as a minimal unified-diff header so the UI distinguishes writes
+// from reads without needing a diff viewer.
+//
+// Terminal blocks ({type:"terminal", terminalId}) reference a remote
+// terminal the client would normally subscribe to via terminal/output;
+// we don't advertise terminal capability so we never receive those in
+// practice, but if one slips through we skip it (nothing useful to
+// surface from a bare ID).
+func extractACPToolCallText(blocks []json.RawMessage) string {
+ var b strings.Builder
+ appendPiece := func(piece string) {
+ if piece == "" {
+ return
+ }
+ if b.Len() > 0 {
+ b.WriteByte('\n')
+ }
+ b.WriteString(piece)
+ }
+ for _, raw := range blocks {
+ var kind struct {
+ Type string `json:"type"`
+ }
+ if err := json.Unmarshal(raw, &kind); err != nil {
+ continue
+ }
+ switch kind.Type {
+ case "content":
+ var outer struct {
+ Content json.RawMessage `json:"content"`
+ }
+ if err := json.Unmarshal(raw, &outer); err != nil || len(outer.Content) == 0 {
+ continue
+ }
+ var inner struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ }
+ if err := json.Unmarshal(outer.Content, &inner); err != nil {
+ continue
+ }
+ if inner.Type != "text" {
+ continue
+ }
+ appendPiece(inner.Text)
+ case "diff":
+ var diff struct {
+ Path string `json:"path"`
+ OldText string `json:"oldText"`
+ NewText string `json:"newText"`
+ }
+ if err := json.Unmarshal(raw, &diff); err != nil || diff.Path == "" {
+ continue
+ }
+ // Keep it tiny — a full unified diff can be huge and we're
+ // really just recording "this tool wrote to this file".
+ // The UI can re-read the file if it needs the actual content.
+ var piece strings.Builder
+ piece.WriteString("--- ")
+ piece.WriteString(diff.Path)
+ piece.WriteString("\n+++ ")
+ piece.WriteString(diff.Path)
+ if diff.OldText == "" {
+ piece.WriteString("\n(new file, ")
+ piece.WriteString(strconv.Itoa(len(diff.NewText)))
+ piece.WriteString(" bytes)")
+ } else {
+ piece.WriteString("\n(edited: ")
+ piece.WriteString(strconv.Itoa(len(diff.OldText)))
+ piece.WriteString(" → ")
+ piece.WriteString(strconv.Itoa(len(diff.NewText)))
+ piece.WriteString(" bytes)")
+ }
+ appendPiece(piece.String())
+ default:
+ // terminal blocks, image blocks, unknown future types —
+ // ignore. We have no way to inline-render them.
+ }
+ }
+ return b.String()
+}
+
func (c *hermesClient) handleUsageUpdate(data json.RawMessage) {
var msg struct {
Usage struct {
@@ -634,7 +962,10 @@ func (c *hermesClient) handleUsageUpdate(data json.RawMessage) {
// ── Helpers ──
-func extractHermesSessionID(result json.RawMessage) string {
+// extractACPSessionID pulls `sessionId` out of a session/new or
+// session/resume response. Shared by all ACP backends (hermes, kimi,
+// and anything else that follows the standard ACP schema).
+func extractACPSessionID(result json.RawMessage) string {
var r struct {
SessionID string `json:"sessionId"`
}
@@ -697,46 +1028,64 @@ func hermesToolNameFromTitle(title string, kind string) string {
case "think":
return "thinking"
default:
+ // Preserve a non-empty title when we can't classify it: kimi
+ // emits bare titles like "Shell" or "Read file" without any
+ // `kind`, so returning an empty string here drops the tool
+ // name entirely before kimiToolNameFromTitle can map it.
+ // Hermes titles always carry a colon, so hermes never reaches
+ // this branch with a non-empty title.
+ if title != "" {
+ return title
+ }
return kind
}
}
// ── Provider-error sniffing ──
//
-// hermes' session/prompt RPC reports stopReason=end_turn even when
-// the underlying HTTP call to the configured LLM endpoint returned
-// an error — the actionable detail only appears on stderr (e.g.
+// ACP agents (hermes, kimi, …) all have the same failure mode:
+// session/prompt reports stopReason=end_turn even when the underlying
+// HTTP call to the configured LLM endpoint returned an error — the
+// actionable detail only appears on stderr (e.g.
// `⚠️ API call failed (attempt 1/3): BadRequestError [HTTP 400]` and
// `Error: HTTP 400: Error code: 400 - {'detail': "The '...' model
// is not supported when using Codex with a ChatGPT account."}`).
-// We scan for those patterns so the daemon can surface a real
-// failure instead of a generic "empty output".
-type hermesProviderErrorSniffer struct {
- mu sync.Mutex
- remains []byte // buffer for a partial trailing line across writes
- lines []string // captured error lines, bounded
- seen map[string]bool
+// The sniffer scans for those patterns so the daemon can surface a
+// real failure instead of a generic "empty output".
+//
+// Parameterised by provider name so both hermes and kimi can share
+// the transport: the regexes match format-level signals (HTTP status,
+// error-kind tags, "API call failed" banner) that both runtimes emit.
+type acpProviderErrorSniffer struct {
+ provider string
+ mu sync.Mutex
+ remains []byte // buffer for a partial trailing line across writes
+ lines []string // captured error lines, bounded
+ seen map[string]bool
}
-// hermesErrorHeaderRe matches the first line of an API-error block.
-// Hermes prefixes these with ⚠️ / ❌ and includes an HTTP status
-// code or a non-retryable-error tag.
-var hermesErrorHeaderRe = regexp.MustCompile(`(?:⚠️|❌|\[ERROR\]).*(?:BadRequestError|AuthenticationError|RateLimitError|HTTP [0-9]{3}|Non-retryable|API call failed)`)
+// acpErrorHeaderRe matches the first line of an API-error block.
+// ACP agents typically prefix these with ⚠️ / ❌ and include an HTTP
+// status code or a non-retryable-error tag.
+var acpErrorHeaderRe = regexp.MustCompile(`(?:⚠️|❌|\[ERROR\]).*(?:BadRequestError|AuthenticationError|RateLimitError|HTTP [0-9]{3}|Non-retryable|API call failed)`)
-// hermesErrorDetailRe pulls the most useful single-line messages
-// out of the subsequent lines of the error block (the one whose
-// "Error:" or "Details:" tag actually spells out what happened).
-var hermesErrorDetailRe = regexp.MustCompile(`(?:Error:|detail:|Details:)\s*(.+)`)
+// acpErrorDetailRe pulls the most useful single-line messages out of
+// the subsequent lines of the error block (the one whose "Error:" or
+// "Details:" tag actually spells out what happened).
+var acpErrorDetailRe = regexp.MustCompile(`(?:Error:|detail:|Details:)\s*(.+)`)
-const hermesMaxErrorLines = 8
+const acpMaxErrorLines = 8
-func newHermesProviderErrorSniffer() *hermesProviderErrorSniffer {
- return &hermesProviderErrorSniffer{seen: map[string]bool{}}
+// newACPProviderErrorSniffer returns a sniffer that tags its messages
+// with the given provider name (e.g. "hermes", "kimi") so failure
+// strings make it obvious which runtime produced the error.
+func newACPProviderErrorSniffer(provider string) *acpProviderErrorSniffer {
+ return &acpProviderErrorSniffer{provider: provider, seen: map[string]bool{}}
}
// Write implements io.Writer so the sniffer can sit behind an
// io.MultiWriter next to the normal stderr log forwarder.
-func (s *hermesProviderErrorSniffer) Write(p []byte) (int, error) {
+func (s *acpProviderErrorSniffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -757,7 +1106,7 @@ func (s *hermesProviderErrorSniffer) Write(p []byte) (int, error) {
if line == "" {
continue
}
- if !(hermesErrorHeaderRe.MatchString(line) || hermesErrorDetailRe.MatchString(line)) {
+ if !(acpErrorHeaderRe.MatchString(line) || acpErrorDetailRe.MatchString(line)) {
continue
}
if s.seen[line] {
@@ -765,8 +1114,8 @@ func (s *hermesProviderErrorSniffer) Write(p []byte) (int, error) {
}
s.seen[line] = true
s.lines = append(s.lines, line)
- if len(s.lines) > hermesMaxErrorLines {
- s.lines = s.lines[len(s.lines)-hermesMaxErrorLines:]
+ if len(s.lines) > acpMaxErrorLines {
+ s.lines = s.lines[len(s.lines)-acpMaxErrorLines:]
}
}
return len(p), nil
@@ -776,21 +1125,22 @@ func (s *hermesProviderErrorSniffer) Write(p []byte) (int, error) {
// error field. Prefers the most specific "Error:" / "detail:"
// fragment; falls back to the first captured header line; empty
// when nothing useful was seen.
-func (s *hermesProviderErrorSniffer) message() string {
+func (s *acpProviderErrorSniffer) message() string {
s.mu.Lock()
defer s.mu.Unlock()
+ prefix := s.provider + " provider error: "
for _, line := range s.lines {
- if m := hermesErrorDetailRe.FindStringSubmatch(line); m != nil {
+ if m := acpErrorDetailRe.FindStringSubmatch(line); m != nil {
detail := strings.TrimSpace(m[1])
if detail != "" {
- return "hermes provider error: " + detail
+ return prefix + detail
}
}
}
for _, line := range s.lines {
- if hermesErrorHeaderRe.MatchString(line) {
- return "hermes provider error: " + line
+ if acpErrorHeaderRe.MatchString(line) {
+ return prefix + line
}
}
return ""
diff --git a/server/pkg/agent/hermes_test.go b/server/pkg/agent/hermes_test.go
index 86c5344e4..dc837e4dd 100644
--- a/server/pkg/agent/hermes_test.go
+++ b/server/pkg/agent/hermes_test.go
@@ -2,7 +2,9 @@ package agent
import (
"encoding/json"
+ "log/slog"
"strings"
+ "sync"
"testing"
)
@@ -17,30 +19,30 @@ func TestNewReturnsHermesBackend(t *testing.T) {
}
}
-// ── extractHermesSessionID ──
+// ── extractACPSessionID ──
-func TestExtractHermesSessionID(t *testing.T) {
+func TestExtractACPSessionID(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`{"sessionId":"20260410_141145_47260c"}`)
- got := extractHermesSessionID(raw)
+ got := extractACPSessionID(raw)
if got != "20260410_141145_47260c" {
t.Errorf("got %q, want %q", got, "20260410_141145_47260c")
}
}
-func TestExtractHermesSessionIDEmpty(t *testing.T) {
+func TestExtractACPSessionIDEmpty(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`{}`)
- got := extractHermesSessionID(raw)
+ got := extractACPSessionID(raw)
if got != "" {
t.Errorf("got %q, want empty", got)
}
}
-func TestExtractHermesSessionIDInvalidJSON(t *testing.T) {
+func TestExtractACPSessionIDInvalidJSON(t *testing.T) {
t.Parallel()
raw := json.RawMessage(`not json`)
- got := extractHermesSessionID(raw)
+ got := extractACPSessionID(raw)
if got != "" {
t.Errorf("got %q, want empty", got)
}
@@ -65,14 +67,24 @@ func TestHermesToolNameFromTitle(t *testing.T) {
{"delegate: fix the bug", "execute", "delegate_task"},
{"analyze image: what is this?", "read", "vision_analyze"},
{"execute code", "execute", "execute_code"},
- // Fallback to kind when no colon in title.
+ // Fallback to kind when no colon in title but kind is known.
{"unknownTool", "read", "read_file"},
{"unknownTool", "edit", "write_file"},
{"unknownTool", "execute", "terminal"},
{"unknownTool", "search", "search_files"},
{"unknownTool", "fetch", "web_search"},
{"unknownTool", "think", "thinking"},
- {"unknownTool", "other", "other"},
+ // Bare title (no colon, no known kind) — preserve the title
+ // itself rather than falling back to an unclassified kind.
+ // Matters for kimi: its ACP `tool_call` updates emit a bare
+ // `title: "Shell"` with no `kind`, and we need downstream
+ // normalisation (kimiToolNameFromTitle) to see "Shell" rather
+ // than an empty string.
+ {"Shell", "", "Shell"},
+ {"Read file", "", "Read file"},
+ {"unknownTool", "other", "unknownTool"},
+ // Empty title falls back to kind, even when kind isn't known.
+ {"", "other", "other"},
// Tool with colon but not in known map.
{"custom_tool: args", "other", "custom_tool"},
}
@@ -101,7 +113,7 @@ func TestHermesClientHandleLineResponse(t *testing.T) {
if res.err != nil {
t.Fatalf("unexpected error: %v", res.err)
}
- sid := extractHermesSessionID(res.result)
+ sid := extractACPSessionID(res.result)
if sid != "ses_abc" {
t.Errorf("sessionId: got %q, want %q", sid, "ses_abc")
}
@@ -127,6 +139,111 @@ func TestHermesClientHandleLineError(t *testing.T) {
}
}
+// ── agent → client request handling ──
+
+// bufferWriter is a test stand-in for cmd.StdinPipe that captures
+// writes in-memory so we can assert what handleAgentRequest emitted.
+type bufferWriter struct {
+ mu sync.Mutex
+ buf strings.Builder
+}
+
+func (b *bufferWriter) Write(p []byte) (int, error) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.WriteString(string(p))
+}
+
+func (b *bufferWriter) String() string {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ return b.buf.String()
+}
+
+// TestHermesClientAutoApprovesPermissionRequest asserts that when an
+// ACP agent sends us `session/request_permission` (kimi does this on
+// every Shell / file-mutating tool call), the client replies with
+// `approve_for_session` — without this the agent blocks 300s and the
+// task hangs. The id in the reply must match the agent's request id
+// so its in-flight future resolves.
+func TestHermesClientAutoApprovesPermissionRequest(t *testing.T) {
+ t.Parallel()
+
+ w := &bufferWriter{}
+ c := &hermesClient{
+ cfg: Config{Logger: slog.Default()},
+ stdin: w,
+ pending: make(map[int]*pendingRPC),
+ }
+
+ c.handleLine(`{"jsonrpc":"2.0","id":42,"method":"session/request_permission","params":{"sessionId":"ses_1","options":[{"optionId":"approve","name":"Approve once","kind":"allow_once"},{"optionId":"approve_for_session","name":"Approve for this session","kind":"allow_always"},{"optionId":"reject","name":"Reject","kind":"reject_once"}],"toolCall":{"toolCallId":"tc_1","title":"Shell","content":[]}}}`)
+
+ got := w.String()
+ var resp struct {
+ JSONRPC string `json:"jsonrpc"`
+ ID int `json:"id"`
+ Result struct {
+ Outcome struct {
+ Outcome string `json:"outcome"`
+ OptionID string `json:"optionId"`
+ } `json:"outcome"`
+ } `json:"result"`
+ }
+ if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &resp); err != nil {
+ t.Fatalf("reply is not valid JSON: %q err=%v", got, err)
+ }
+ if resp.JSONRPC != "2.0" {
+ t.Errorf("jsonrpc: got %q, want 2.0", resp.JSONRPC)
+ }
+ if resp.ID != 42 {
+ t.Errorf("id: got %d, want 42 (must echo agent's request id)", resp.ID)
+ }
+ if resp.Result.Outcome.Outcome != "selected" {
+ t.Errorf("outcome.outcome: got %q, want %q", resp.Result.Outcome.Outcome, "selected")
+ }
+ if resp.Result.Outcome.OptionID != "approve_for_session" {
+ t.Errorf("outcome.optionId: got %q, want %q", resp.Result.Outcome.OptionID, "approve_for_session")
+ }
+}
+
+// TestHermesClientReplesMethodNotFoundForUnknownAgentRequest ensures
+// that any agent → client request we don't explicitly handle gets a
+// proper JSON-RPC error back, not silence. Silence would block the
+// agent for however long its internal timeout is, same as the
+// session/request_permission hang this change fixes.
+func TestHermesClientReplesMethodNotFoundForUnknownAgentRequest(t *testing.T) {
+ t.Parallel()
+
+ w := &bufferWriter{}
+ c := &hermesClient{
+ cfg: Config{Logger: slog.Default()},
+ stdin: w,
+ pending: make(map[int]*pendingRPC),
+ }
+ c.handleLine(`{"jsonrpc":"2.0","id":7,"method":"fs/read_text_file","params":{"path":"/tmp/x"}}`)
+
+ got := w.String()
+ var resp struct {
+ ID int `json:"id"`
+ Error struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal([]byte(strings.TrimSpace(got)), &resp); err != nil {
+ t.Fatalf("reply not valid JSON: %q err=%v", got, err)
+ }
+ if resp.ID != 7 {
+ t.Errorf("id echo: got %d, want 7", resp.ID)
+ }
+ if resp.Error.Code != -32601 {
+ t.Errorf("error code: got %d, want -32601 (method not found)", resp.Error.Code)
+ }
+ if !strings.Contains(resp.Error.Message, "fs/read_text_file") {
+ t.Errorf("error message should name the unhandled method, got %q", resp.Error.Message)
+ }
+}
+
// ── session/update notification handling ──
func TestHermesClientHandleAgentMessage(t *testing.T) {
@@ -226,6 +343,211 @@ func TestHermesClientHandleToolCallComplete(t *testing.T) {
}
}
+// TestHermesClientKimiStreamingToolCall walks the real kimi frame
+// sequence for a single Shell call:
+// 1. tool_call with empty content (LLM hasn't started emitting args yet)
+// 2. tool_call_update status=in_progress carrying the cumulative args
+// JSON character-by-character ("{", "{\"command", …)
+// 3. tool_call_update status=completed carrying the command's stdout
+//
+// The client must defer MessageToolUse until we have the full args so
+// the UI doesn't show a command like `{"comma` — and the MessageToolUse
+// must carry the parsed args as the Input map (`{"command": "echo hi"}`
+// → Input["command"] = "echo hi") rather than a raw string.
+func TestHermesClientKimiStreamingToolCall(t *testing.T) {
+ t.Parallel()
+
+ var got []Message
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onMessage: func(msg Message) {
+ got = append(got, msg)
+ },
+ }
+
+ // 1. tool_call: empty content (classic kimi start frame).
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call","toolCallId":"tc-kimi-1","title":"Shell","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":""}}]}}}`)
+ if len(got) != 0 {
+ t.Fatalf("expected nothing emitted yet (args empty), got %+v", got)
+ }
+
+ // 2. Streaming updates — cumulative args JSON.
+ partials := []string{
+ `{"`,
+ `{"command`,
+ `{"command":`,
+ `{"command":"echo `,
+ `{"command":"echo hi"}`,
+ }
+ for _, args := range partials {
+ // JSON-encode args so embedded quotes are escaped properly.
+ argsJSON, _ := json.Marshal(args)
+ line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-kimi-1","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":` + string(argsJSON) + `}}]}}}`
+ c.handleLine(line)
+ }
+ if len(got) != 0 {
+ t.Fatalf("expected nothing emitted mid-stream, got %+v", got)
+ }
+
+ // 3. Completed — stdout.
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc-kimi-1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"hi\n"}}]}}}`)
+
+ if len(got) != 2 {
+ t.Fatalf("expected [MessageToolUse, MessageToolResult], got %d: %+v", len(got), got)
+ }
+ if got[0].Type != MessageToolUse {
+ t.Errorf("first message: got %v, want MessageToolUse", got[0].Type)
+ }
+ if got[0].CallID != "tc-kimi-1" {
+ t.Errorf("first.callID: got %q", got[0].CallID)
+ }
+ if cmd, _ := got[0].Input["command"].(string); cmd != "echo hi" {
+ t.Errorf("first.Input.command: got %v, want %q", got[0].Input["command"], "echo hi")
+ }
+ if got[1].Type != MessageToolResult {
+ t.Errorf("second message: got %v, want MessageToolResult", got[1].Type)
+ }
+ if got[1].Output != "hi\n" {
+ t.Errorf("second.output: got %q, want %q", got[1].Output, "hi\n")
+ }
+}
+
+// TestHermesClientKimiMalformedArgsFallback: if the accumulated args
+// aren't valid JSON (streaming glitch, tool with non-JSON args), we
+// still surface the text under Input.text rather than silently
+// dropping it.
+func TestHermesClientKimiMalformedArgsFallback(t *testing.T) {
+ t.Parallel()
+
+ var got []Message
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onMessage: func(msg Message) {
+ got = append(got, msg)
+ },
+ }
+
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call","toolCallId":"tc","title":"Shell","status":"in_progress","content":[{"type":"content","content":{"type":"text","text":"not-json"}}]}}}`)
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc","status":"completed","content":[{"type":"content","content":{"type":"text","text":"output"}}]}}}`)
+
+ if len(got) < 1 {
+ t.Fatalf("expected ToolUse+ToolResult, got %+v", got)
+ }
+ if text, _ := got[0].Input["text"].(string); text != "not-json" {
+ t.Errorf("fallback Input.text: got %v", got[0].Input["text"])
+ }
+}
+
+// TestHermesClientHandleToolCallCompleteOrphan: if a completion frame
+// arrives without a preceding tool_call (out-of-order / missed frame),
+// still emit ToolUse synthesised from the update's own title/rawInput
+// before ToolResult. Keeps the UI from showing a bare result with no
+// header.
+func TestHermesClientHandleToolCallCompleteOrphan(t *testing.T) {
+ t.Parallel()
+
+ var got []Message
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onMessage: func(msg Message) {
+ got = append(got, msg)
+ },
+ }
+
+ c.handleLine(`{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc","status":"completed","title":"terminal: ls","kind":"execute","rawInput":{"command":"ls"},"content":[{"type":"content","content":{"type":"text","text":"file.go\n"}}]}}}`)
+
+ if len(got) != 2 || got[0].Type != MessageToolUse || got[1].Type != MessageToolResult {
+ t.Fatalf("expected [ToolUse, ToolResult], got %+v", got)
+ }
+ if got[0].Tool != "terminal" {
+ t.Errorf("orphan ToolUse tool: got %q", got[0].Tool)
+ }
+ if cmd, _ := got[0].Input["command"].(string); cmd != "ls" {
+ t.Errorf("orphan ToolUse input.command: got %v", got[0].Input["command"])
+ }
+ if got[1].Output != "file.go\n" {
+ t.Errorf("ToolResult output: got %q", got[1].Output)
+ }
+}
+
+// TestHermesClientHandleToolCallRawOutputTakesPrecedence keeps hermes
+// behaviour unchanged: when the update has both `rawOutput` (hermes
+// convention) and `content` (would be ambiguous), honour rawOutput.
+func TestHermesClientHandleToolCallRawOutputTakesPrecedence(t *testing.T) {
+ t.Parallel()
+
+ var got Message
+ c := &hermesClient{
+ pending: make(map[int]*pendingRPC),
+ onMessage: func(msg Message) {
+ got = msg
+ },
+ }
+
+ line := `{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"ses_1","update":{"sessionUpdate":"tool_call_update","toolCallId":"tc","status":"completed","rawOutput":"raw wins","content":[{"type":"content","content":{"type":"text","text":"ignored"}}]}}}`
+ c.handleLine(line)
+
+ if got.Output != "raw wins" {
+ t.Errorf("output: got %q, want %q", got.Output, "raw wins")
+ }
+}
+
+func TestExtractACPToolCallText(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ json string
+ want string
+ }{
+ {
+ name: "single text block",
+ json: `[{"type":"content","content":{"type":"text","text":"hello"}}]`,
+ want: "hello",
+ },
+ {
+ name: "multiple text blocks join with newline",
+ json: `[{"type":"content","content":{"type":"text","text":"a"}},{"type":"content","content":{"type":"text","text":"b"}}]`,
+ want: "a\nb",
+ },
+ {
+ name: "terminal blocks skipped",
+ json: `[{"type":"terminal","terminalId":"t1"},{"type":"content","content":{"type":"text","text":"shell out"}}]`,
+ want: "shell out",
+ },
+ {
+ name: "diff block renders as mini header",
+ json: `[{"type":"diff","path":"foo.go","oldText":"abc","newText":"abcdef"}]`,
+ want: "--- foo.go\n+++ foo.go\n(edited: 3 → 6 bytes)",
+ },
+ {
+ name: "new-file diff (no oldText)",
+ json: `[{"type":"diff","path":"new.go","oldText":"","newText":"hi"}]`,
+ want: "--- new.go\n+++ new.go\n(new file, 2 bytes)",
+ },
+ {
+ name: "empty array returns empty",
+ json: `[]`,
+ want: "",
+ },
+ {
+ name: "no text content",
+ json: `[{"type":"terminal","terminalId":"t1"}]`,
+ want: "",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var blocks []json.RawMessage
+ if err := json.Unmarshal([]byte(tt.json), &blocks); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ if got := extractACPToolCallText(blocks); got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
func TestHermesClientHandleToolCallInProgressIgnored(t *testing.T) {
t.Parallel()
@@ -384,7 +706,7 @@ func TestHermesProviderErrorSniffer(t *testing.T) {
// LLM endpoint rejects the requested model. We verify the
// sniffer extracts the `Error: ...` line so the task error
// tells the user *why* it failed.
- s := newHermesProviderErrorSniffer()
+ s := newACPProviderErrorSniffer("hermes")
lines := []string{
"2026-04-20 23:41:47 [INFO] acp_adapter.server: Prompt on session abc",
`⚠️ API call failed (attempt 1/3): BadRequestError [HTTP 400]`,
@@ -409,7 +731,7 @@ func TestHermesProviderErrorSniffer(t *testing.T) {
func TestHermesProviderErrorSnifferIgnoresInfoLines(t *testing.T) {
t.Parallel()
- s := newHermesProviderErrorSniffer()
+ s := newACPProviderErrorSniffer("hermes")
s.Write([]byte("2026-04-20 23:41:45 [INFO] acp_adapter.entry: Loaded env\n"))
s.Write([]byte("2026-04-20 23:41:47 [INFO] agent.auxiliary_client: Vision auto-detect...\n"))
if msg := s.message(); msg != "" {
@@ -422,7 +744,7 @@ func TestHermesProviderErrorSnifferHandlesPartialLines(t *testing.T) {
// Writer may be called mid-line; the sniffer must buffer until
// it sees a newline so the regex doesn't miss the header.
- s := newHermesProviderErrorSniffer()
+ s := newACPProviderErrorSniffer("hermes")
s.Write([]byte(`⚠️ API call failed (attempt 1/3):`))
s.Write([]byte(` BadRequestError [HTTP 400]` + "\n"))
s.Write([]byte(` 📝 Error: something went wrong` + "\n"))
@@ -435,12 +757,12 @@ func TestHermesProviderErrorSnifferHandlesPartialLines(t *testing.T) {
func TestHermesProviderErrorSnifferBoundedBuffer(t *testing.T) {
t.Parallel()
- s := newHermesProviderErrorSniffer()
+ s := newACPProviderErrorSniffer("hermes")
for i := 0; i < 20; i++ {
// Each line differs so dedup doesn't merge them.
s.Write([]byte(`⚠️ API call failed (HTTP 400) attempt ` + string(rune('a'+i%26)) + `: Non-retryable error` + "\n"))
}
- if len(s.lines) > hermesMaxErrorLines {
- t.Errorf("sniffer kept %d lines, limit is %d", len(s.lines), hermesMaxErrorLines)
+ if len(s.lines) > acpMaxErrorLines {
+ t.Errorf("sniffer kept %d lines, limit is %d", len(s.lines), acpMaxErrorLines)
}
}
diff --git a/server/pkg/agent/kimi.go b/server/pkg/agent/kimi.go
new file mode 100644
index 000000000..800c40398
--- /dev/null
+++ b/server/pkg/agent/kimi.go
@@ -0,0 +1,382 @@
+package agent
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+)
+
+// kimiBlockedArgs are flags hardcoded by the daemon that must not be
+// overridden by user-configured custom_args. `acp` is the protocol
+// subcommand that drives the ACP JSON-RPC transport for Kimi Code CLI;
+// overriding it would break the daemon↔Kimi communication contract.
+var kimiBlockedArgs = map[string]blockedArgMode{
+ "acp": blockedStandalone,
+}
+
+// kimiBackend implements Backend by spawning `kimi acp` and communicating
+// via the ACP (Agent Client Protocol) JSON-RPC 2.0 over stdin/stdout.
+//
+// Kimi Code CLI (https://github.com/MoonshotAI/kimi-cli) supports ACP out of
+// the box via the `kimi acp` subcommand. We reuse the existing hermesClient
+// ACP transport since both runtimes speak the same protocol — only the
+// binary, env, and tool-name extraction differ.
+type kimiBackend struct {
+ cfg Config
+}
+
+func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
+ execPath := b.cfg.ExecutablePath
+ if execPath == "" {
+ execPath = "kimi"
+ }
+ if _, err := exec.LookPath(execPath); err != nil {
+ return nil, fmt.Errorf("kimi executable not found at %q: %w", execPath, err)
+ }
+
+ timeout := opts.Timeout
+ if timeout == 0 {
+ timeout = 20 * time.Minute
+ }
+ runCtx, cancel := context.WithTimeout(ctx, timeout)
+
+ // `kimi acp` ignores --yolo / --auto-approve (they're flags on the
+ // root `kimi` command, not on the `acp` subcommand). Instead, the
+ // daemon auto-approves in hermesClient.handleAgentRequest by replying
+ // "approve_for_session" to every session/request_permission request.
+ kimiArgs := append([]string{"acp"}, filterCustomArgs(opts.CustomArgs, kimiBlockedArgs, b.cfg.Logger)...)
+ cmd := exec.CommandContext(runCtx, execPath, kimiArgs...)
+ b.cfg.Logger.Debug("agent command", "exec", execPath, "args", kimiArgs)
+ if opts.Cwd != "" {
+ cmd.Dir = opts.Cwd
+ }
+ cmd.Env = buildEnv(b.cfg.Env)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("kimi stdout pipe: %w", err)
+ }
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ cancel()
+ return nil, fmt.Errorf("kimi stdin pipe: %w", err)
+ }
+ // Forward stderr to the daemon log *and* sniff provider-level
+ // errors out of it so we can surface them in the task result.
+ // Kimi's session/prompt still reports stopReason=end_turn when
+ // the underlying HTTP call to api.kimi.com returns 4xx/5xx, so
+ // without this the daemon reports a misleading "empty output"
+ // and the actionable error (expired token, rate limit, upstream
+ // 5xx, …) stays buried in the daemon log.
+ providerErr := newACPProviderErrorSniffer("kimi")
+ cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[kimi:stderr] "), providerErr)
+
+ if err := cmd.Start(); err != nil {
+ cancel()
+ return nil, fmt.Errorf("start kimi: %w", err)
+ }
+
+ b.cfg.Logger.Info("kimi acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
+
+ msgCh := make(chan Message, 256)
+ resCh := make(chan Result, 1)
+
+ var outputMu sync.Mutex
+ var output strings.Builder
+
+ promptDone := make(chan hermesPromptResult, 1)
+
+ // Reuse the hermesClient ACP transport — Kimi speaks the same protocol.
+ c := &hermesClient{
+ cfg: b.cfg,
+ stdin: stdin,
+ pending: make(map[int]*pendingRPC),
+ pendingTools: make(map[string]*pendingToolCall),
+ onMessage: func(msg Message) {
+ // hermesClient.handleToolCallStart has already mapped
+ // the raw ACP title via hermesToolNameFromTitle — which
+ // covers lowercase hermes-style titles ("read:", "patch
+ // (replace)", …) but not capitalised kimi-style ones
+ // ("Read file: …", "Run command: …"). Re-normalise so
+ // the UI sees consistent snake_case identifiers across
+ // both backends. No-op when the name is already normal
+ // form (e.g. already mapped to "read_file").
+ if msg.Type == MessageToolUse {
+ msg.Tool = kimiToolNameFromTitle(msg.Tool)
+ }
+ if msg.Type == MessageText {
+ outputMu.Lock()
+ output.WriteString(msg.Content)
+ outputMu.Unlock()
+ }
+ trySend(msgCh, msg)
+ },
+ onPromptDone: func(result hermesPromptResult) {
+ select {
+ case promptDone <- result:
+ default:
+ }
+ },
+ }
+
+ // Start reading stdout in background.
+ readerDone := make(chan struct{})
+ go func() {
+ defer close(readerDone)
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+ c.handleLine(line)
+ }
+ c.closeAllPending(fmt.Errorf("kimi process exited"))
+ }()
+
+ // Drive the ACP session lifecycle in a goroutine.
+ go func() {
+ defer cancel()
+ defer close(msgCh)
+ defer close(resCh)
+ defer func() {
+ stdin.Close()
+ _ = cmd.Wait()
+ }()
+
+ startTime := time.Now()
+ finalStatus := "completed"
+ var finalError string
+ var sessionID string
+
+ // 1. Initialize handshake.
+ _, err := c.request(runCtx, "initialize", map[string]any{
+ "protocolVersion": 1,
+ "clientInfo": map[string]any{
+ "name": "multica-agent-sdk",
+ "version": "0.2.0",
+ },
+ "clientCapabilities": map[string]any{},
+ })
+ if err != nil {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kimi initialize failed: %v", err)
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+
+ // 2. Create or resume a session.
+ cwd := opts.Cwd
+ if cwd == "" {
+ cwd = "."
+ }
+
+ if opts.ResumeSessionID != "" {
+ result, err := c.request(runCtx, "session/resume", map[string]any{
+ "cwd": cwd,
+ "sessionId": opts.ResumeSessionID,
+ })
+ if err != nil {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kimi session/resume failed: %v", err)
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+ sessionID = opts.ResumeSessionID
+ _ = result
+ } else {
+ result, err := c.request(runCtx, "session/new", map[string]any{
+ "cwd": cwd,
+ "mcpServers": []any{},
+ })
+ if err != nil {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kimi session/new failed: %v", err)
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+ sessionID = extractACPSessionID(result)
+ if sessionID == "" {
+ finalStatus = "failed"
+ finalError = "kimi session/new returned no session ID"
+ resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
+ return
+ }
+ }
+
+ c.sessionID = sessionID
+ b.cfg.Logger.Info("kimi session created", "session_id", sessionID)
+
+ // 3. If the caller picked a model (via agent.model from the
+ // UI dropdown), ask kimi to switch the session to it before
+ // we send any prompt. Kimi's ACP server exposes
+ // `session/set_model` and advertises available models via
+ // the `models.availableModels` block returned by
+ // `session/new` — we pass the chosen modelId through
+ // verbatim. This MUST fail the task on error: silently
+ // falling back to kimi's default model would let the user
+ // believe their pick was honoured while the task actually
+ // ran on something else.
+ if opts.Model != "" {
+ if _, err := c.request(runCtx, "session/set_model", map[string]any{
+ "sessionId": sessionID,
+ "modelId": opts.Model,
+ }); err != nil {
+ b.cfg.Logger.Warn("kimi set_session_model failed", "error", err, "requested_model", opts.Model)
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kimi could not switch to model %q: %v", opts.Model, err)
+ resCh <- Result{
+ Status: finalStatus,
+ Error: finalError,
+ DurationMs: time.Since(startTime).Milliseconds(),
+ SessionID: sessionID,
+ }
+ return
+ }
+ b.cfg.Logger.Info("kimi session model set", "model", opts.Model)
+ }
+
+ // 4. Build the prompt content. If we have a system prompt, prepend it.
+ userText := prompt
+ if opts.SystemPrompt != "" {
+ userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
+ }
+
+ // 5. Send the prompt and wait for PromptResponse.
+ _, err = c.request(runCtx, "session/prompt", map[string]any{
+ "sessionId": sessionID,
+ "prompt": []map[string]any{
+ {"type": "text", "text": userText},
+ },
+ })
+ if err != nil {
+ if runCtx.Err() == context.DeadlineExceeded {
+ finalStatus = "timeout"
+ finalError = fmt.Sprintf("kimi timed out after %s", timeout)
+ } else if runCtx.Err() == context.Canceled {
+ finalStatus = "aborted"
+ finalError = "execution cancelled"
+ } else {
+ finalStatus = "failed"
+ finalError = fmt.Sprintf("kimi session/prompt failed: %v", err)
+ }
+ } else {
+ select {
+ case pr := <-promptDone:
+ if pr.stopReason == "cancelled" {
+ finalStatus = "aborted"
+ finalError = "kimi cancelled the prompt"
+ }
+ c.usageMu.Lock()
+ c.usage.InputTokens += pr.usage.InputTokens
+ c.usage.OutputTokens += pr.usage.OutputTokens
+ c.usageMu.Unlock()
+ default:
+ }
+ }
+
+ duration := time.Since(startTime)
+ b.cfg.Logger.Info("kimi finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
+
+ stdin.Close()
+ cancel()
+
+ <-readerDone
+
+ outputMu.Lock()
+ finalOutput := output.String()
+ outputMu.Unlock()
+
+ // If kimi produced no visible output but we sniffed a
+ // provider-level error on stderr (typically HTTP 4xx from
+ // api.kimi.com — token expired, rate-limited, upstream
+ // 5xx, …), promote the status to failed and surface the
+ // real reason. Without this the daemon reports a cryptic
+ // "completed + empty output" and the actionable error
+ // stays buried in daemon logs.
+ if finalStatus == "completed" && finalOutput == "" {
+ if msg := providerErr.message(); msg != "" {
+ finalStatus = "failed"
+ finalError = msg
+ }
+ }
+
+ c.usageMu.Lock()
+ u := c.usage
+ c.usageMu.Unlock()
+
+ var usageMap map[string]TokenUsage
+ if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 {
+ model := opts.Model
+ if model == "" {
+ model = "unknown"
+ }
+ usageMap = map[string]TokenUsage{model: u}
+ }
+
+ resCh <- Result{
+ Status: finalStatus,
+ Output: finalOutput,
+ Error: finalError,
+ DurationMs: duration.Milliseconds(),
+ SessionID: sessionID,
+ Usage: usageMap,
+ }
+ }()
+
+ return &Session{Messages: msgCh, Result: resCh}, nil
+}
+
+// kimiToolNameFromTitle normalises tool names emitted by Kimi's ACP
+// server into the snake_case identifiers the Multica UI expects.
+//
+// Kimi follows the ACP spec where `title` is a short human-readable
+// label such as "Read file: /path/to/foo.go" or "Run command: ls".
+// hermesToolNameFromTitle upstream handles hermes' lowercase
+// convention ("read:", "patch (replace)") but not kimi's capitalised
+// format — so we get called on the already-mapped name from hermes
+// and fix up anything that slipped through. Empty input returns "".
+func kimiToolNameFromTitle(title string) string {
+ t := strings.TrimSpace(title)
+ if t == "" {
+ return ""
+ }
+
+ // Strip everything after the first colon — ACP titles often look like
+ // "Tool Name: argument detail" and we want only the tool name.
+ if idx := strings.Index(t, ":"); idx > 0 {
+ t = strings.TrimSpace(t[:idx])
+ }
+
+ lower := strings.ToLower(t)
+ switch lower {
+ case "read", "read file":
+ return "read_file"
+ case "write", "write file":
+ return "write_file"
+ case "edit", "patch":
+ return "edit_file"
+ case "shell", "bash", "terminal", "run command", "run shell command":
+ return "terminal"
+ case "search", "grep", "find":
+ return "search_files"
+ case "glob":
+ return "glob"
+ case "web search":
+ return "web_search"
+ case "fetch", "web fetch":
+ return "web_fetch"
+ case "todo", "todo write":
+ return "todo_write"
+ }
+
+ // Fallback: snake_case the title so the UI gets a stable identifier.
+ return strings.ReplaceAll(lower, " ", "_")
+}
diff --git a/server/pkg/agent/kimi_test.go b/server/pkg/agent/kimi_test.go
new file mode 100644
index 000000000..707213a26
--- /dev/null
+++ b/server/pkg/agent/kimi_test.go
@@ -0,0 +1,215 @@
+package agent
+
+import (
+ "context"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewReturnsKimiBackend(t *testing.T) {
+ t.Parallel()
+ b, err := New("kimi", Config{ExecutablePath: "/nonexistent/kimi"})
+ if err != nil {
+ t.Fatalf("New(kimi) error: %v", err)
+ }
+ if _, ok := b.(*kimiBackend); !ok {
+ t.Fatalf("expected *kimiBackend, got %T", b)
+ }
+}
+
+func TestKimiToolNameFromTitle(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ title string
+ want string
+ }{
+ {"Read file: /tmp/foo.go", "read_file"},
+ {"read", "read_file"},
+ {"Write: /tmp/bar.go", "write_file"},
+ {"Edit", "edit_file"},
+ {"Patch: /tmp/x", "edit_file"},
+ {"Shell: ls -la", "terminal"},
+ {"Bash", "terminal"},
+ {"Run command: pwd", "terminal"},
+ {"Search: foo", "search_files"},
+ {"Glob: *.go", "glob"},
+ {"Web search: golang acp", "web_search"},
+ {"Fetch: https://example.com", "web_fetch"},
+ {"Todo Write", "todo_write"},
+ // Fallback: snake_case the title.
+ {"Custom Thing", "custom_thing"},
+ // Empty input returns empty — caller decides how to react.
+ {"", ""},
+ }
+ for _, tt := range tests {
+ got := kimiToolNameFromTitle(tt.title)
+ if got != tt.want {
+ t.Errorf("kimiToolNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want)
+ }
+ }
+}
+
+// fakeKimiACPScript returns a POSIX-sh script that impersonates
+// `kimi acp` for a single short ACP session: it acks initialize /
+// session/new and then replies to session/set_model with a JSON-RPC
+// error — the scenario the kimiBackend must propagate as a failed
+// task rather than silently falling back to the default model.
+func fakeKimiACPScript() string {
+ return `#!/bin/sh
+# Fake ` + "`kimi`" + ` binary — used by TestKimiBackendSetModelFailureFailsTask
+# and TestKimiBackendPassesYoloFlag.
+#
+# Writes the full argv (one arg per line) to $KIMI_ARGS_FILE if that env
+# var is set, so tests can assert that the daemon invokes us with the
+# right flags (`+"`--yolo acp`"+`, not bare `+"`acp`"+`).
+#
+# Then reads one JSON-RPC request per line from stdin, matches on the
+# method name, and writes back a canned response. Exits after set_model
+# so the kimiBackend cleanup path can run.
+if [ -n "$KIMI_ARGS_FILE" ]; then
+ for arg in "$@"; do
+ printf '%s\n' "$arg" >> "$KIMI_ARGS_FILE"
+ done
+fi
+while IFS= read -r line; do
+ id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9]*\).*/\1/p')
+ case "$line" in
+ *'"method":"initialize"'*)
+ printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentCapabilities":{}}}\n' "$id"
+ ;;
+ *'"method":"session/new"'*)
+ printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"ses_fake"}}\n' "$id"
+ ;;
+ *'"method":"session/set_model"'*)
+ printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"model not available: bogus-model"}}\n' "$id"
+ exit 0
+ ;;
+ esac
+done
+`
+}
+
+// TestKimiBackendSetModelFailureFailsTask pins the "don't silently
+// fall back" behaviour that landed in this PR: when kimi rejects the
+// caller-selected model via session/set_model, the task result must
+// report status=failed with a message that names the model and the
+// upstream error — not claim success while actually running on the
+// default model.
+func TestKimiBackendSetModelFailureFailsTask(t *testing.T) {
+ t.Parallel()
+
+ fakePath := filepath.Join(t.TempDir(), "kimi")
+ if err := os.WriteFile(fakePath, []byte(fakeKimiACPScript()), 0o755); err != nil {
+ t.Fatalf("write fake kimi: %v", err)
+ }
+
+ backend, err := New("kimi", Config{ExecutablePath: fakePath, Logger: slog.Default()})
+ if err != nil {
+ t.Fatalf("new kimi backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Model: "bogus-model",
+ Timeout: 5 * time.Second,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ // Drain message stream so the lifecycle goroutine can progress.
+ go func() {
+ for range session.Messages {
+ }
+ }()
+
+ select {
+ case result, ok := <-session.Result:
+ if !ok {
+ t.Fatal("result channel closed without a value")
+ }
+ if result.Status != "failed" {
+ t.Fatalf("expected status=failed, got %q (error=%q)", result.Status, result.Error)
+ }
+ if !strings.Contains(result.Error, `could not switch to model "bogus-model"`) {
+ t.Errorf("expected error to name the requested model, got %q", result.Error)
+ }
+ if !strings.Contains(result.Error, "model not available") {
+ t.Errorf("expected error to surface upstream message, got %q", result.Error)
+ }
+ if result.SessionID != "ses_fake" {
+ t.Errorf("expected session id to be preserved on failure, got %q", result.SessionID)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("timeout waiting for result")
+ }
+}
+
+// TestKimiBackendInvokesACPSubcommand pins the argv for `kimi`. An
+// earlier fix tried passing `--yolo` to bypass per-tool approval
+// prompts, but the `acp` subcommand in kimi-cli takes no options
+// (see cli/__init__.py @cli.command def acp()), so `--yolo` was a
+// no-op and the daemon still hung for 5 min on the first Shell call.
+// The actual bypass is in hermesClient.handleAgentRequest, which
+// auto-approves session/request_permission. This test catches
+// accidental re-introduction of the dead flag.
+func TestKimiBackendInvokesACPSubcommand(t *testing.T) {
+ t.Parallel()
+
+ tempDir := t.TempDir()
+ argsFile := filepath.Join(tempDir, "argv.txt")
+ fakePath := filepath.Join(tempDir, "kimi")
+ if err := os.WriteFile(fakePath, []byte(fakeKimiACPScript()), 0o755); err != nil {
+ t.Fatalf("write fake kimi: %v", err)
+ }
+
+ backend, err := New("kimi", Config{
+ ExecutablePath: fakePath,
+ Logger: slog.Default(),
+ Env: map[string]string{"KIMI_ARGS_FILE": argsFile},
+ })
+ if err != nil {
+ t.Fatalf("new kimi backend: %v", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ // Set Model so the fake binary exits on set_model and we don't
+ // have to wait for the prompt branch. We only care about argv here.
+ session, err := backend.Execute(ctx, "prompt-ignored", ExecOptions{
+ Model: "bogus-model",
+ Timeout: 5 * time.Second,
+ })
+ if err != nil {
+ t.Fatalf("execute: %v", err)
+ }
+ go func() {
+ for range session.Messages {
+ }
+ }()
+ <-session.Result
+
+ raw, err := os.ReadFile(argsFile)
+ if err != nil {
+ t.Fatalf("read args file: %v", err)
+ }
+ lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
+ if len(lines) < 1 {
+ t.Fatalf("expected at least 1 arg (acp), got %d: %q", len(lines), lines)
+ }
+ if lines[0] != "acp" {
+ t.Errorf("expected first arg to be acp, got %q (full: %q)", lines[0], lines)
+ }
+ for _, l := range lines {
+ switch l {
+ case "--yolo", "--auto-approve", "--yes", "-y":
+ t.Errorf("kimi acp doesn't accept %q; auto-approval is handled in hermesClient.handleAgentRequest", l)
+ }
+ }
+}
diff --git a/server/pkg/agent/models.go b/server/pkg/agent/models.go
index 7e1ee7f71..23ff5e632 100644
--- a/server/pkg/agent/models.go
+++ b/server/pkg/agent/models.go
@@ -71,6 +71,10 @@ func ListModels(ctx context.Context, providerType, executablePath string) ([]Mod
return cachedDiscovery(providerType, func() ([]Model, error) {
return discoverHermesModels(ctx, executablePath)
})
+ case "kimi":
+ return cachedDiscovery(providerType, func() ([]Model, error) {
+ return discoverKimiModels(ctx, executablePath)
+ })
case "opencode":
return cachedDiscovery(providerType, func() ([]Model, error) {
return discoverOpenCodeModels(ctx, executablePath)
@@ -317,8 +321,52 @@ func parsePiModels(output string) []Model {
// error) all return an empty list so the UI falls back to the
// creatable manual-entry input instead of blocking the form.
func discoverHermesModels(ctx context.Context, executablePath string) ([]Model, error) {
+ return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
+ defaultBin: "hermes",
+ clientName: "multica-model-discovery",
+ extraEnv: []string{"HERMES_YOLO_MODE=1"},
+ tmpdirPrefix: "multica-hermes-discovery-",
+ })
+}
+
+// discoverKimiModels spins up a throwaway `kimi acp` process and
+// drives the same minimal ACP handshake as Hermes to surface the
+// model catalog advertised by Kimi's `session/new` response. Kimi's
+// ACPServer.new_session returns a `models` block of the same shape
+// (`availableModels`/`currentModelId`) so the parsing path is shared.
+//
+// Failure modes (kimi missing, not logged in, config error) all
+// return an empty list so the UI falls back to manual entry.
+func discoverKimiModels(ctx context.Context, executablePath string) ([]Model, error) {
+ return discoverACPModels(ctx, executablePath, acpDiscoveryProvider{
+ defaultBin: "kimi",
+ clientName: "multica-model-discovery",
+ tmpdirPrefix: "multica-kimi-discovery-",
+ })
+}
+
+// acpDiscoveryProvider configures how discoverACPModels launches an
+// ACP-speaking agent CLI. The shared helper drives every CLI in
+// the same way (initialize → session/new → parse models block) — the
+// per-provider differences are which binary to spawn, which env
+// vars suppress interactive prompts during init, and what to label
+// temporary work directories so they're easy to identify in logs.
+type acpDiscoveryProvider struct {
+ defaultBin string
+ clientName string
+ extraEnv []string
+ tmpdirPrefix string
+}
+
+// discoverACPModels runs the ACP handshake for any agent CLI that
+// implements the standard `initialize` + `session/new` flow and
+// advertises its model catalog in the response under
+// `models.availableModels` / `models.currentModelId`. This covers
+// Hermes and Kimi today; future ACP backends can plug in by adding
+// an acpDiscoveryProvider entry instead of duplicating the loop.
+func discoverACPModels(ctx context.Context, executablePath string, p acpDiscoveryProvider) ([]Model, error) {
if executablePath == "" {
- executablePath = "hermes"
+ executablePath = p.defaultBin
}
if _, err := exec.LookPath(executablePath); err != nil {
return []Model{}, nil
@@ -327,8 +375,9 @@ func discoverHermesModels(ctx context.Context, executablePath string) ([]Model,
defer cancel()
cmd := exec.CommandContext(runCtx, executablePath, "acp")
- // Mirror the real backend's auto-approve so init doesn't prompt.
- cmd.Env = append(os.Environ(), "HERMES_YOLO_MODE=1")
+ if len(p.extraEnv) > 0 {
+ cmd.Env = append(os.Environ(), p.extraEnv...)
+ }
stdin, err := cmd.StdinPipe()
if err != nil {
return []Model{}, nil
@@ -370,16 +419,16 @@ func discoverHermesModels(ctx context.Context, executablePath string) ([]Model,
// Send initialize + session/new.
if err := writeACP(1, "initialize", map[string]any{
"protocolVersion": 1,
- "clientInfo": map[string]any{"name": "multica-model-discovery", "version": "0.1.0"},
+ "clientInfo": map[string]any{"name": p.clientName, "version": "0.1.0"},
"clientCapabilities": map[string]any{},
}); err != nil {
return []Model{}, nil
}
- // Hermes requires a valid cwd for session/new — use a temp
- // directory we clean up afterwards, not the daemon's workdir
- // (which might be in the middle of another task's worktree).
- tmp, err := os.MkdirTemp("", "multica-hermes-discovery-")
+ // session/new requires a valid cwd — use a temp directory we
+ // clean up afterwards, not the daemon's workdir (which might
+ // be in the middle of another task's worktree).
+ tmp, err := os.MkdirTemp("", p.tmpdirPrefix)
if err != nil {
return []Model{}, nil
}
@@ -414,7 +463,7 @@ func discoverHermesModels(ctx context.Context, executablePath string) ([]Model,
if env.ID.String() != "2" || len(env.Result) == 0 {
continue
}
- done <- parseHermesSessionNewModels(env.Result)
+ done <- parseACPSessionNewModels(env.Result)
return
}
}()
@@ -432,14 +481,15 @@ func discoverHermesModels(ctx context.Context, executablePath string) ([]Model,
}
}
-// parseHermesSessionNewModels extracts the model catalog from a
-// hermes `session/new` response. Hermes' ACP schema emits:
+// parseACPSessionNewModels extracts the model catalog from an ACP
+// `session/new` response. Both Hermes and Kimi (and any other ACP
+// agent that follows the standard schema) emit:
//
// {
// "sessionId": "...",
// "models": {
// "availableModels": [
-// {"modelId": "...", "name": "...", "description": "... current"}
+// {"modelId": "...", "name": "...", "description": "..."}
// ],
// "currentModelId": "..."
// }
@@ -448,7 +498,7 @@ func discoverHermesModels(ctx context.Context, executablePath string) ([]Model,
// Returns nil (not an empty slice) when the payload is missing so
// the caller can distinguish "parsed with no models" (valid but
// empty catalog) from "couldn't find the structure at all".
-func parseHermesSessionNewModels(raw json.RawMessage) []Model {
+func parseACPSessionNewModels(raw json.RawMessage) []Model {
var resp struct {
Models struct {
AvailableModels []struct {
diff --git a/server/pkg/agent/models_test.go b/server/pkg/agent/models_test.go
index 3808ba983..5f5cf74de 100644
--- a/server/pkg/agent/models_test.go
+++ b/server/pkg/agent/models_test.go
@@ -258,7 +258,7 @@ func TestParseHermesSessionNewModels(t *testing.T) {
"currentModelId": "nous:anthropic/claude-opus-4.7"
}
}`)
- models := parseHermesSessionNewModels(raw)
+ models := parseACPSessionNewModels(raw)
if len(models) != 2 {
t.Fatalf("expected 2 models (duplicate deduped), got %d: %+v", len(models), models)
}
@@ -281,13 +281,13 @@ func TestParseHermesSessionNewModelsMissingField(t *testing.T) {
// failed _build_model_state — should yield nil so the caller
// can distinguish "no catalog" from "empty catalog".
raw := []byte(`{"sessionId": "ses_123"}`)
- if got := parseHermesSessionNewModels(raw); got != nil && len(got) != 0 {
+ if got := parseACPSessionNewModels(raw); got != nil && len(got) != 0 {
t.Errorf("expected nil/empty, got %+v", got)
}
}
func TestParseHermesSessionNewModelsGarbage(t *testing.T) {
- if got := parseHermesSessionNewModels([]byte("not json")); got != nil {
+ if got := parseACPSessionNewModels([]byte("not json")); got != nil {
t.Errorf("expected nil for non-JSON, got %+v", got)
}
}