From 5bef0335cf17d5768c8ebc4016b1b5ff312c7909 Mon Sep 17 00:00:00 2001 From: kina Date: Wed, 9 Sep 2026 14:50:42 +0800 Subject: [PATCH 1/5] feat(adapters): add Kimi Code CLI support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `kimi` as a seat agent and MCP host, alongside Claude Code, Codex, Cursor, Grok, Antigravity, and Copilot. Seats run `kimi -p --output-format stream-json` over the CLI bridge; resume uses `-S ` and model selection uses `-m`. Kimi Code exposes no reasoning-effort flag, so effort is rejected explicitly. A successful run without an observed session id (session.resume_hint) is treated as an error. Readiness checks the Kimi Code data root ($KIMI_CODE_HOME, default ~/.kimi-code) for credentials/kimi-code.json or config.toml; Kimi Code does not consume API keys from the shell environment. Relative KIMI_CODE_HOME values anchor to the current directory and are pinned on the child process so confer and the seat resolve the same data root. `confer mcp install` writes $KIMI_CODE_HOME/mcp.json. The bundled Skill installs to kitup's `kimi-cli` host, whose user skill dir upstream 0.1.4 prefers ~/.config/agents/skills — a directory Kimi Code never scans (its user roots are $KIMI_CODE_HOME/skills and ~/.agents/skills). Until kitup fixes that host data, `skill install --agent kimi` may target a directory Kimi Code does not read. --- README.md | 2 +- docs/SPEC.md | 11 +- docs/confer-architecture.svg | 2 +- skills/confer/SKILL.md | 4 +- src/adapters.rs | 238 ++++++++++++++++++++++++++++++++++- src/adapters/cli_tests.rs | 45 ++++++- src/cli.rs | 32 ++++- src/mcp/rooms.rs | 1 + src/mcp_host.rs | 152 ++++++++++++++++------ src/types.rs | 8 +- 10 files changed, 438 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 878757a..0d46920 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Confer -Confer exposes local coding-agent execution through MCP. Coding agents and other applications can create rooms, send work to Claude Code, Codex, Cursor Agent, Grok Build, Antigravity CLI, and GitHub Copilot CLI, and collect their results. +Confer exposes local coding-agent execution through MCP. Coding agents and other applications can create rooms, send work to Claude Code, Codex, Cursor Agent, Grok Build, Antigravity CLI, GitHub Copilot CLI, and Kimi Code, and collect their results. The host manages tasks and relays; it is not an execution seat. Supply `target_size` for automatic seat selection, explicit `seats`, or both. There is no default count or fixed seat limit, and identical agent configurations can occupy independent seats. diff --git a/docs/SPEC.md b/docs/SPEC.md index eb81709..afbefb8 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -18,8 +18,9 @@ The supported products can act as external room participants and as MCP hosts: | `grok` | Grok Build | `grok` | native `grok mcp` command | | `agy` | Antigravity CLI | `agy` | native `agy mcp` command | | `copilot` | GitHub Copilot CLI | `copilot` | native `copilot mcp` command | +| `kimi` | Kimi Code | `kimi` | `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code/mcp.json`) | -MCP is the public protocol. Every seat uses an ACP v1 lifecycle internally. Cursor, Grok, and Copilot use native ACP over stdio; Codex uses an in-process ACP bridge to its app-server; Claude and Antigravity use in-process ACP bridges to their native headless commands. Confer ships one Rust binary and requires no separate bridge runtime. +MCP is the public protocol. Every seat uses an ACP v1 lifecycle internally. Cursor, Grok, and Copilot use native ACP over stdio; Codex uses an in-process ACP bridge to its app-server; Claude, Antigravity, and Kimi Code use in-process ACP bridges to their native headless commands. Confer ships one Rust binary and requires no separate bridge runtime. ## Room model @@ -164,13 +165,13 @@ confer skill install [--scope user|project] [--agent ]... [--dry-run] [--yes `confer mcp` serves stdio MCP. Room operations are not exposed as ordinary CLI commands. -MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, and GitHub Copilot CLI. +MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code. -`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. +`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. Kitup 0.1.4's `kimi-cli` host data prefers `~/.config/agents/skills`, which Kimi Code does not scan (its user skill roots are `$KIMI_CODE_HOME/skills` and `~/.agents/skills`), so a user-scope install targeting Kimi may land in a directory Kimi Code never reads until Kitup corrects that host data. `confer mcp install` follows each host’s supported registration mechanism. Repeated installation updates the Confer-owned registration without deleting unrelated MCP entries. Uninstall removes only the `confer` entry. -Explicit `--agent cursor` registration and removal edit Cursor's MCP configuration without requiring the participant CLI on `PATH`. Default selection and `--agent '*'` still discover installed host executables; they do not create Cursor configuration on machines without its CLI. Other hosts require their native registration command. Registration does not establish participant readiness or authentication. +Explicit `--agent cursor` and `--agent kimi` registration and removal edit that host's MCP configuration (Cursor's `~/.cursor/mcp.json`, Kimi Code's `$KIMI_CODE_HOME/mcp.json`, default `~/.kimi-code/mcp.json`) without requiring the participant CLI on `PATH`. Default selection and `--agent '*'` still discover installed host executables; they do not create MCP configuration on machines without its CLI. Other hosts require their native registration command. Registration does not establish participant readiness or authentication. ## Adapter contract @@ -195,7 +196,7 @@ Model and reasoning fields are requests to the native CLI. An unsupported value Confer uses the room workspace as each child process working directory. It does not create filesystem isolation. Independent seats may therefore read or modify the same files even when their messages are isolated. -Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; and Copilot receives `--allow-all`. Seats therefore run with the authority of the local Confer process and without sandbox isolation. Explicit task instructions remain the only limit on what a seat is asked to do. +Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; Copilot receives `--allow-all`; and Kimi Code's non-interactive `-p` mode runs under its default `auto` permission policy, which never asks for approval but still enforces the user's static deny rules. Seats therefore run with the authority of the local Confer process and without sandbox isolation, except that a Kimi Code seat remains subject to its configured deny rules. Explicit task instructions remain the only limit on what a seat is asked to do. ## Errors diff --git a/docs/confer-architecture.svg b/docs/confer-architecture.svg index 2c2e97d..803e880 100644 --- a/docs/confer-architecture.svg +++ b/docs/confer-architecture.svg @@ -60,7 +60,7 @@ Seat adapters - Claude · Codex · Cursor · Grok · Antigravity · Copilot · agent types may repeat + Claude · Codex · Cursor · Grok · Antigravity · Copilot · Kimi · agent types may repeat Host diff --git a/skills/confer/SKILL.md b/skills/confer/SKILL.md index fa8e8ce..b6a03e7 100644 --- a/skills/confer/SKILL.md +++ b/skills/confer/SKILL.md @@ -1,6 +1,6 @@ --- name: confer -description: Coordinate Claude Code, Codex, Cursor Agent, Grok, Antigravity CLI, and GitHub Copilot CLI through private local MCP rooms. Trigger when the user asks to consult another agent, have multiple agents investigate independently, divide planning and implementation, compare answers, or request an independent review without copying between terminals. +description: Coordinate Claude Code, Codex, Cursor Agent, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code through private local MCP rooms. Trigger when the user asks to consult another agent, have multiple agents investigate independently, divide planning and implementation, compare answers, or request an independent review without copying between terminals. --- # Confer @@ -21,6 +21,8 @@ Honor explicit choices for agent, model, reasoning effort, participant count, ro Choose the number of execution seats from the task; there is no default count. Supply a positive `target_size`, explicit `seats`, or both. `target_size` excludes the host. Confer preserves explicit seats and automatically fills remaining positions, preferring agent types other than the host. The same agent, model, and reasoning effort may be used by multiple independent seats. Give seats short unique names and private instructions. Treat one room as one coordination context: add a new seat when a later phase needs another role, and retire a seat only after its delivery finishes and its role is complete. +When the host is Kimi Code, pass `"host_agent": "kimi"` on create_room: confer's automatic host detection recognizes Claude, Codex, Cursor, Grok, and Copilot session markers, while Kimi Code and Antigravity expose no equivalent marker, so auto-detection would mislabel those hosts. Pass it explicitly whenever auto-detection may not identify the host, not only for Kimi. + ## Keep Seats Private For independent work, send the same prompt separately or by multicast and wait for every required answer before sharing any of them. diff --git a/src/adapters.rs b/src/adapters.rs index 579aa77..2bbbc33 100644 --- a/src/adapters.rs +++ b/src/adapters.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, Mutex}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; @@ -82,7 +82,9 @@ pub(crate) fn check_readiness(agent: AgentKind) -> Readiness { pub(crate) fn reserve_session(agent: AgentKind) -> Option { match agent { AgentKind::Claude | AgentKind::Grok => Some(uuid::Uuid::new_v4().to_string()), - AgentKind::Codex | AgentKind::Cursor | AgentKind::Agy | AgentKind::Copilot => None, + AgentKind::Codex | AgentKind::Cursor | AgentKind::Agy | AgentKind::Copilot | AgentKind::Kimi => { + None + } } } @@ -270,6 +272,16 @@ async fn run_cli(invocation: Invocation, prompt: &str) -> AdapterOutput { error: Some(error_text(&error, &stderr, "")), }; } + if invocation.agent == AgentKind::Kimi && observed_session_id.is_none() { + // Kimi Code always emits a session.resume_hint with its session id; + // a "successful" run without one means we never actually attached to + // a session, so report it instead of returning an unresumable answer. + return AdapterOutput { + observed_session_id: None, + answer: None, + error: Some(error_text("kimi did not report a session id", &stderr, &raw)), + }; + } let answer = answer .or_else(|| (!streamed_text.is_empty()).then_some(streamed_text)) .or_else(|| { @@ -326,6 +338,9 @@ pub(crate) fn validate_seat_config( effort: Option<&str>, ) -> Result<()> { validate_effort(effort)?; + if agent == AgentKind::Kimi && effort.is_some() { + bail!("Kimi Code does not support reasoning_effort"); + } if agent == AgentKind::Agy && let Some(effort) = effort && !["low", "medium", "high"].contains(&effort) @@ -429,6 +444,27 @@ fn build_command(invocation: &Invocation, prompt: &str) -> Result { command.args(["--effort", effort]); } } + AgentKind::Kimi => { + // `-p` runs one prompt non-interactively under Kimi Code's + // default `auto` permission policy, which never blocks on an + // approval prompt. + // + // Pin KIMI_CODE_HOME to the resolved data root so the child + // agrees with confer's readiness and MCP registration even + // when the variable is unset or relative in our environment. + let kimi_home = + resolve_kimi_home(std::env::var_os("KIMI_CODE_HOME"), dirs::home_dir())?; + command.env("KIMI_CODE_HOME", &kimi_home); + command.args(["-p", prompt, "--output-format", "stream-json"]); + if let Some(id) = &invocation.native_session_id { + command.args(["-S", id]); + } else if !invocation.first_message { + bail!("Kimi Code resume requires a native session ID"); + } + if let Some(model) = &invocation.model { + command.args(["-m", model]); + } + } AgentKind::Codex | AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot => { bail!("agent requires its ACP transport") } @@ -482,12 +518,15 @@ fn executable_file(path: &Path) -> bool { } fn has_local_auth_marker(agent: AgentKind) -> bool { + // Kimi Code reads credentials only from its data root (OAuth files and + // provider `api_key` entries in config.toml); it does not consume + // KIMI_API_KEY/MOONSHOT_API_KEY from the shell environment. let env_ready = match agent { AgentKind::Claude => std::env::var_os("ANTHROPIC_API_KEY").is_some(), AgentKind::Codex => std::env::var_os("OPENAI_API_KEY").is_some(), AgentKind::Cursor => std::env::var_os("CURSOR_API_KEY").is_some(), AgentKind::Grok => std::env::var_os("XAI_API_KEY").is_some(), - AgentKind::Agy => false, + AgentKind::Agy | AgentKind::Kimi => false, AgentKind::Copilot => [ "COPILOT_GITHUB_TOKEN", "GH_TOKEN", @@ -526,10 +565,46 @@ fn has_local_auth_marker(agent: AgentKind) -> bool { // Copilot keeps its login token in the OS credential store, so the // managed config file written on first launch is the local marker. AgentKind::Copilot => vec![copilot_home(&home).join("config.json")], + AgentKind::Kimi => { + let Ok(kimi_home) = resolve_kimi_home(std::env::var_os("KIMI_CODE_HOME"), Some(home)) + else { + return false; + }; + return kimi_home_has_auth(&kimi_home); + } }; markers.iter().any(|marker| marker.is_file()) } +fn kimi_home_has_auth(kimi_home: &Path) -> bool { + // OAuth tokens live in credentials/.json; API-key auth appears + // as a non-empty `api_key` entry in config.toml. Kimi Code writes + // config.toml (with empty api_key values) on first launch, before any + // login, so file existence alone is not an auth marker. Files under + // credentials/mcp/ are MCP server metadata, not credentials. + let credentials = kimi_home.join("credentials"); + if let Ok(entries) = std::fs::read_dir(&credentials) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|ext| ext == "json") { + return true; + } + } + } + let Ok(config) = std::fs::read_to_string(kimi_home.join("config.toml")) else { + return false; + }; + config.lines().any(|line| { + let Some((key, value)) = line.trim().split_once('=') else { + return false; + }; + if key.trim() != "api_key" { + return false; + } + !value.trim().trim_matches('"').trim().is_empty() + }) +} + fn copilot_home(home: &Path) -> PathBuf { std::env::var_os("COPILOT_HOME") .filter(|value| !value.is_empty()) @@ -537,6 +612,30 @@ fn copilot_home(home: &Path) -> PathBuf { .unwrap_or_else(|| home.join(".copilot")) } +pub(crate) fn resolve_kimi_home( + kimi_code_home: Option, + home: Option, +) -> Result { + match kimi_code_home.filter(|value| !value.is_empty()) { + Some(dir) => { + let dir = PathBuf::from(dir); + if dir.is_absolute() { + Ok(dir) + } else { + // Kimi Code resolves a relative KIMI_CODE_HOME against the + // child's working directory; anchor it to confer's cwd so + // both sides mean the same data root. + let cwd = + std::env::current_dir().context("cannot determine current directory")?; + Ok(cwd.join(dir)) + } + } + None => home + .map(|home| home.join(".kimi-code")) + .context("cannot determine home directory"), + } +} + fn extract_session_id(value: &Value) -> Option { let object = value.as_object()?; for key in [ @@ -922,4 +1021,137 @@ mod tests { }; assert!(build_command(&invalid_effort, &super::prompt_text(&invalid_effort)).is_err()); } + + #[test] + fn builds_kimi_command_for_first_and_resume_messages() { + let first = Invocation { + agent: AgentKind::Kimi, + executable: PathBuf::from("kimi"), + workspace: PathBuf::from("/workspace"), + native_session_id: None, + model: Some("kimi-code/k3".into()), + reasoning_effort: None, + instructions: None, + message: "Analyze this".into(), + first_message: true, + }; + let command = build_command(&first, &super::prompt_text(&first)).unwrap(); + let debug = format!("{command:?}"); + assert!(debug.contains("-p")); + assert!(debug.contains("--output-format")); + assert!(debug.contains("stream-json")); + assert!(debug.contains("-m")); + assert!(debug.contains("kimi-code/k3")); + // KIMI_CODE_HOME is pinned so the child resolves the same data root. + assert!(debug.contains("KIMI_CODE_HOME")); + + let resume = Invocation { + agent: AgentKind::Kimi, + executable: PathBuf::from("kimi"), + workspace: PathBuf::from("/workspace"), + native_session_id: Some("session_kimi-1".into()), + model: None, + reasoning_effort: None, + instructions: None, + message: "Next step".into(), + first_message: false, + }; + let command = build_command(&resume, &super::prompt_text(&resume)).unwrap(); + let debug = format!("{command:?}"); + assert!(debug.contains("-S")); + assert!(debug.contains("session_kimi-1")); + + let invalid_resume = Invocation { + agent: AgentKind::Kimi, + executable: PathBuf::from("kimi"), + workspace: PathBuf::from("/workspace"), + native_session_id: None, + model: None, + reasoning_effort: None, + instructions: None, + message: "Next step".into(), + first_message: false, + }; + assert!(build_command(&invalid_resume, &super::prompt_text(&invalid_resume)).is_err()); + } + + #[test] + fn kimi_rejects_reasoning_effort() { + assert!(super::validate_seat_config(AgentKind::Kimi, None, Some("high")).is_err()); + assert!(super::validate_seat_config(AgentKind::Kimi, None, None).is_ok()); + } + + + #[test] + fn kimi_home_respects_env_override_and_default() { + use std::ffi::OsString; + let home = PathBuf::from("/home/test"); + assert_eq!( + super::resolve_kimi_home(Some(OsString::from("/custom/kimi")), Some(home.clone())) + .unwrap(), + PathBuf::from("/custom/kimi") + ); + assert_eq!( + super::resolve_kimi_home(None, Some(home.clone())).unwrap(), + home.join(".kimi-code") + ); + assert_eq!( + super::resolve_kimi_home(Some(OsString::new()), Some(home.clone())).unwrap(), + home.join(".kimi-code") + ); + assert!(super::resolve_kimi_home(None, None).is_err()); + // Relative paths anchor to the current directory so confer and the + // kimi child process resolve the same data root. + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + super::resolve_kimi_home(Some(OsString::from("relative/kimi")), Some(home.clone())) + .unwrap(), + cwd.join("relative/kimi") + ); + } + + #[test] + fn parses_kimi_stream_json_events() { + let version = serde_json::json!({"role":"meta","type":"system.version","version":"0.41.0"}); + let answer = serde_json::json!({"role":"assistant","content":"KIMI_OK"}); + let resume_hint = serde_json::json!({ + "role": "meta", + "type": "session.resume_hint", + "session_id": "session_kimi-1", + }); + assert_eq!(extract_session_id(&version), None); + assert_eq!(extract_answer(&answer).as_deref(), Some("KIMI_OK")); + assert_eq!( + extract_session_id(&resume_hint).as_deref(), + Some("session_kimi-1") + ); + } + + #[test] + fn kimi_auth_marker_requires_credentials_or_a_key() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + assert!(!super::kimi_home_has_auth(home)); + // First-launch config.toml carries empty api_key values; not auth. + std::fs::write( + home.join("config.toml"), + "[providers.\"managed:kimi-code\"]\napi_key = \"\"\n", + ) + .unwrap(); + assert!(!super::kimi_home_has_auth(home)); + // A non-empty api_key entry counts. + std::fs::write( + home.join("config.toml"), + "[providers.custom]\ntype = \"openai\"\napi_key = \"sk-test\"\n", + ) + .unwrap(); + assert!(super::kimi_home_has_auth(home)); + // OAuth credential files count; MCP metadata under credentials/mcp/ does not. + std::fs::remove_file(home.join("config.toml")).unwrap(); + std::fs::create_dir_all(home.join("credentials/mcp")).unwrap(); + std::fs::write(home.join("credentials/mcp/tool.json"), "{}").unwrap(); + assert!(!super::kimi_home_has_auth(home)); + std::fs::write(home.join("credentials/kimi-code.json"), "{}").unwrap(); + assert!(super::kimi_home_has_auth(home)); + } } diff --git a/src/adapters/cli_tests.rs b/src/adapters/cli_tests.rs index c0c26ac..d0bf06d 100644 --- a/src/adapters/cli_tests.rs +++ b/src/adapters/cli_tests.rs @@ -23,7 +23,7 @@ fn invocation(directory: &Path, agent: AgentKind, script: &str) -> Invocation { #[tokio::test] async fn cli_bridge_preserves_native_identity_and_prompt() { - for agent in [AgentKind::Claude, AgentKind::Agy] { + for agent in [AgentKind::Claude, AgentKind::Agy, AgentKind::Kimi] { let directory = tempfile::tempdir().unwrap(); let mut invocation = invocation( directory.path(), @@ -57,6 +57,49 @@ printf '%s\n' '{"session_id":"native-session","result":"Final answer"}' } } +#[tokio::test] +async fn kimi_bridge_reads_identity_from_resume_hint_only() { + let directory = tempfile::tempdir().unwrap(); + let invocation = invocation( + directory.path(), + AgentKind::Kimi, + r#" +printf '%s\n' '{"role":"meta","type":"system.version","version":"0.41.0"}' +printf '%s\n' '{"role":"assistant","content":"KIMI_OK"}' +printf '%s\n' '{"role":"meta","type":"session.resume_hint","session_id":"session_kimi-1","command":"kimi -r session_kimi-1"}' +"#, + ); + let output = run(invocation).await; + assert_eq!(output.answer.as_deref(), Some("KIMI_OK")); + assert_eq!( + output.observed_session_id.as_deref(), + Some("session_kimi-1") + ); + assert!(output.error.is_none(), "{output:?}"); +} + +#[tokio::test] +async fn kimi_bridge_rejects_success_without_a_session_id() { + let directory = tempfile::tempdir().unwrap(); + // The assistant answer arrived but the trailing session.resume_hint + // never did, so the seat identity would be lost. + let invocation = invocation( + directory.path(), + AgentKind::Kimi, + r#"printf '%s\n' '{"role":"assistant","content":"KIMI_OK"}'"#, + ); + let output = run(invocation).await; + assert_eq!(output.answer, None); + assert_eq!(output.observed_session_id, None); + assert!( + output + .error + .as_deref() + .is_some_and(|error| error.contains("did not report a session id")), + "{output:?}" + ); +} + #[tokio::test] async fn native_git_uses_the_invocation_workspace() { if std::env::var_os("CONFER_TEST_NATIVE_GIT").is_none() { diff --git a/src/cli.rs b/src/cli.rs index 89b7463..9a5b978 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -38,7 +38,7 @@ enum McpCommands { Install { #[arg( long = "agent", - help = "Target host: claude, codex, cursor, grok, agy, or copilot. Repeat for multiple hosts. Use '*' for all." + help = "Target host: claude, codex, cursor, grok, agy, copilot, or kimi. Repeat for multiple hosts. Use '*' for all." )] agents: Vec, #[arg(long, help = "Print host changes without applying them")] @@ -50,7 +50,7 @@ enum McpCommands { Uninstall { #[arg( long = "agent", - help = "Target host: claude, codex, cursor, grok, agy, or copilot. Repeat for multiple hosts. Use '*' for all." + help = "Target host: claude, codex, cursor, grok, agy, copilot, or kimi. Repeat for multiple hosts. Use '*' for all." )] agents: Vec, #[arg(long, help = "Print host changes without applying them")] @@ -66,7 +66,7 @@ enum SkillCommands { scope: Option, #[arg( long = "agent", - help = "Target agent: claude, codex, cursor, grok, agy, or copilot. Repeat for multiple agents. Use '*' for all." + help = "Target agent: claude, codex, cursor, grok, agy, copilot, or kimi. Repeat for multiple agents. Use '*' for all." )] agents: Vec, #[arg(long, help = "Show the Kitup install plan without writing")] @@ -154,12 +154,24 @@ fn supported_skill_agents( .collect::>(); let selected = match selector { kitup::AgentSelector::Auto => { - kitup::detect_hosts(&kitup::BaseOptions::default(), Some(scope)) + let mut detected = kitup::detect_hosts(&kitup::BaseOptions::default(), Some(scope)) .context("failed to detect local Skill hosts")? .into_iter() .map(|host| host.id) .filter(|id| supported.contains(&id.as_str())) - .collect::>() + .collect::>(); + // kitup's kimi-cli detection only probes the default data root; + // a custom KIMI_CODE_HOME is invisible to it. Fall back to + // confer's own readiness check so auto selection still lists + // Kimi Code. This fixes detection only: the install target path + // still comes from kitup's host data (see the SPEC note on + // Kitup 0.1.4's kimi-cli skill dir). + if !detected.iter().any(|id| id == "kimi-cli") + && crate::adapters::check_readiness(AgentKind::Kimi).locally_ready + { + detected.push("kimi-cli".into()); + } + detected } kitup::AgentSelector::All => supported.iter().map(ToString::to_string).collect(), kitup::AgentSelector::Explicit(values) => { @@ -270,6 +282,16 @@ mod tests { selected, kitup::AgentSelector::Explicit(vec!["github-copilot".into()]) ); + + let selected = supported_skill_agents( + kitup::AgentSelector::Explicit(vec!["kimi".into(), "kimi-cli".into()]), + kitup::Scope::User, + ) + .unwrap(); + assert_eq!( + selected, + kitup::AgentSelector::Explicit(vec!["kimi-cli".into()]) + ); } #[test] diff --git a/src/mcp/rooms.rs b/src/mcp/rooms.rs index 0949ef5..8915c04 100644 --- a/src/mcp/rooms.rs +++ b/src/mcp/rooms.rs @@ -426,6 +426,7 @@ mod tests { (AgentKind::Cursor, Some("model[effort"), None), (AgentKind::Claude, None, Some("invalid")), (AgentKind::Agy, None, Some("xhigh")), + (AgentKind::Kimi, None, Some("high")), ] { let request = SeatSpecInput { agent: Some(agent.id().into()), diff --git a/src/mcp_host.rs b/src/mcp_host.rs index 24d230f..64d3d32 100644 --- a/src/mcp_host.rs +++ b/src/mcp_host.rs @@ -137,6 +137,7 @@ fn add_args(host: AgentKind, bin: &str) -> Option> { bin.into(), SERVER_ARG.into(), ]), + AgentKind::Kimi => None, } } @@ -160,6 +161,7 @@ fn remove_args(host: AgentKind) -> Option> { ]), AgentKind::Agy => Some(vec!["mcp".into(), "remove".into(), SERVER_NAME.into()]), AgentKind::Copilot => Some(vec!["mcp".into(), "remove".into(), SERVER_NAME.into()]), + AgentKind::Kimi => None, } } @@ -174,7 +176,7 @@ fn run_hosts( let mut errors = Vec::new(); for host in hosts { let program = host_program(host); - if program.is_none() && (discover || host != AgentKind::Cursor) { + if program.is_none() && (discover || !uses_config_file(host)) { eprintln!("skipped {}: executable is not on PATH", host.id()); continue; } @@ -235,11 +237,11 @@ fn apply_host( Ok(()) } None => { - let path = cursor_config_path()?; + let path = mcp_config_path(host)?; if dry_run { println!("write {} ({SERVER_NAME})", path.display()); } else { - write_cursor_config(&path, bin)?; + write_mcp_config(host, &path, bin)?; println!("installed {}", host.id()); } Ok(()) @@ -252,11 +254,11 @@ fn apply_host( Ok(()) } None => { - let path = cursor_config_path()?; + let path = mcp_config_path(host)?; if dry_run { println!("remove {} ({SERVER_NAME})", path.display()); } else { - remove_cursor_config(&path)?; + remove_mcp_config(host, &path)?; println!("uninstalled {}", host.id()); } Ok(()) @@ -305,14 +307,25 @@ fn run_host_command( bail!("{}: {}", display_command(program, args), detail) } -fn cursor_config_path() -> Result { - Ok(dirs::home_dir() - .context("cannot determine home directory")? - .join(".cursor/mcp.json")) +fn uses_config_file(host: AgentKind) -> bool { + matches!(host, AgentKind::Cursor | AgentKind::Kimi) } -fn write_cursor_config(path: &Path, bin: &str) -> Result<()> { - update_cursor_config(path, |config| { +fn mcp_config_path(host: AgentKind) -> Result { + match host { + AgentKind::Cursor => Ok(dirs::home_dir() + .context("cannot determine home directory")? + .join(".cursor/mcp.json")), + AgentKind::Kimi => Ok( + crate::adapters::resolve_kimi_home(std::env::var_os("KIMI_CODE_HOME"), dirs::home_dir())? + .join("mcp.json"), + ), + _ => bail!("{} does not use an MCP config file", host.id()), + } +} + +fn write_mcp_config(host: AgentKind, path: &Path, bin: &str) -> Result<()> { + update_mcp_config(path, |config| { let servers = config .as_object_mut() .and_then(|root| { @@ -320,14 +333,19 @@ fn write_cursor_config(path: &Path, bin: &str) -> Result<()> { .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() }) - .context("invalid Cursor MCP config: mcpServers must be an object")?; + .with_context(|| format!("invalid {} MCP config: mcpServers must be an object", host.id()))?; let entry = servers .entry(SERVER_NAME) .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() - .context("invalid Cursor MCP config: mcpServers.confer must be an object")?; + .with_context(|| { + format!("invalid {} MCP config: mcpServers.confer must be an object", host.id()) + })?; if uses_non_stdio_transport(entry) { - bail!("cannot install Cursor MCP: existing confer entry uses a non-stdio transport"); + bail!( + "cannot install {} MCP config: existing confer entry uses a non-stdio transport", + host.id() + ); } entry.insert("type".into(), Value::String("stdio".into())); entry.insert("command".into(), Value::String(bin.into())); @@ -336,11 +354,11 @@ fn write_cursor_config(path: &Path, bin: &str) -> Result<()> { }) } -fn remove_cursor_config(path: &Path) -> Result<()> { +fn remove_mcp_config(host: AgentKind, path: &Path) -> Result<()> { if !path.is_file() { return Ok(()); } - update_cursor_config(path, |config| { + update_mcp_config(path, |config| { if let Some(servers) = config.get_mut("mcpServers").and_then(Value::as_object_mut) { if servers .get(SERVER_NAME) @@ -348,7 +366,8 @@ fn remove_cursor_config(path: &Path) -> Result<()> { .is_some_and(uses_non_stdio_transport) { bail!( - "cannot uninstall Cursor MCP: existing confer entry uses a non-stdio transport" + "cannot uninstall {} MCP config: existing confer entry uses a non-stdio transport", + host.id() ); } servers.remove(SERVER_NAME); @@ -357,13 +376,13 @@ fn remove_cursor_config(path: &Path) -> Result<()> { }) } -fn update_cursor_config(path: &Path, change: impl FnOnce(&mut Value) -> Result<()>) -> Result<()> { - let mut config = read_cursor_config(path)?; +fn update_mcp_config(path: &Path, change: impl FnOnce(&mut Value) -> Result<()>) -> Result<()> { + let mut config = read_mcp_config(path)?; change(&mut config)?; - write_cursor_config_file(path, &config) + write_mcp_config_file(path, &config) } -fn read_cursor_config(path: &Path) -> Result { +fn read_mcp_config(path: &Path) -> Result { match fs::read_to_string(path) { Ok(body) if body.trim().is_empty() => Ok(serde_json::json!({ "mcpServers": {} })), Ok(body) => serde_json::from_str(&body) @@ -375,7 +394,7 @@ fn read_cursor_config(path: &Path) -> Result { } } -fn write_cursor_config_file(path: &Path, config: &Value) -> Result<()> { +fn write_mcp_config_file(path: &Path, config: &Value) -> Result<()> { let target = match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Some( fs::canonicalize(path) @@ -390,16 +409,16 @@ fn write_cursor_config_file(path: &Path, config: &Value) -> Result<()> { let path = target.as_deref().unwrap_or(path); let parent = path .parent() - .context("Cursor MCP config has no parent directory")?; + .context("MCP config has no parent directory")?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let mut temp = tempfile::NamedTempFile::new_in(parent) .with_context(|| format!("failed to create temporary file in {}", parent.display()))?; let body = format!("{}\n", serde_json::to_string_pretty(config)?); temp.write_all(body.as_bytes()) - .context("failed to write Cursor MCP config")?; + .context("failed to write MCP config")?; temp.as_file() .sync_all() - .context("failed to sync Cursor MCP config")?; + .context("failed to sync MCP config")?; temp.persist(path) .map_err(|error| error.error) .with_context(|| format!("failed to replace {}", path.display()))?; @@ -485,7 +504,8 @@ fn looks_like_already_exists(value: &str) -> bool { #[cfg(test)] mod tests { use super::{ - add_args, read_cursor_config, remove_args, remove_cursor_config, write_cursor_config, + add_args, read_mcp_config, remove_args, remove_mcp_config, uses_config_file, + write_mcp_config, }; use crate::types::AgentKind; @@ -519,10 +539,17 @@ mod tests { remove_args(AgentKind::Copilot).unwrap(), ["mcp", "remove", "confer"] ); + assert!(add_args(AgentKind::Cursor, "confer").is_none()); + assert!(remove_args(AgentKind::Cursor).is_none()); + assert!(add_args(AgentKind::Kimi, "confer").is_none()); + assert!(remove_args(AgentKind::Kimi).is_none()); + assert!(uses_config_file(AgentKind::Cursor)); + assert!(uses_config_file(AgentKind::Kimi)); + assert!(!uses_config_file(AgentKind::Claude)); } #[test] - fn cursor_update_preserves_unrelated_entries() { + fn mcp_config_update_preserves_unrelated_entries() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mcp.json"); std::fs::write( @@ -530,8 +557,8 @@ mod tests { r#"{"mcpServers":{"other":{"command":"other"},"confer":{"env":{"A":"B"}}}}"#, ) .unwrap(); - write_cursor_config(&path, "/tmp/confer").unwrap(); - let config = read_cursor_config(&path).unwrap(); + write_mcp_config(AgentKind::Cursor, &path, "/tmp/confer").unwrap(); + let config = read_mcp_config(&path).unwrap(); assert_eq!(config["mcpServers"]["other"]["command"], "other"); assert_eq!(config["mcpServers"]["confer"]["env"]["A"], "B"); assert_eq!(config["mcpServers"]["confer"]["type"], "stdio"); @@ -540,13 +567,13 @@ mod tests { config["mcpServers"]["confer"]["args"], serde_json::json!(["mcp"]) ); - remove_cursor_config(&path).unwrap(); + remove_mcp_config(AgentKind::Cursor, &path).unwrap(); assert_eq!( - read_cursor_config(&path).unwrap()["mcpServers"]["other"]["command"], + read_mcp_config(&path).unwrap()["mcpServers"]["other"]["command"], "other" ); assert!( - read_cursor_config(&path).unwrap()["mcpServers"] + read_mcp_config(&path).unwrap()["mcpServers"] .get("confer") .is_none() ); @@ -605,22 +632,54 @@ mod tests { std::env::current_dir().unwrap().canonicalize().unwrap(), isolated ); - let path = super::cursor_config_path().unwrap(); + let path = super::mcp_config_path(AgentKind::Cursor).unwrap(); + let kimi_path = super::mcp_config_path(AgentKind::Kimi).unwrap(); + assert_eq!( + kimi_path, + dirs::home_dir() + .unwrap() + .join(".kimi-code") + .join("mcp.json") + ); + // A custom KIMI_CODE_HOME redirects the config; a relative value + // anchors to the current directory. Safe: this test runs alone in + // the isolated re-invoked process, so no other thread reads env. + let custom = isolated.join("custom-kimi-home"); + unsafe { + std::env::set_var("KIMI_CODE_HOME", &custom); + } + assert_eq!( + super::mcp_config_path(AgentKind::Kimi).unwrap(), + custom.join("mcp.json") + ); + unsafe { + std::env::set_var("KIMI_CODE_HOME", "relative-kimi-home"); + } + assert_eq!( + super::mcp_config_path(AgentKind::Kimi).unwrap(), + isolated.join("relative-kimi-home").join("mcp.json") + ); + unsafe { + std::env::remove_var("KIMI_CODE_HOME"); + } let agents = ["cursor".into()]; + let kimi_agents = ["kimi".into()]; for dry_run in [true, false] { for selection in [vec![], vec!["*".into()], vec!["cursor".into(), "*".into()]] { assert!(super::install(&selection, dry_run, None).is_err()); assert!(super::uninstall(&selection, dry_run).is_err()); assert!(!path.exists()); + assert!(!kimi_path.exists()); } for host in AgentKind::ALL { - if host != AgentKind::Cursor { + if !uses_config_file(host) { assert!(super::install(&[host.id().into()], dry_run, None).is_err()); assert!(super::uninstall(&[host.id().into()], dry_run).is_err()); } } assert!(super::install(&agents, dry_run, Some(path.clone())).is_err()); assert!(!path.exists()); + assert!(!kimi_path.exists()); } super::install(&agents, true, None).unwrap(); @@ -629,7 +688,7 @@ mod tests { assert!(!path.exists()); super::install(&agents, false, None).unwrap(); assert_eq!( - read_cursor_config(&path).unwrap()["mcpServers"]["confer"]["command"], + read_mcp_config(&path).unwrap()["mcpServers"]["confer"]["command"], "confer" ); let installed = std::fs::read(&path).unwrap(); @@ -638,22 +697,37 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), installed); super::uninstall(&agents, false).unwrap(); assert!( - read_cursor_config(&path).unwrap()["mcpServers"] + read_mcp_config(&path).unwrap()["mcpServers"] .get("confer") .is_none() ); assert!(!crate::adapters::check_readiness(AgentKind::Cursor).locally_ready); + + super::install(&kimi_agents, true, None).unwrap(); + assert!(!kimi_path.exists()); + super::install(&kimi_agents, false, None).unwrap(); + assert_eq!( + read_mcp_config(&kimi_path).unwrap()["mcpServers"]["confer"]["command"], + "confer" + ); + super::uninstall(&kimi_agents, false).unwrap(); + assert!( + read_mcp_config(&kimi_path).unwrap()["mcpServers"] + .get("confer") + .is_none() + ); + assert!(!crate::adapters::check_readiness(AgentKind::Kimi).locally_ready); } #[test] - fn cursor_update_accepts_empty_config_file() { + fn mcp_config_update_accepts_empty_config_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mcp.json"); std::fs::write(&path, " \n").unwrap(); - write_cursor_config(&path, "confer").unwrap(); + write_mcp_config(AgentKind::Kimi, &path, "confer").unwrap(); - let config = read_cursor_config(&path).unwrap(); + let config = read_mcp_config(&path).unwrap(); assert_eq!(config["mcpServers"]["confer"]["command"], "confer"); } } diff --git a/src/types.rs b/src/types.rs index 5b7b749..d27d510 100644 --- a/src/types.rs +++ b/src/types.rs @@ -11,16 +11,18 @@ pub(crate) enum AgentKind { Grok, Agy, Copilot, + Kimi, } impl AgentKind { - pub(crate) const ALL: [Self; 6] = [ + pub(crate) const ALL: [Self; 7] = [ Self::Claude, Self::Codex, Self::Cursor, Self::Grok, Self::Agy, Self::Copilot, + Self::Kimi, ]; pub(crate) fn id(self) -> &'static str { @@ -31,6 +33,7 @@ impl AgentKind { Self::Grok => "grok", Self::Agy => "agy", Self::Copilot => "copilot", + Self::Kimi => "kimi", } } @@ -42,6 +45,7 @@ impl AgentKind { "grok" | "grok-build" => Some(Self::Grok), "agy" | "antigravity" | "antigravity-cli" => Some(Self::Agy), "copilot" | "copilot-cli" | "github-copilot" => Some(Self::Copilot), + "kimi" | "kimi-code" | "kimi-code-cli" | "kimi-cli" => Some(Self::Kimi), _ => None, } } @@ -54,6 +58,7 @@ impl AgentKind { Self::Grok => &["grok"], Self::Agy => &["agy"], Self::Copilot => &["copilot"], + Self::Kimi => &["kimi"], } } @@ -65,6 +70,7 @@ impl AgentKind { Self::Grok => Some("grok"), Self::Agy => Some("antigravity-cli"), Self::Copilot => Some("github-copilot"), + Self::Kimi => Some("kimi-cli"), } } } From 704ab77e0879b2a4529c2c0e575afae6b5c6566a Mon Sep 17 00:00:00 2001 From: kina Date: Thu, 10 Sep 2026 14:22:39 +0800 Subject: [PATCH 2/5] feat(adapters): run Kimi Code over native ACP Move Kimi seats from the CLI print-bridge to native ACP (`kimi acp`), matching Grok, Cursor, and Copilot. Resume uses `session/resume`. Model and unattended permission are applied through `session/set_config_option`: Kimi 0.41.0 maps ACP `mode=auto` to engine Never Ask (advertised `yolo` is Ask When Needed). `reasoning_effort` remains rejected. KIMI_CODE_HOME is pinned on the ACP child. Patch kitup to delete-cloud/kitup@716a744 so kimi-cli user skills install to ~/.agents/skills. --- Cargo.lock | 3 +- Cargo.toml | 5 ++ docs/SPEC.md | 10 ++-- src/adapters.rs | 102 +++++--------------------------------- src/adapters/acp.rs | 14 ++++++ src/adapters/acp_tests.rs | 89 +++++++++++++++++++++++++++++++++ src/adapters/cli_tests.rs | 45 +---------------- src/adapters/native.rs | 88 ++++++++++++++++++++++++-------- src/cli.rs | 5 +- 9 files changed, 196 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7abbe7d..dcc7ae0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1080,8 +1080,7 @@ dependencies = [ [[package]] name = "kitup" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46d990ed3fd5d645bfc97a2acc03e221eb9e7611da0eea452b9dfdf98c0856a7" +source = "git+https://github.com/delete-cloud/kitup.git?rev=716a74480300582b7044e2ae745cc42974cf001f#716a74480300582b7044e2ae745cc42974cf001f" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 28aefcb..4700d24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,8 @@ pre-release-commit-message = "chore(release): bump to v{{version}}" tag-message = "v{{version}}" tag-name = "v{{version}}" pre-release-hook = ["scripts/regenerate-changelog", "{{version}}"] + +# crates.io kitup 0.1.4 installs kimi-cli skills into ~/.config/agents/skills, +# which Kimi Code does not scan. Remove after upstream publishes that host-data fix. +[patch.crates-io] +kitup = { git = "https://github.com/delete-cloud/kitup.git", rev = "716a74480300582b7044e2ae745cc42974cf001f" } diff --git a/docs/SPEC.md b/docs/SPEC.md index afbefb8..3558eac 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -20,7 +20,7 @@ The supported products can act as external room participants and as MCP hosts: | `copilot` | GitHub Copilot CLI | `copilot` | native `copilot mcp` command | | `kimi` | Kimi Code | `kimi` | `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code/mcp.json`) | -MCP is the public protocol. Every seat uses an ACP v1 lifecycle internally. Cursor, Grok, and Copilot use native ACP over stdio; Codex uses an in-process ACP bridge to its app-server; Claude, Antigravity, and Kimi Code use in-process ACP bridges to their native headless commands. Confer ships one Rust binary and requires no separate bridge runtime. +MCP is the public protocol. Every seat uses an ACP v1 lifecycle internally. Cursor, Grok, Copilot, and Kimi Code use native ACP over stdio; Codex uses an in-process ACP bridge to its app-server; Claude and Antigravity use in-process ACP bridges to their native headless commands. Confer ships one Rust binary and requires no separate bridge runtime. ## Room model @@ -82,7 +82,7 @@ Room metadata writes use a short advisory lock and atomic replacement. Current w Readiness checks are local and run when a room is created, when a seat is added, and before each delivery starts. They inspect the executable and local authentication or configuration state without calling a model or checking quota. A positive result means `locally_ready`; it does not guarantee provider availability, model access, or remaining quota. -Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. +Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. Kimi Code applies `model` through ACP `session/set_config_option`; it rejects `reasoning_effort` because its thinking picker currently exposes no effort levels. The host may specify all, some, or none of the seats. For unspecified agents and remaining target positions, Confer cycles through locally ready supported agents, placing agent types other than the known host first. Agent types may repeat; the host's agent type remains eligible. Explicit agent choices are honored. @@ -167,7 +167,7 @@ confer skill install [--scope user|project] [--agent ]... [--dry-run] [--yes MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code. -`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. Kitup 0.1.4's `kimi-cli` host data prefers `~/.config/agents/skills`, which Kimi Code does not scan (its user skill roots are `$KIMI_CODE_HOME/skills` and `~/.agents/skills`), so a user-scope install targeting Kimi may land in a directory Kimi Code never reads until Kitup corrects that host data. +`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. Crates.io Kitup 0.1.4's `kimi-cli` host data prefers `~/.config/agents/skills`, which Kimi Code does not scan (its user skill roots are `$KIMI_CODE_HOME/skills` and `~/.agents/skills`). Confer patches Kitup to `delete-cloud/kitup` (`716a744`) so a Kimi user-scope install lands in `~/.agents/skills`. Existing installs already written under `~/.config/agents/skills` are not migrated. Remove the patch after upstream publishes that host-data fix. `confer mcp install` follows each host’s supported registration mechanism. Repeated installation updates the Confer-owned registration without deleting unrelated MCP entries. Uninstall removes only the `confer` entry. @@ -188,7 +188,7 @@ Every adapter must: Confer owns the FIFO Queue above every adapter. Each queued delivery opens one ACP connection, runs one native agent process, and resumes the seat's recorded native session when one exists. Session history replay is excluded from the current answer. After a terminal response, Confer closes the connection and reaps its child; a child that remains alive after three seconds is terminated. There is no idle process pool. -Cursor seats use its ACP session store. Old headless Cursor session IDs are not migrated and require new seats. Copilot seats use its native session store through ACP `session/load`; replayed history is excluded from the current answer. Other native session stores are not rewritten. An observed session ID remains available when the prompt fails; a missing or stale ID never triggers silent replacement. A native ACP session whose model or effort configuration fails before its first prompt is not recorded, because no native work has started; the next message to that seat starts a new native session. +Cursor seats use its ACP session store. Old headless Cursor session IDs are not migrated and require new seats. Copilot seats use its native session store through ACP `session/load`; replayed history is excluded from the current answer. Kimi Code seats use native ACP `session/resume` (it also advertises `loadSession`; Confer prefers resume so history is not replayed into the current answer). Other native session stores are not rewritten. An observed session ID remains available when the prompt fails; a missing or stale ID never triggers silent replacement. A native ACP session whose model or effort configuration fails before its first prompt is not recorded, because no native work has started; the next message to that seat starts a new native session. Model and reasoning fields are requests to the native CLI. An unsupported value must produce a clear adapter error rather than silently selecting another model. @@ -196,7 +196,7 @@ Model and reasoning fields are requests to the native CLI. An unsupported value Confer uses the room workspace as each child process working directory. It does not create filesystem isolation. Independent seats may therefore read or modify the same files even when their messages are isolated. -Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; Copilot receives `--allow-all`; and Kimi Code's non-interactive `-p` mode runs under its default `auto` permission policy, which never asks for approval but still enforces the user's static deny rules. Seats therefore run with the authority of the local Confer process and without sandbox isolation, except that a Kimi Code seat remains subject to its configured deny rules. Explicit task instructions remain the only limit on what a seat is asked to do. +Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; Copilot receives `--allow-all`; and Kimi Code receives ACP `session/set_config_option` `mode=auto`. Kimi 0.41.0 maps that ACP id to engine Never Ask (`permission: "auto"`); its advertised `yolo` label is Ask When Needed and is not used. `kimi acp` has no permission flags, and even `kimi --auto acp` leaves `session/new` at `mode=default`. A Kimi Code seat remains subject to the user's static deny rules. Other seats run with the authority of the local Confer process and without sandbox isolation. Explicit task instructions remain the only limit on what a seat is asked to do. ## Errors diff --git a/src/adapters.rs b/src/adapters.rs index 2bbbc33..0cc425f 100644 --- a/src/adapters.rs +++ b/src/adapters.rs @@ -93,7 +93,9 @@ pub(crate) async fn run(invocation: Invocation) -> AdapterOutput { return AdapterOutput::failed(error.to_string()); } match invocation.agent { - AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot => native::run(invocation).await, + AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot | AgentKind::Kimi => { + native::run(invocation).await + } _ => bridge::run(invocation).await, } } @@ -272,16 +274,6 @@ async fn run_cli(invocation: Invocation, prompt: &str) -> AdapterOutput { error: Some(error_text(&error, &stderr, "")), }; } - if invocation.agent == AgentKind::Kimi && observed_session_id.is_none() { - // Kimi Code always emits a session.resume_hint with its session id; - // a "successful" run without one means we never actually attached to - // a session, so report it instead of returning an unresumable answer. - return AdapterOutput { - observed_session_id: None, - answer: None, - error: Some(error_text("kimi did not report a session id", &stderr, &raw)), - }; - } let answer = answer .or_else(|| (!streamed_text.is_empty()).then_some(streamed_text)) .or_else(|| { @@ -444,28 +436,7 @@ fn build_command(invocation: &Invocation, prompt: &str) -> Result { command.args(["--effort", effort]); } } - AgentKind::Kimi => { - // `-p` runs one prompt non-interactively under Kimi Code's - // default `auto` permission policy, which never blocks on an - // approval prompt. - // - // Pin KIMI_CODE_HOME to the resolved data root so the child - // agrees with confer's readiness and MCP registration even - // when the variable is unset or relative in our environment. - let kimi_home = - resolve_kimi_home(std::env::var_os("KIMI_CODE_HOME"), dirs::home_dir())?; - command.env("KIMI_CODE_HOME", &kimi_home); - command.args(["-p", prompt, "--output-format", "stream-json"]); - if let Some(id) = &invocation.native_session_id { - command.args(["-S", id]); - } else if !invocation.first_message { - bail!("Kimi Code resume requires a native session ID"); - } - if let Some(model) = &invocation.model { - command.args(["-m", model]); - } - } - AgentKind::Codex | AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot => { + AgentKind::Codex | AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot | AgentKind::Kimi => { bail!("agent requires its ACP transport") } } @@ -1023,7 +994,7 @@ mod tests { } #[test] - fn builds_kimi_command_for_first_and_resume_messages() { + fn kimi_requires_native_acp_transport() { let first = Invocation { agent: AgentKind::Kimi, executable: PathBuf::from("kimi"), @@ -1035,44 +1006,13 @@ mod tests { message: "Analyze this".into(), first_message: true, }; - let command = build_command(&first, &super::prompt_text(&first)).unwrap(); - let debug = format!("{command:?}"); - assert!(debug.contains("-p")); - assert!(debug.contains("--output-format")); - assert!(debug.contains("stream-json")); - assert!(debug.contains("-m")); - assert!(debug.contains("kimi-code/k3")); - // KIMI_CODE_HOME is pinned so the child resolves the same data root. - assert!(debug.contains("KIMI_CODE_HOME")); - - let resume = Invocation { - agent: AgentKind::Kimi, - executable: PathBuf::from("kimi"), - workspace: PathBuf::from("/workspace"), - native_session_id: Some("session_kimi-1".into()), - model: None, - reasoning_effort: None, - instructions: None, - message: "Next step".into(), - first_message: false, - }; - let command = build_command(&resume, &super::prompt_text(&resume)).unwrap(); - let debug = format!("{command:?}"); - assert!(debug.contains("-S")); - assert!(debug.contains("session_kimi-1")); - - let invalid_resume = Invocation { - agent: AgentKind::Kimi, - executable: PathBuf::from("kimi"), - workspace: PathBuf::from("/workspace"), - native_session_id: None, - model: None, - reasoning_effort: None, - instructions: None, - message: "Next step".into(), - first_message: false, - }; - assert!(build_command(&invalid_resume, &super::prompt_text(&invalid_resume)).is_err()); + let error = build_command(&first, &super::prompt_text(&first)) + .unwrap_err() + .to_string(); + assert!( + error.contains("ACP transport"), + "{error}" + ); } #[test] @@ -1081,7 +1021,6 @@ mod tests { assert!(super::validate_seat_config(AgentKind::Kimi, None, None).is_ok()); } - #[test] fn kimi_home_respects_env_override_and_default() { use std::ffi::OsString; @@ -1110,23 +1049,6 @@ mod tests { ); } - #[test] - fn parses_kimi_stream_json_events() { - let version = serde_json::json!({"role":"meta","type":"system.version","version":"0.41.0"}); - let answer = serde_json::json!({"role":"assistant","content":"KIMI_OK"}); - let resume_hint = serde_json::json!({ - "role": "meta", - "type": "session.resume_hint", - "session_id": "session_kimi-1", - }); - assert_eq!(extract_session_id(&version), None); - assert_eq!(extract_answer(&answer).as_deref(), Some("KIMI_OK")); - assert_eq!( - extract_session_id(&resume_hint).as_deref(), - Some("session_kimi-1") - ); - } - #[test] fn kimi_auth_marker_requires_credentials_or_a_key() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/adapters/acp.rs b/src/adapters/acp.rs index 845e42f..fdea944 100644 --- a/src/adapters/acp.rs +++ b/src/adapters/acp.rs @@ -164,6 +164,20 @@ pub(super) async fn run_connection( cx.send_request(SetSessionConfigOptionRequest::new(session.clone(), id.to_owned(), value)).block_task().await?; } } + if invocation.agent == AgentKind::Kimi { + // `kimi acp` has no permission flags; even `kimi --auto acp` + // leaves session/new at mode=default (manual approvals). + // ACP advertises yolo as "Auto-approve everything", but + // Kimi 0.41.0 maps auto→engine permission "auto" (Never + // Ask) and yolo→"yolo" (Ask When Needed). Unattended seats + // need Never Ask. Model uses the same picker; thinking has + // no effort levels. + let mode = Some(("mode", "auto")); + let model = invocation.model.as_deref().map(|model| ("model", model)); + for (id, value) in mode.into_iter().chain(model) { + cx.send_request(SetSessionConfigOptionRequest::new(session.clone(), id.to_owned(), value)).block_task().await?; + } + } // No native work can happen before the prompt, so a session whose // configuration failed is not recorded; agents such as Copilot never // persist a session that received no prompt. diff --git a/src/adapters/acp_tests.rs b/src/adapters/acp_tests.rs index b7e2e8d..2595d9e 100644 --- a/src/adapters/acp_tests.rs +++ b/src/adapters/acp_tests.rs @@ -555,6 +555,95 @@ async fn copilot_applies_model_and_effort_through_config_options() { } } +#[tokio::test] +async fn kimi_applies_auto_mode_and_model_through_config_options() { + for (first_message, model) in [ + (true, Some("kimi-code/k3")), + (false, Some("kimi-code/kimi-for-coding")), + (true, None), + (false, None), + ] { + let mut invocation = invocation(first_message); + invocation.agent = AgentKind::Kimi; + invocation.model = model.map(str::to_owned); + super::validate_invocation(&invocation).unwrap(); + let expected = std::iter::once((json!("mode"), json!("auto"))) + .chain(model.map(|model| (json!("model"), json!(model)))) + .collect::>(); + let (client, server) = Channel::duplex(); + let configured = Arc::new(Mutex::new(Vec::new())); + let server = tokio::spawn(async move { + Agent + .builder() + .on_receive_request( + async move |request: UntypedMessage, responder, cx| { + let params = request.params(); + match request.method() { + "initialize" => responder.respond(json!({ + "protocolVersion":1, + "agentCapabilities":{ + "loadSession":true, + "sessionCapabilities":{"resume":{},"close":{}} + } + })), + "session/new" => { + assert!(first_message); + responder.respond(json!({ + "sessionId":"native-session", + "configOptions":[ + {"type":"select","id":"mode","currentValue":"default","options":[]}, + {"type":"select","id":"model","currentValue":"kimi-code/kimi-for-coding","options":[]} + ] + })) + } + "session/resume" => { + assert!(!first_message); + assert_eq!(params["sessionId"], "native-session"); + responder.respond(json!({})) + } + "session/set_config_option" => { + assert_eq!(params["sessionId"], "native-session"); + configured + .lock() + .unwrap() + .push((params["configId"].clone(), params["value"].clone())); + responder.respond(json!({"configOptions":[]})) + } + "session/prompt" => { + assert_eq!(*configured.lock().unwrap(), expected); + cx.send_notification(message("configured answer"))?; + responder.respond(json!({"stopReason":"end_turn"})) + } + "session/close" => responder.respond(json!({})), + _ => responder.respond_with_error(Error::method_not_found()), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(server) + .await + }); + let output = tokio::time::timeout( + Duration::from_secs(5), + acp::run_connection(client, invocation, true), + ) + .await + .expect("Kimi setup and configuration must terminate"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(output.error.is_none(), "{output:?}"); + assert_eq!(output.answer.as_deref(), Some("configured answer")); + assert_eq!( + output.observed_session_id.as_deref(), + Some("native-session") + ); + } +} + #[tokio::test] async fn configuration_failure_before_the_prompt_records_no_native_session() { let mut invocation = invocation(true); diff --git a/src/adapters/cli_tests.rs b/src/adapters/cli_tests.rs index d0bf06d..c0c26ac 100644 --- a/src/adapters/cli_tests.rs +++ b/src/adapters/cli_tests.rs @@ -23,7 +23,7 @@ fn invocation(directory: &Path, agent: AgentKind, script: &str) -> Invocation { #[tokio::test] async fn cli_bridge_preserves_native_identity_and_prompt() { - for agent in [AgentKind::Claude, AgentKind::Agy, AgentKind::Kimi] { + for agent in [AgentKind::Claude, AgentKind::Agy] { let directory = tempfile::tempdir().unwrap(); let mut invocation = invocation( directory.path(), @@ -57,49 +57,6 @@ printf '%s\n' '{"session_id":"native-session","result":"Final answer"}' } } -#[tokio::test] -async fn kimi_bridge_reads_identity_from_resume_hint_only() { - let directory = tempfile::tempdir().unwrap(); - let invocation = invocation( - directory.path(), - AgentKind::Kimi, - r#" -printf '%s\n' '{"role":"meta","type":"system.version","version":"0.41.0"}' -printf '%s\n' '{"role":"assistant","content":"KIMI_OK"}' -printf '%s\n' '{"role":"meta","type":"session.resume_hint","session_id":"session_kimi-1","command":"kimi -r session_kimi-1"}' -"#, - ); - let output = run(invocation).await; - assert_eq!(output.answer.as_deref(), Some("KIMI_OK")); - assert_eq!( - output.observed_session_id.as_deref(), - Some("session_kimi-1") - ); - assert!(output.error.is_none(), "{output:?}"); -} - -#[tokio::test] -async fn kimi_bridge_rejects_success_without_a_session_id() { - let directory = tempfile::tempdir().unwrap(); - // The assistant answer arrived but the trailing session.resume_hint - // never did, so the seat identity would be lost. - let invocation = invocation( - directory.path(), - AgentKind::Kimi, - r#"printf '%s\n' '{"role":"assistant","content":"KIMI_OK"}'"#, - ); - let output = run(invocation).await; - assert_eq!(output.answer, None); - assert_eq!(output.observed_session_id, None); - assert!( - output - .error - .as_deref() - .is_some_and(|error| error.contains("did not report a session id")), - "{output:?}" - ); -} - #[tokio::test] async fn native_git_uses_the_invocation_workspace() { if std::env::var_os("CONFER_TEST_NATIVE_GIT").is_none() { diff --git a/src/adapters/native.rs b/src/adapters/native.rs index 6b7706d..f10cf26 100644 --- a/src/adapters/native.rs +++ b/src/adapters/native.rs @@ -5,30 +5,12 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use super::{AdapterOutput, Invocation, acp, error_text}; use crate::types::AgentKind; +use tokio::process::Command; pub(super) async fn run(invocation: Invocation) -> AdapterOutput { let mut command = invocation.command(); - match invocation.agent { - AgentKind::Grok => { - command.args(["agent", "--no-leader", "--always-approve"]); - if let Some(model) = &invocation.model { - command.args(["--model", model]); - } - if let Some(effort) = &invocation.reasoning_effort { - command.args(["--reasoning-effort", effort]); - } - command.arg("stdio"); - } - AgentKind::Cursor => { - command - .args(["--trust", "--force", "--workspace"]) - .arg(&invocation.workspace); - command.arg("acp"); - } - AgentKind::Copilot => { - command.args(["--acp", "--allow-all"]); - } - _ => return AdapterOutput::failed("agent has no native ACP transport".into()), + if let Err(error) = apply_native_args(&invocation, &mut command) { + return AdapterOutput::failed(error); } command .stdin(Stdio::piped()) @@ -65,3 +47,67 @@ pub(super) async fn run(invocation: Invocation) -> AdapterOutput { } result } + +fn apply_native_args(invocation: &Invocation, command: &mut Command) -> Result<(), String> { + match invocation.agent { + AgentKind::Grok => { + command.args(["agent", "--no-leader", "--always-approve"]); + if let Some(model) = &invocation.model { + command.args(["--model", model]); + } + if let Some(effort) = &invocation.reasoning_effort { + command.args(["--reasoning-effort", effort]); + } + command.arg("stdio"); + } + AgentKind::Cursor => { + command + .args(["--trust", "--force", "--workspace"]) + .arg(&invocation.workspace); + command.arg("acp"); + } + AgentKind::Copilot => { + command.args(["--acp", "--allow-all"]); + } + AgentKind::Kimi => { + // Pin the data root so readiness, MCP registration, and the + // ACP child agree even when KIMI_CODE_HOME is unset or relative. + let kimi_home = super::resolve_kimi_home( + std::env::var_os("KIMI_CODE_HOME"), + dirs::home_dir(), + ) + .map_err(|error| error.to_string())?; + command.env("KIMI_CODE_HOME", &kimi_home); + command.arg("acp"); + } + _ => return Err("agent has no native ACP transport".into()), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn kimi_native_command_pins_home_and_starts_acp() { + let invocation = Invocation { + agent: AgentKind::Kimi, + executable: PathBuf::from("kimi"), + workspace: PathBuf::from("/workspace"), + native_session_id: None, + model: Some("kimi-code/k3".into()), + reasoning_effort: None, + instructions: None, + message: "Analyze this".into(), + first_message: true, + }; + let mut command = invocation.command(); + apply_native_args(&invocation, &mut command).unwrap(); + let debug = format!("{command:?}"); + assert!(debug.contains("acp"), "{debug}"); + assert!(!debug.contains("-p"), "{debug}"); + assert!(debug.contains("KIMI_CODE_HOME"), "{debug}"); + } +} diff --git a/src/cli.rs b/src/cli.rs index 9a5b978..3829b31 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -163,9 +163,8 @@ fn supported_skill_agents( // kitup's kimi-cli detection only probes the default data root; // a custom KIMI_CODE_HOME is invisible to it. Fall back to // confer's own readiness check so auto selection still lists - // Kimi Code. This fixes detection only: the install target path - // still comes from kitup's host data (see the SPEC note on - // Kitup 0.1.4's kimi-cli skill dir). + // Kimi Code. Detection is independent of the install target, + // which comes from patched kitup host data (`~/.agents/skills`). if !detected.iter().any(|id| id == "kimi-cli") && crate::adapters::check_readiness(AgentKind::Kimi).locally_ready { From 1b444ba2ed903fd82dd340681384aedeb177ab65 Mon Sep 17 00:00:00 2001 From: kina Date: Thu, 10 Sep 2026 22:14:43 +0800 Subject: [PATCH 3/5] feat(adapters): map Kimi reasoning_effort to ACP thinking Send Confer reasoning_effort as session/set_config_option thinking after mode=auto and model. Kimi-only: accept on/low/high/max; reject none/off at seat config because current models cannot disable thinking (Copilot none stays valid). A picker that does not list the level still fails before the prompt and records no session. --- docs/SPEC.md | 2 +- src/adapters.rs | 31 +++++++++-- src/adapters/acp.rs | 12 +++- src/adapters/acp_tests.rs | 113 +++++++++++++++++++++++++++++++++++--- src/mcp/rooms.rs | 2 +- 5 files changed, 141 insertions(+), 19 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 3558eac..479572f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -82,7 +82,7 @@ Room metadata writes use a short advisory lock and atomic replacement. Current w Readiness checks are local and run when a room is created, when a seat is added, and before each delivery starts. They inspect the executable and local authentication or configuration state without calling a model or checking quota. A positive result means `locally_ready`; it does not guarantee provider availability, model access, or remaining quota. -Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. Kimi Code applies `model` through ACP `session/set_config_option`; it rejects `reasoning_effort` because its thinking picker currently exposes no effort levels. +Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. Kimi Code applies `model` and, when supplied, `reasoning_effort` through ACP `session/set_config_option` (`thinking`). Seat configuration accepts only `on`, `low`, `high`, and `max`; `none` and `off` fail immediately because current Kimi models cannot disable thinking (Copilot's `none` remains valid for Copilot seats). Kimi 0.41.0's K2.7 thinking picker is always-on with no levels; K3 is always-on with `low`/`high`/`max` (`on` aliases to `high`). A locally accepted level the current picker does not list returns a native error. Kimi permission mode is always `auto` (engine Never Ask); advertised ACP `yolo` is Ask When Needed and is not used. The host may specify all, some, or none of the seats. For unspecified agents and remaining target positions, Confer cycles through locally ready supported agents, placing agent types other than the known host first. Agent types may repeat; the host's agent type remains eligible. Explicit agent choices are honored. diff --git a/src/adapters.rs b/src/adapters.rs index 0cc425f..568b19e 100644 --- a/src/adapters.rs +++ b/src/adapters.rs @@ -329,9 +329,17 @@ pub(crate) fn validate_seat_config( model: Option<&str>, effort: Option<&str>, ) -> Result<()> { - validate_effort(effort)?; - if agent == AgentKind::Kimi && effort.is_some() { - bail!("Kimi Code does not support reasoning_effort"); + if agent == AgentKind::Kimi { + // Kimi ACP thinking is always-on for current models. Copilot's + // `none` is a real Copilot level; Kimi has no off switch, so + // `none`/`off` fail here instead of at the first prompt. + if let Some(effort) = effort + && !["on", "low", "high", "max"].contains(&effort) + { + bail!("unsupported Kimi thinking '{effort}'"); + } + } else { + validate_effort(effort)?; } if agent == AgentKind::Agy && let Some(effort) = effort @@ -1016,9 +1024,20 @@ mod tests { } #[test] - fn kimi_rejects_reasoning_effort() { - assert!(super::validate_seat_config(AgentKind::Kimi, None, Some("high")).is_err()); - assert!(super::validate_seat_config(AgentKind::Kimi, None, None).is_ok()); + fn kimi_accepts_thinking_values_as_reasoning_effort() { + assert!(super::validate_seat_config(AgentKind::Copilot, None, Some("none")).is_ok()); + for effort in [None, Some("on"), Some("low"), Some("high"), Some("max")] { + assert!( + super::validate_seat_config(AgentKind::Kimi, None, effort).is_ok(), + "{effort:?}" + ); + } + for effort in ["none", "off", "medium", "minimal", "xhigh", "ultra", "not-a-level"] { + assert!( + super::validate_seat_config(AgentKind::Kimi, None, Some(effort)).is_err(), + "{effort}" + ); + } } #[test] diff --git a/src/adapters/acp.rs b/src/adapters/acp.rs index fdea944..c2f036b 100644 --- a/src/adapters/acp.rs +++ b/src/adapters/acp.rs @@ -170,11 +170,17 @@ pub(super) async fn run_connection( // ACP advertises yolo as "Auto-approve everything", but // Kimi 0.41.0 maps auto→engine permission "auto" (Never // Ask) and yolo→"yolo" (Ask When Needed). Unattended seats - // need Never Ask. Model uses the same picker; thinking has - // no effort levels. + // need Never Ask. Model uses the same picker. Confer's + // reasoning_effort maps to thinking (`on`/`low`/`high`/`max`); + // `none`/`off` are rejected at seat config. A level the + // current picker does not list fails here, before the prompt. let mode = Some(("mode", "auto")); let model = invocation.model.as_deref().map(|model| ("model", model)); - for (id, value) in mode.into_iter().chain(model) { + let thinking = invocation + .reasoning_effort + .as_deref() + .map(|effort| ("thinking", effort)); + for (id, value) in mode.into_iter().chain(model).chain(thinking) { cx.send_request(SetSessionConfigOptionRequest::new(session.clone(), id.to_owned(), value)).block_task().await?; } } diff --git a/src/adapters/acp_tests.rs b/src/adapters/acp_tests.rs index 2595d9e..84f5200 100644 --- a/src/adapters/acp_tests.rs +++ b/src/adapters/acp_tests.rs @@ -556,28 +556,38 @@ async fn copilot_applies_model_and_effort_through_config_options() { } #[tokio::test] -async fn kimi_applies_auto_mode_and_model_through_config_options() { - for (first_message, model) in [ - (true, Some("kimi-code/k3")), - (false, Some("kimi-code/kimi-for-coding")), - (true, None), - (false, None), +async fn kimi_sends_auto_mode_then_model_then_thinking() { + // Wire contract only: Confer always sets mode=auto (Never Ask), never + // advertised yolo. reasoning_effort maps to thinking as-is. + // Kimi 0.41.0 k2.7 thinking is on-only; k3 is low/high/max with on→high. + for (first_message, model, effort, thinking) in [ + (true, Some("kimi-code/k3"), None, None), + (true, Some("kimi-code/k3"), Some("low"), Some("low")), + (true, Some("kimi-code/k3"), Some("high"), Some("high")), + (true, Some("kimi-code/k3"), Some("max"), Some("max")), + (true, Some("kimi-code/k3"), Some("on"), Some("on")), + (false, Some("kimi-code/kimi-for-coding"), Some("on"), Some("on")), ] { let mut invocation = invocation(first_message); invocation.agent = AgentKind::Kimi; invocation.model = model.map(str::to_owned); + invocation.reasoning_effort = effort.map(str::to_owned); super::validate_invocation(&invocation).unwrap(); let expected = std::iter::once((json!("mode"), json!("auto"))) .chain(model.map(|model| (json!("model"), json!(model)))) + .chain(thinking.map(|thinking| (json!("thinking"), json!(thinking)))) .collect::>(); let (client, server) = Channel::duplex(); let configured = Arc::new(Mutex::new(Vec::new())); + let methods = Arc::new(Mutex::new(Vec::new())); + let seen_methods = methods.clone(); let server = tokio::spawn(async move { Agent .builder() .on_receive_request( async move |request: UntypedMessage, responder, cx| { let params = request.params(); + methods.lock().unwrap().push(request.method().to_owned()); match request.method() { "initialize" => responder.respond(json!({ "protocolVersion":1, @@ -591,8 +601,8 @@ async fn kimi_applies_auto_mode_and_model_through_config_options() { responder.respond(json!({ "sessionId":"native-session", "configOptions":[ - {"type":"select","id":"mode","currentValue":"default","options":[]}, - {"type":"select","id":"model","currentValue":"kimi-code/kimi-for-coding","options":[]} + {"type":"select","id":"thinking","currentValue":"on","options":[{"value":"on"}]}, + {"type":"select","id":"mode","currentValue":"default"} ] })) } @@ -601,8 +611,11 @@ async fn kimi_applies_auto_mode_and_model_through_config_options() { assert_eq!(params["sessionId"], "native-session"); responder.respond(json!({})) } + "session/load" => responder.respond_with_error(Error::method_not_found()), "session/set_config_option" => { assert_eq!(params["sessionId"], "native-session"); + assert_ne!(params["configId"], json!("yolo")); + assert_ne!(params["value"], json!("yolo")); configured .lock() .unwrap() @@ -611,6 +624,7 @@ async fn kimi_applies_auto_mode_and_model_through_config_options() { } "session/prompt" => { assert_eq!(*configured.lock().unwrap(), expected); + assert_eq!(expected[0], (json!("mode"), json!("auto"))); cx.send_notification(message("configured answer"))?; responder.respond(json!({"stopReason":"end_turn"})) } @@ -641,9 +655,92 @@ async fn kimi_applies_auto_mode_and_model_through_config_options() { output.observed_session_id.as_deref(), Some("native-session") ); + let seen = seen_methods.lock().unwrap().clone(); + if first_message { + assert!(seen.iter().any(|m| m == "session/new"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "session/resume"), "{seen:?}"); + } else { + assert!(seen.iter().any(|m| m == "session/resume"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "session/new"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "session/load"), "{seen:?}"); + } } } +#[tokio::test] +async fn kimi_unknown_thinking_fails_before_the_prompt() { + // `high` is locally valid (k3) but k2.7's picker is on-only. + let mut invocation = invocation(true); + invocation.agent = AgentKind::Kimi; + invocation.model = Some("kimi-code/kimi-for-coding".into()); + invocation.reasoning_effort = Some("high".into()); + super::validate_invocation(&invocation).unwrap(); + let (client, server) = Channel::duplex(); + let prompted = Arc::new(Mutex::new(false)); + let server_prompted = prompted.clone(); + let server = tokio::spawn(async move { + Agent + .builder() + .on_receive_request( + async move |request: UntypedMessage, responder, _cx| match request.method() { + "initialize" => responder.respond(json!({ + "protocolVersion":1, + "agentCapabilities":{ + "loadSession":true, + "sessionCapabilities":{"resume":{},"close":{}} + } + })), + "session/new" => responder.respond(json!({ + "sessionId":"unprompted-session", + "configOptions":[ + {"type":"select","id":"thinking","currentValue":"on","options":[{"value":"on"}]} + ] + })), + "session/set_config_option" => { + if request.params()["configId"] == "thinking" { + responder.respond_with_error(Error::new( + -32602, + "Invalid params: Unknown thinking value: high", + )) + } else { + responder.respond(json!({"configOptions":[]})) + } + } + "session/prompt" => { + *server_prompted.lock().unwrap() = true; + responder.respond(json!({"stopReason":"end_turn"})) + } + _ => responder.respond_with_error(Error::method_not_found()), + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(server) + .await + }); + let output = tokio::time::timeout( + Duration::from_secs(5), + acp::run_connection(client, invocation, true), + ) + .await + .expect("rejected thinking must terminate the connection"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(!*prompted.lock().unwrap()); + assert!(output.observed_session_id.is_none(), "{output:?}"); + assert!(output.answer.is_none()); + assert!( + output + .error + .as_deref() + .is_some_and(|error| error.contains("Unknown thinking value")), + "{output:?}" + ); +} + #[tokio::test] async fn configuration_failure_before_the_prompt_records_no_native_session() { let mut invocation = invocation(true); diff --git a/src/mcp/rooms.rs b/src/mcp/rooms.rs index 8915c04..9abd461 100644 --- a/src/mcp/rooms.rs +++ b/src/mcp/rooms.rs @@ -426,7 +426,7 @@ mod tests { (AgentKind::Cursor, Some("model[effort"), None), (AgentKind::Claude, None, Some("invalid")), (AgentKind::Agy, None, Some("xhigh")), - (AgentKind::Kimi, None, Some("high")), + (AgentKind::Kimi, None, Some("none")), ] { let request = SeatSpecInput { agent: Some(agent.id().into()), From f6e5a759904c89045bd7a6ff5f0fd8829155de85 Mon Sep 17 00:00:00 2001 From: kina Date: Fri, 11 Sep 2026 01:18:24 +0800 Subject: [PATCH 4/5] chore: patch kitup from lathe-cli main Point the crates.io kitup override at lathe-cli/kitup main after #41 instead of delete-cloud/kitup. Note that Kimi Code seats use thinking levels on/low/high/max. --- Cargo.lock | 2 +- Cargo.toml | 7 ++++--- docs/SPEC.md | 2 +- skills/confer/SKILL.md | 2 ++ src/cli.rs | 4 +++- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dcc7ae0..9daa523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "kitup" version = "0.1.4" -source = "git+https://github.com/delete-cloud/kitup.git?rev=716a74480300582b7044e2ae745cc42974cf001f#716a74480300582b7044e2ae745cc42974cf001f" +source = "git+https://github.com/lathe-cli/kitup.git?branch=main#3cdf8974e49d8b15ce6709779778dd5da73cc4d4" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 4700d24..1fa4693 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,7 +55,8 @@ tag-message = "v{{version}}" tag-name = "v{{version}}" pre-release-hook = ["scripts/regenerate-changelog", "{{version}}"] -# crates.io kitup 0.1.4 installs kimi-cli skills into ~/.config/agents/skills, -# which Kimi Code does not scan. Remove after upstream publishes that host-data fix. +# crates.io kitup 0.1.4 still prefers ~/.config/agents/skills for host id +# kimi-cli; Kimi Code does not scan that directory. lathe-cli/kitup#41 +# landed the host-data fix on main. Remove this patch after the next crates.io release. [patch.crates-io] -kitup = { git = "https://github.com/delete-cloud/kitup.git", rev = "716a74480300582b7044e2ae745cc42974cf001f" } +kitup = { git = "https://github.com/lathe-cli/kitup.git", branch = "main" } diff --git a/docs/SPEC.md b/docs/SPEC.md index 479572f..2176e1d 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -167,7 +167,7 @@ confer skill install [--scope user|project] [--agent ]... [--dry-run] [--yes MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code. -`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. Crates.io Kitup 0.1.4's `kimi-cli` host data prefers `~/.config/agents/skills`, which Kimi Code does not scan (its user skill roots are `$KIMI_CODE_HOME/skills` and `~/.agents/skills`). Confer patches Kitup to `delete-cloud/kitup` (`716a744`) so a Kimi user-scope install lands in `~/.agents/skills`. Existing installs already written under `~/.config/agents/skills` are not migrated. Remove the patch after upstream publishes that host-data fix. +`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. Crates.io Kitup 0.1.4's `kimi-cli` host adapter (the kitup id for **Kimi Code**, not the legacy kimi-cli product) prefers `~/.config/agents/skills`, which Kimi Code does not scan (its user skill roots are `$KIMI_CODE_HOME/skills` and `~/.agents/skills`). That host-data fix is on kitup main (`lathe-cli/kitup#41`); Confer patches crates.io kitup to `lathe-cli/kitup` `main` so a Kimi Code user-scope install lands in `~/.agents/skills`. Existing installs already written under `~/.config/agents/skills` are not migrated. Remove the patch after the next Kitup crates.io release. `confer mcp install` follows each host’s supported registration mechanism. Repeated installation updates the Confer-owned registration without deleting unrelated MCP entries. Uninstall removes only the `confer` entry. diff --git a/skills/confer/SKILL.md b/skills/confer/SKILL.md index b6a03e7..154f9b2 100644 --- a/skills/confer/SKILL.md +++ b/skills/confer/SKILL.md @@ -23,6 +23,8 @@ Choose the number of execution seats from the task; there is no default count. S When the host is Kimi Code, pass `"host_agent": "kimi"` on create_room: confer's automatic host detection recognizes Claude, Codex, Cursor, Grok, and Copilot session markers, while Kimi Code and Antigravity expose no equivalent marker, so auto-detection would mislabel those hosts. Pass it explicitly whenever auto-detection may not identify the host, not only for Kimi. +Kimi Code seats' `reasoning_effort` is thinking (`on` / `low` / `high` / `max`). + ## Keep Seats Private For independent work, send the same prompt separately or by multicast and wait for every required answer before sharing any of them. diff --git a/src/cli.rs b/src/cli.rs index 3829b31..807c132 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -164,7 +164,9 @@ fn supported_skill_agents( // a custom KIMI_CODE_HOME is invisible to it. Fall back to // confer's own readiness check so auto selection still lists // Kimi Code. Detection is independent of the install target, - // which comes from patched kitup host data (`~/.agents/skills`). + // which comes from kitup host data (`~/.agents/skills` after + // lathe-cli/kitup#41; Confer still patches crates.io 0.1.4 to + // kitup main). if !detected.iter().any(|id| id == "kimi-cli") && crate::adapters::check_readiness(AgentKind::Kimi).locally_ready { From fc8d17325e89541d4174443e1dd434005356c557 Mon Sep 17 00:00:00 2001 From: kina0630 Date: Fri, 11 Sep 2026 15:32:26 +0800 Subject: [PATCH 5/5] docs: keep the Kimi ACP contract brief SPEC and nearby comments now match Copilot's sentence shape: no Kimi version, picker, or kitup host-path essays. Those details belong in the upstream PR, not the contract. --- Cargo.toml | 4 +--- docs/SPEC.md | 8 ++++---- src/adapters.rs | 4 +--- src/adapters/acp.rs | 11 ++--------- src/adapters/acp_tests.rs | 6 ++---- src/cli.rs | 5 +---- 6 files changed, 11 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1fa4693..16d6878 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,8 +55,6 @@ tag-message = "v{{version}}" tag-name = "v{{version}}" pre-release-hook = ["scripts/regenerate-changelog", "{{version}}"] -# crates.io kitup 0.1.4 still prefers ~/.config/agents/skills for host id -# kimi-cli; Kimi Code does not scan that directory. lathe-cli/kitup#41 -# landed the host-data fix on main. Remove this patch after the next crates.io release. +# Remove after the next kitup crates.io release (lathe-cli/kitup#41). [patch.crates-io] kitup = { git = "https://github.com/lathe-cli/kitup.git", branch = "main" } diff --git a/docs/SPEC.md b/docs/SPEC.md index 2176e1d..e225d54 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -82,7 +82,7 @@ Room metadata writes use a short advisory lock and atomic replacement. Current w Readiness checks are local and run when a room is created, when a seat is added, and before each delivery starts. They inspect the executable and local authentication or configuration state without calling a model or checking quota. A positive result means `locally_ready`; it does not guarantee provider availability, model access, or remaining quota. -Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. Kimi Code applies `model` and, when supplied, `reasoning_effort` through ACP `session/set_config_option` (`thinking`). Seat configuration accepts only `on`, `low`, `high`, and `max`; `none` and `off` fail immediately because current Kimi models cannot disable thinking (Copilot's `none` remains valid for Copilot seats). Kimi 0.41.0's K2.7 thinking picker is always-on with no levels; K3 is always-on with `low`/`high`/`max` (`on` aliases to `high`). A locally accepted level the current picker does not list returns a native error. Kimi permission mode is always `auto` (engine Never Ask); advertised ACP `yolo` is Ask When Needed and is not used. +Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. Kimi Code applies `model` and then `reasoning_effort` (`thinking`) through ACP session configuration options; a model or effort its session does not offer returns a native error. The host may specify all, some, or none of the seats. For unspecified agents and remaining target positions, Confer cycles through locally ready supported agents, placing agent types other than the known host first. Agent types may repeat; the host's agent type remains eligible. Explicit agent choices are honored. @@ -167,7 +167,7 @@ confer skill install [--scope user|project] [--agent ]... [--dry-run] [--yes MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code. -`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. Crates.io Kitup 0.1.4's `kimi-cli` host adapter (the kitup id for **Kimi Code**, not the legacy kimi-cli product) prefers `~/.config/agents/skills`, which Kimi Code does not scan (its user skill roots are `$KIMI_CODE_HOME/skills` and `~/.agents/skills`). That host-data fix is on kitup main (`lathe-cli/kitup#41`); Confer patches crates.io kitup to `lathe-cli/kitup` `main` so a Kimi Code user-scope install lands in `~/.agents/skills`. Existing installs already written under `~/.config/agents/skills` are not migrated. Remove the patch after the next Kitup crates.io release. +`confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. `confer mcp install` follows each host’s supported registration mechanism. Repeated installation updates the Confer-owned registration without deleting unrelated MCP entries. Uninstall removes only the `confer` entry. @@ -188,7 +188,7 @@ Every adapter must: Confer owns the FIFO Queue above every adapter. Each queued delivery opens one ACP connection, runs one native agent process, and resumes the seat's recorded native session when one exists. Session history replay is excluded from the current answer. After a terminal response, Confer closes the connection and reaps its child; a child that remains alive after three seconds is terminated. There is no idle process pool. -Cursor seats use its ACP session store. Old headless Cursor session IDs are not migrated and require new seats. Copilot seats use its native session store through ACP `session/load`; replayed history is excluded from the current answer. Kimi Code seats use native ACP `session/resume` (it also advertises `loadSession`; Confer prefers resume so history is not replayed into the current answer). Other native session stores are not rewritten. An observed session ID remains available when the prompt fails; a missing or stale ID never triggers silent replacement. A native ACP session whose model or effort configuration fails before its first prompt is not recorded, because no native work has started; the next message to that seat starts a new native session. +Cursor seats use its ACP session store. Old headless Cursor session IDs are not migrated and require new seats. Copilot seats use its native session store through ACP `session/load`; replayed history is excluded from the current answer. Kimi Code seats use native ACP `session/resume`. Other native session stores are not rewritten. An observed session ID remains available when the prompt fails; a missing or stale ID never triggers silent replacement. A native ACP session whose model or effort configuration fails before its first prompt is not recorded, because no native work has started; the next message to that seat starts a new native session. Model and reasoning fields are requests to the native CLI. An unsupported value must produce a clear adapter error rather than silently selecting another model. @@ -196,7 +196,7 @@ Model and reasoning fields are requests to the native CLI. An unsupported value Confer uses the room workspace as each child process working directory. It does not create filesystem isolation. Independent seats may therefore read or modify the same files even when their messages are isolated. -Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; Copilot receives `--allow-all`; and Kimi Code receives ACP `session/set_config_option` `mode=auto`. Kimi 0.41.0 maps that ACP id to engine Never Ask (`permission: "auto"`); its advertised `yolo` label is Ask When Needed and is not used. `kimi acp` has no permission flags, and even `kimi --auto acp` leaves `session/new` at `mode=default`. A Kimi Code seat remains subject to the user's static deny rules. Other seats run with the authority of the local Confer process and without sandbox isolation. Explicit task instructions remain the only limit on what a seat is asked to do. +Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; Copilot receives `--allow-all`; and Kimi Code receives ACP `mode=auto`. Seats therefore run with the authority of the local Confer process and without sandbox isolation. Explicit task instructions remain the only limit on what a seat is asked to do. ## Errors diff --git a/src/adapters.rs b/src/adapters.rs index 568b19e..f9230d3 100644 --- a/src/adapters.rs +++ b/src/adapters.rs @@ -330,9 +330,7 @@ pub(crate) fn validate_seat_config( effort: Option<&str>, ) -> Result<()> { if agent == AgentKind::Kimi { - // Kimi ACP thinking is always-on for current models. Copilot's - // `none` is a real Copilot level; Kimi has no off switch, so - // `none`/`off` fail here instead of at the first prompt. + // Kimi thinking is `on`/`low`/`high`/`max`; `none`/`off` fail here. if let Some(effort) = effort && !["on", "low", "high", "max"].contains(&effort) { diff --git a/src/adapters/acp.rs b/src/adapters/acp.rs index c2f036b..6669c8f 100644 --- a/src/adapters/acp.rs +++ b/src/adapters/acp.rs @@ -165,15 +165,8 @@ pub(super) async fn run_connection( } } if invocation.agent == AgentKind::Kimi { - // `kimi acp` has no permission flags; even `kimi --auto acp` - // leaves session/new at mode=default (manual approvals). - // ACP advertises yolo as "Auto-approve everything", but - // Kimi 0.41.0 maps auto→engine permission "auto" (Never - // Ask) and yolo→"yolo" (Ask When Needed). Unattended seats - // need Never Ask. Model uses the same picker. Confer's - // reasoning_effort maps to thinking (`on`/`low`/`high`/`max`); - // `none`/`off` are rejected at seat config. A level the - // current picker does not list fails here, before the prompt. + // Unattended seats use ACP mode=auto. Model and thinking use + // the same config-option channel. let mode = Some(("mode", "auto")); let model = invocation.model.as_deref().map(|model| ("model", model)); let thinking = invocation diff --git a/src/adapters/acp_tests.rs b/src/adapters/acp_tests.rs index 84f5200..38bbdd2 100644 --- a/src/adapters/acp_tests.rs +++ b/src/adapters/acp_tests.rs @@ -557,9 +557,7 @@ async fn copilot_applies_model_and_effort_through_config_options() { #[tokio::test] async fn kimi_sends_auto_mode_then_model_then_thinking() { - // Wire contract only: Confer always sets mode=auto (Never Ask), never - // advertised yolo. reasoning_effort maps to thinking as-is. - // Kimi 0.41.0 k2.7 thinking is on-only; k3 is low/high/max with on→high. + // Wire contract: mode=auto, then model, then thinking. for (first_message, model, effort, thinking) in [ (true, Some("kimi-code/k3"), None, None), (true, Some("kimi-code/k3"), Some("low"), Some("low")), @@ -669,7 +667,7 @@ async fn kimi_sends_auto_mode_then_model_then_thinking() { #[tokio::test] async fn kimi_unknown_thinking_fails_before_the_prompt() { - // `high` is locally valid (k3) but k2.7's picker is on-only. + // A locally accepted level the session does not offer fails here. let mut invocation = invocation(true); invocation.agent = AgentKind::Kimi; invocation.model = Some("kimi-code/kimi-for-coding".into()); diff --git a/src/cli.rs b/src/cli.rs index 807c132..7f152a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -163,10 +163,7 @@ fn supported_skill_agents( // kitup's kimi-cli detection only probes the default data root; // a custom KIMI_CODE_HOME is invisible to it. Fall back to // confer's own readiness check so auto selection still lists - // Kimi Code. Detection is independent of the install target, - // which comes from kitup host data (`~/.agents/skills` after - // lathe-cli/kitup#41; Confer still patches crates.io 0.1.4 to - // kitup main). + // Kimi Code. if !detected.iter().any(|id| id == "kimi-cli") && crate::adapters::check_readiness(AgentKind::Kimi).locally_ready {