Compare commits

...
10 Commits
Author SHA1 Message Date
KimiSwitch Dev 5138a295e0 chore(release): bump to v0.7.12 — 适配 kimi-code 0.40.1(flag 清单/优先级/危险命令守卫)+ WebUI 竞态修复 + models-dev 7495/212
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-09-03 10:12:17 +08:00
KimiSwitch Dev a02179c9da chore(data): 同步 models-dev 最新快照(7495 模型 / 212 供应商) 2026-09-03 10:11:10 +08:00
KimiSwitch Dev 86a128efea feat: 适配 kimi-code 0.40.1 —— flag 清单/优先级语义/危险命令守卫
- flag 清单 9→10:新增 file_history、search_worker;secondary-model
  0.40.1 起默认开启(defaultEnabled)
- env 探测补齐为 12 项元组(10 flag + master + legacy),修正此前
  tower/subagent_fork/wait_for/auto_session_title 四项漏探
- 优先级语义跟随上游 flagService:单 flag env > [experimental] 显式值
  > master env(仅强制开)> 默认。master env 不再锁定 UI;setExperimentalFlag
  增加 explicitFalse 路径,关闭默认开/master 强开的 flag 时写字面 false
- 权限设置新增 dangerous_command_guard 开关(默认开,写 [permission]
  蛇形键;env KIMI_CODE_DANGEROUS_COMMAND_GUARD 运行时覆盖 config)
- 新增 11 个测试(注册表/写语义/守卫读写)
2026-09-03 10:10:52 +08:00
KimiSwitch Dev 6a7c8f5393 fix(webui): 修复首次打开慢时多次点击导致的竞态报错
open_kimi_web_embedded 加 launch_lock 串行化并发调用,拿锁后重查单例;
oneshot 在锁内等待主线程建窗完成,封死命令返回后的残留空档。
spawned_by_me 门控失败路径的 server 清理,复用的 server 不再被误杀;
spawn 前用 try_wait 检查已有子进程避免覆盖泄漏。前端两个打开按钮加
busy 态防重入(打开中...)。
2026-09-03 10:10:40 +08:00
KimiSwitch Dev 8b597496b3 chore(release): bump to v0.7.11 — 适配 kimi-code 0.39.1(thinking keep/loop_control/flags)+ 归档元数据化 + 子代理池修复 + models-dev 7482/207
Release / Version consistency (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
2026-08-29 11:15:40 +08:00
KimiSwitch Dev 8ce3f290ed chore(data): 同步 models-dev 最新快照(7482 模型 / 207 供应商) 2026-08-29 11:14:11 +08:00
KimiSwitch Dev 8639188968 fix(sessions): 归档迁移为 state.json 元数据(archived/archivedAt,与上游 v2 setArchived 逐字段一致),不再物理搬目录;遗留 .kcd-archive 目录兼容识别与恢复;归档不再动 session_index 2026-08-29 11:14:01 +08:00
KimiSwitch Dev 45ec073e6f fix(config): 适配 kimi-code 0.39.1——thinking keep 写字符串 off(布尔会整节作废)、存量布尔归一化;loop_control 默认只写 v2 键(legacy flag 才双写);effort 移除 max 档;实验 flags 补 remote-control、wait_for/minidb 标默认开启 2026-08-29 11:13:58 +08:00
KimiSwitch Dev 7435198fcc chore(release): bump to v0.7.10 — 配置写回保护(导入基线 + 未知供应商类型原样保留)
Release / Version consistency (push) Canceled after 0s
Release / Attach macOS install script (push) Canceled after 0s
Release / Build (macos-latest) (push) Canceled after 0s
Release / Build (ubuntu-latest) (push) Canceled after 0s
Release / Build (windows-latest) (push) Canceled after 0s
2026-08-29 00:10:52 +08:00
KimiSwitch Dev 67db19eda4 fix(config): 保存不再误删 CLI 后加的顶层配置节 + 未知供应商类型原样保留
- 导出 stale-key 清理增加导入基线(imported_section_keys):CLI 在导入后
  新增的顶层节(kimi-code 0.38+ 的 [task]/[swarm]/[cron] 等)在保存时
  原样保留;用户在 UI 显式删除的节仍被移除;空基线(SQLite 快照恢复)
  不删任何键(安全退化)
- ProviderType 增加 Unknown(String) 变体,手写 serde 保持纯字符串线格式,
  上游新增的 type 值往返不再被改写为 kimi;SQLite 快照恢复路径同步修复;
  模型发现对未知类型返回明确错误,不再误用 KIMI_API_KEY
- 新增 3 个往返测试(106 个 Rust 测试全绿),前端 tsc/vitest 通过
2026-08-29 00:09:55 +08:00
26 changed files with 12987 additions and 7063 deletions

No files matched your search

+8 -2
View File
@@ -123,7 +123,7 @@
![会话管理](docs/screenshots/sessions.png) ![会话管理](docs/screenshots/sessions.png)
按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选;流式逐行预览(20MB 字节上限,500 字符折叠);归档/取消归档/批量删除。归档写入 `state.json` 元数据(与 CLI v2 一致),旧版本物理归档目录 `.kcd-archive/` 仍可识别与恢复。
## 架构总览 ## 架构总览
@@ -239,7 +239,7 @@
- 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选 - 按工作区隔离浏览 Kimi Code 会话,支持活跃/已归档/全部筛选
- 流式逐行预览(20MB 字节上限,500 字符折叠可展开) - 流式逐行预览(20MB 字节上限,500 字符折叠可展开)
- 归档 / 取消归档 / 批量删除 - 归档 / 取消归档 / 批量删除(归档写入 `state.json` 元数据,与 CLI v2 一致;旧版本物理归档目录 `.kcd-archive/` 仍可识别与恢复)
- 早期版本的"闪崩"已通过流式读取 + 限制解决 - 早期版本的"闪崩"已通过流式读取 + 限制解决
### 设置面板 ### 设置面板
@@ -543,6 +543,12 @@ A: 需要分两步:
**Q: 切换会覆盖其他供应商吗?** **Q: 切换会覆盖其他供应商吗?**
A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。 A: 不会。Kimi Code 的 `config.toml` 始终写入全部供应商,仅 `default_model` 决定生效项。这与 CLI 原生 `/provider` 行为一致。
**Q: 在 Kimi Code CLI 里新增的顶层配置节(如 `[task]`/`[swarm]`)会被保存时删掉吗?**
A: 不会(v0.7.10 起)。保存时以加载配置那一刻的顶层键为基线,CLI 后加的节原样保留;只有在本应用中显式删除的节才会从 `config.toml` 移除。
**Q: 遇到 Kimi Code 新增的供应商类型(`type` 值)会怎样?**
A: 原样保留,不再改写为 `kimi`。编辑界面会显示原始类型值(标注"未知类型");该供应商不支持的操作(如模型发现)会返回明确错误提示。
**Q: 新增/激活的供应商位置怎么变?** **Q: 新增/激活的供应商位置怎么变?**
A: 自动提升到列表最前,UI 立即反映。 A: 自动提升到列表最前,UI 立即反映。
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "kimiswitch", "name": "kimiswitch",
"private": true, "private": true,
"version": "0.7.9", "version": "0.7.12",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+8
View File
@@ -0,0 +1,8 @@
## v0.7.10
### 配置写回保护(适配 kimi-code 0.38+ 配置演进)
- **修复:保存不再误删 CLI 新增的顶层配置节**。此前在 Kimi Switch 加载配置后,若 Kimi Code CLI 往 `config.toml` 写入了新的顶层节(如 0.38+ 的 `[task]` / `[swarm]` / `[cron]` / `[tools]` / `[identity]` / `[token_counting]` 等),回到 Kimi Switch 保存时会把这些节**静默删除**。现在以导入时的顶层键为基线:CLI 后加的节原样保留;在界面中显式删除的节仍会被移除;老数据(无基线,如 SQLite 快照恢复)不删任何键
- **修复:未知供应商类型不再被改写**。`config.toml` 中 Kimi Code 新增的供应商 `type` 值往返读写后不再被改写成 `kimi`,原样保留;编辑器 API 格式下拉框显示原始值(标注"未知类型");模型发现等不支持的操作返回明确错误提示,不再误用 `KIMI_API_KEY`
- SQLite 快照恢复路径同步修复(此前从快照恢复同样会丢失未知类型)
- 新增 3 个配置往返回归测试(Rust 测试 106 项全绿,前端 tsc / vitest 通过)
+22
View File
@@ -0,0 +1,22 @@
## v0.7.11
### 适配 kimi-code 0.39.1
- **修复:`[thinking]` 保留开关不再破坏思考配置**。此前在 UI 关闭"保留思考内容"会写入布尔 `keep = false`,而上游两个引擎都只接受字符串——v2 会把整个 `[thinking]` 节校验失败并丢弃。现改为写 `keep = "off"`,存量配置里的布尔值加载时自动归一化
- **修复:`loop_control` 不再触发上游废弃告警**。默认只写新键 `max_attempts_per_step` 并清理存量旧键 `max_retries_per_step`;仅检测到 `KIMI_CODE_LEGACY_FLAG`(v1 引擎)时才双写
- **思考强度移除 `max` 档**(上游已自动迁移为 `high`),存量配置显示归一
- **实验开关同步上游全集**:新增 remote-control;`wait_for` 与 persistence_minidb_readmodel 已在上游转正默认开启,UI 会标注"默认开启"(显式关闭优先)
### 会话归档与上游 v2 对齐
- **归档不再搬动文件**:改为在会话 `state.json` 写入 `archived`/`archivedAt` 元数据(与 CLI v2 的 `setArchived` 逐字段一致),CLI 侧与 Kimi Switch 侧归档状态从此互通
- 旧版本物理归档的会话(`.kcd-archive/` 目录)仍可识别为已归档并正常恢复
- 归档不再改写 `session_index.jsonl`(删除会话的行为不变)
### 子代理模型池修复
- **修复:模型池无法添加第二个条目**。从"仅默认模型"的隐式单条目形态添加条目时,默认模型现在会自动物化进池,不再被"默认模型不在模型池中"的校验拦截
### 模型数据同步
- models.dev 快照刷新至 **7482 个模型 / 207 个供应商**
+16
View File
@@ -0,0 +1,16 @@
# KimiSwitch v0.7.12
## 适配 kimi-code 0.40.1
- **实验 flag 清单更新(9→10)**:新增 `file_history`(轮级文件历史快照)、`search_worker`(kap-server 全局搜索 worker);`secondary-model`(子代理次主力模型)自 kimi-code 0.40.1 起默认开启,设置页显示"默认开启"徽章
- **flag 优先级语义修正**:跟随上游新优先级——单 flag 环境变量 > `[experimental]` 显式配置 > 总开关环境变量(仅强制开)> 默认值。总开关 `KIMI_CODE_EXPERIMENTAL_FLAG` 不再锁定全部开关,显式写入的 false/true 优先生效
- **环境变量探测补齐**:`tower` / `subagent_fork` / `wait_for` / `auto_session_title` 等此前漏探测的环境变量现在会正确显示"被环境变量锁定"状态
- **新增危险命令守卫开关**:权限设置页可配置 `[permission] dangerous_command_guard`(默认开启)——Auto 模式直接拒绝 `rm -rf` / `shutdown` 等危险命令,其它模式强制询问,关闭后回归旧行为
## 修复
- **WebUI 打开竞态**:首次打开较慢时多次点击"在应用内打开"不再报错——并发调用串行化 + 后到的点击自动聚焦已有窗口;失败路径不再误杀正在使用的 server;前端按钮增加"打开中..."防重入态
## 数据
- models-dev 快照更新至 7495 模型 / 212 供应商
+1 -1
View File
@@ -1978,7 +1978,7 @@ dependencies = [
[[package]] [[package]]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.9" version = "0.7.12"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "kimiswitch" name = "kimiswitch"
version = "0.7.9" version = "0.7.12"
description = "Kimi Switch - model config manager" description = "Kimi Switch - model config manager"
authors = ["codingplan.site"] authors = ["codingplan.site"]
edition = "2021" edition = "2021"
+141 -67
View File
@@ -244,6 +244,7 @@ fn build_active_config(config: &Config) -> Config {
providers, providers,
models, models,
raw_other: config.raw_other.clone(), raw_other: config.raw_other.clone(),
imported_section_keys: config.imported_section_keys.clone(),
} }
} }
@@ -277,6 +278,9 @@ pub async fn list_provider_models(provider: Provider) -> Result<Vec<DiscoveredMo
ProviderType::Anthropic => fetch_anthropic_models(&base, &api_key).await, ProviderType::Anthropic => fetch_anthropic_models(&base, &api_key).await,
ProviderType::GoogleGenai => fetch_google_genai_models(&base, &api_key).await, ProviderType::GoogleGenai => fetch_google_genai_models(&base, &api_key).await,
ProviderType::Vertexai => Err("Vertex AI model discovery requires GCP project/location configuration and is not yet supported".to_string()), ProviderType::Vertexai => Err("Vertex AI model discovery requires GCP project/location configuration and is not yet supported".to_string()),
ProviderType::Unknown(s) => Err(format!(
"unsupported provider type for model discovery: {s}"
)),
} }
} }
@@ -338,8 +342,12 @@ fn resolve_api_key(provider: &Provider) -> Option<String> {
return Some(key.clone()); return Some(key.clone());
} }
} }
let env_key = expected_api_key_key(&provider.provider_type); if let Some(env_key) = expected_api_key_key(&provider.provider_type) {
provider.env.get(env_key).filter(|s| !s.is_empty()).cloned() if let Some(key) = provider.env.get(env_key).filter(|s| !s.is_empty()) {
return Some(key.clone());
}
}
None
} }
fn resolve_base_url(provider: &Provider) -> String { fn resolve_base_url(provider: &Provider) -> String {
@@ -356,13 +364,16 @@ fn resolve_base_url(provider: &Provider) -> String {
}) })
} }
fn expected_api_key_key(provider_type: &ProviderType) -> &'static str { /// Well-known env var fallback for the API key per provider type; `None`
/// for unknown types (no well-known variable name to look up).
fn expected_api_key_key(provider_type: &ProviderType) -> Option<&'static str> {
match provider_type { match provider_type {
ProviderType::Kimi => "KIMI_API_KEY", ProviderType::Kimi => Some("KIMI_API_KEY"),
ProviderType::Anthropic => "ANTHROPIC_API_KEY", ProviderType::Anthropic => Some("ANTHROPIC_API_KEY"),
ProviderType::Openai | ProviderType::OpenaiResponses => "OPENAI_API_KEY", ProviderType::Openai | ProviderType::OpenaiResponses => Some("OPENAI_API_KEY"),
ProviderType::GoogleGenai => "GOOGLE_API_KEY", ProviderType::GoogleGenai => Some("GOOGLE_API_KEY"),
ProviderType::Vertexai => "VERTEXAI_API_KEY", ProviderType::Vertexai => Some("VERTEXAI_API_KEY"),
ProviderType::Unknown(_) => None,
} }
} }
@@ -650,9 +661,8 @@ pub async fn query_provider_usage(
.clone() .clone()
.filter(|s| !s.trim().is_empty()) .filter(|s| !s.trim().is_empty())
.or_else(|| { .or_else(|| {
provider expected_api_key_key(&provider.provider_type)
.env .and_then(|env_key| provider.env.get(env_key))
.get(expected_api_key_key(&provider.provider_type))
.cloned() .cloned()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
}); });
@@ -1004,6 +1014,9 @@ pub struct KimiWebState {
window: Mutex<Option<tauri::WebviewWindow>>, window: Mutex<Option<tauri::WebviewWindow>>,
/// The `kimi web` child spawned by this app (None when reusing a server). /// The `kimi web` child spawned by this app (None when reusing a server).
child: Mutex<Option<std::process::Child>>, child: Mutex<Option<std::process::Child>>,
/// Serializes `open_kimi_web_embedded` invocations: a rapid double-click
/// queues here instead of racing the window/server setup.
launch_lock: tokio::sync::Mutex<()>,
} }
const KIMI_WEB_PORT: u16 = 58627; const KIMI_WEB_PORT: u16 = 58627;
@@ -1054,7 +1067,13 @@ pub async fn open_kimi_web_embedded(
app: tauri::AppHandle, app: tauri::AppHandle,
state: tauri::State<'_, KimiWebState>, state: tauri::State<'_, KimiWebState>,
) -> Result<(), String> { ) -> Result<(), String> {
// Singleton: focus the existing window instead of creating a second one. // Serialize concurrent invocations (rapid double-click): a second call
// waits here until the first has finished building the window, then
// re-checks the singleton below and focuses it instead.
let _guard = state.launch_lock.lock().await;
// Singleton (re-checked after acquiring the lock): focus the existing
// window instead of creating a second one.
if let Ok(guard) = state.window.lock() { if let Ok(guard) = state.window.lock() {
if let Some(w) = guard.as_ref() { if let Some(w) = guard.as_ref() {
if w.is_visible().unwrap_or(false) { if w.is_visible().unwrap_or(false) {
@@ -1065,8 +1084,21 @@ pub async fn open_kimi_web_embedded(
} }
} }
// Reuse an already-running server, otherwise spawn `kimi web --no-open`. // Reuse the server this app spawned earlier (still running), or a server
if !kimi_web_alive(Duration::from_millis(800)).await { // already running outside the app; only spawn when neither is present.
// `spawned_by_me` gates every failure-path kill, so a reused server is
// never terminated.
let mut spawned_by_me = false;
let mut child_running = false;
if let Ok(mut guard) = state.child.lock() {
// try_wait: Ok(None) = still running. Exited or unwaitable children
// are treated as gone so a fresh server is spawned below.
child_running = guard
.as_mut()
.map(|child| child.try_wait().map(|st| st.is_none()).unwrap_or(false))
.unwrap_or(false);
}
if !child_running && !kimi_web_alive(Duration::from_millis(800)).await {
let mut cmd = std::process::Command::new("kimi"); let mut cmd = std::process::Command::new("kimi");
cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]); cmd.args(["web", "--no-open", "--port", &KIMI_WEB_PORT.to_string()]);
#[cfg(windows)] #[cfg(windows)]
@@ -1081,6 +1113,7 @@ pub async fn open_kimi_web_embedded(
format!("failed to start `kimi web`: {e}") format!("failed to start `kimi web`: {e}")
} }
})?; })?;
spawned_by_me = true;
if let Ok(mut guard) = state.child.lock() { if let Ok(mut guard) = state.child.lock() {
*guard = Some(child); *guard = Some(child);
} }
@@ -1088,57 +1121,79 @@ pub async fn open_kimi_web_embedded(
// Wait for the server to come up (fresh start takes a moment). // Wait for the server to come up (fresh start takes a moment).
if !kimi_web_alive(Duration::from_secs(8)).await { if !kimi_web_alive(Duration::from_secs(8)).await {
kill_spawned_kimi_web(&state); if spawned_by_me {
kill_spawned_kimi_web(&state);
}
return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})")); return Err(format!("kimi web did not come up within 8s ({KIMI_WEB_ORIGIN})"));
} }
// Create the window on the main thread (required on macOS). // Build the URL, then create the window on the main thread (required on
// macOS). The build result is awaited while still holding `launch_lock`,
// so a concurrent second invocation blocks here and then lands on the
// singleton re-check above once the window exists.
let url: tauri::Url = match kimi_web_url().parse() { let url: tauri::Url = match kimi_web_url().parse() {
Ok(u) => u, Ok(u) => u,
Err(_) => return Err("invalid kimi web url".to_string()), Err(_) => return Err("invalid kimi web url".to_string()),
}; };
let app = app.clone(); let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
tauri::async_runtime::spawn(async move { let builder_app = app.clone();
let builder_app = app.clone(); app.run_on_main_thread(move || {
let _ = app.run_on_main_thread(move || { let result = build_kimi_web_window(&builder_app, &url);
match tauri::WebviewWindowBuilder::new( let _ = tx.send(result);
&builder_app, })
KIMI_WEB_WINDOW_LABEL, .map_err(|e| format!("failed to queue window creation: {e}"))?;
tauri::WebviewUrl::External(url), match rx.await {
) Ok(result) => {
.title("Kimi Code WebUI") if let Err(e) = result {
.inner_size(1100.0, 750.0) // Window creation failed (e.g. label already taken): only stop
.resizable(true) // the server when this invocation spawned it.
.center() if spawned_by_me {
.build() kill_spawned_kimi_web(&state);
{
Ok(window) => {
// Track the window for the singleton check.
if let Ok(mut guard) = builder_app.state::<KimiWebState>().window.lock() {
*guard = Some(window.clone());
}
// Clean up when the window closes: forget it and stop the
// `kimi web` server we spawned (reused servers are untouched).
let w = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Destroyed => {
let app = w.app_handle().clone();
let state = app.state::<KimiWebState>();
if let Ok(mut guard) = state.window.lock() {
*guard = None;
}
kill_spawned_kimi_web(&state);
}
_ => {}
});
}
Err(_) => {
// Window creation failed (e.g. rapid double-click racing
// the singleton check): don't leak the server we spawned.
kill_spawned_kimi_web(&builder_app.state::<KimiWebState>());
} }
return Err(e);
} }
}); Ok(())
}
// Sender dropped without a result (app shutting down, build never
// ran): never kill the server — a later invocation may reuse it.
Err(_) => Ok(()),
}
}
/// Build the embedded WebUI window on the main thread and track it for the
/// singleton check. On success, wires close/destroy events to forget the
/// window and stop the `kimi web` server this app spawned (reused servers are
/// untouched).
fn build_kimi_web_window(app: &tauri::AppHandle, url: &tauri::Url) -> Result<(), String> {
let window = tauri::WebviewWindowBuilder::new(
app,
KIMI_WEB_WINDOW_LABEL,
tauri::WebviewUrl::External(url.clone()),
)
.title("Kimi Code WebUI")
.inner_size(1100.0, 750.0)
.resizable(true)
.center()
.build()
.map_err(|e| format!("failed to create kimi web window: {e}"))?;
// Track the window for the singleton check.
if let Ok(mut guard) = app.state::<KimiWebState>().window.lock() {
*guard = Some(window.clone());
}
// Clean up when the window closes: forget it and stop the `kimi web`
// server we spawned (reused servers are untouched).
let w = window.clone();
window.on_window_event(move |event| match event {
tauri::WindowEvent::Destroyed => {
let app = w.app_handle().clone();
let state = app.state::<KimiWebState>();
if let Ok(mut guard) = state.window.lock() {
*guard = None;
}
kill_spawned_kimi_web(&state);
}
_ => {}
}); });
Ok(()) Ok(())
} }
@@ -1283,24 +1338,43 @@ pub async fn kimi_oauth_poll(
// Experimental feature env-var probe (Kimi Code secondary model etc.) // Experimental feature env-var probe (Kimi Code secondary model etc.)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Read the Kimi Code experimental-feature environment variables that are /// Read the Kimi Code environment variables that are currently set (non-empty)
/// currently set (non-empty) in this process's environment. The frontend /// in this process's environment. Every `[experimental]` feature env var is
/// uses the result to mark config.toml `[experimental]` toggles as locked: /// probed — the frontend uses a per-feature env var to mark the matching
/// env vars outrank the config file in Kimi Code's flag resolution. /// config.toml `[experimental]` toggle as locked, since a single-feature env
/// var outranks the config file in Kimi Code's flag resolution. The master
/// `KIMI_CODE_EXPERIMENTAL_FLAG` (0.40.1+: only force-ONs flags without an
/// explicit config entry; never locks a toggle) and `KIMI_CODE_LEGACY_FLAG`
/// are probed too — the latter lets AgentSettingsPanel decide whether to
/// dual-write the v1 engine's loop_control keys.
///
/// The flag id → env var mapping mirrors `EXPERIMENTAL_FLAGS` in
/// src/lib/subagent-settings.ts; keep both lists in sync.
/// ///
/// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive); /// Truthy values per the CLI: `1` / `true` / `yes` / `on` (case-insensitive);
/// the raw values are returned and the truthy check happens on the frontend. /// the raw values are returned and the truthy check happens on the frontend.
#[tauri::command] #[tauri::command]
pub fn get_experimental_env_status() -> HashMap<String, String> { pub fn get_experimental_env_status() -> HashMap<String, String> {
const VARS: [&str; 5] = [ const VARS: [(&str, &str); 12] = [
"KIMI_CODE_EXPERIMENTAL_FLAG", ("secondary-model", "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL"),
"KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL", ("tool-select", "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT"),
"KIMI_CODE_EXPERIMENTAL_TOOL_SELECT", (
"KIMI_CODE_EXPERIMENTAL_ACP_V2", "persistence_minidb_readmodel",
"KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL", "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
),
("tower", "KIMI_CODE_EXPERIMENTAL_TOWER"),
("subagent_fork", "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK"),
("wait_for", "KIMI_CODE_EXPERIMENTAL_WAIT_FOR"),
("auto_session_title", "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE"),
("remote-control", "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL"),
("search_worker", "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER"),
("file_history", "KIMI_CODE_EXPERIMENTAL_FILE_HISTORY"),
// Non-flag probes consumed by the frontend:
("master", "KIMI_CODE_EXPERIMENTAL_FLAG"),
("legacy", "KIMI_CODE_LEGACY_FLAG"),
]; ];
let mut out = HashMap::new(); let mut out = HashMap::new();
for name in VARS { for (_, name) in VARS {
if let Ok(value) = std::env::var(name) { if let Ok(value) = std::env::var(name) {
if !value.trim().is_empty() { if !value.trim().is_empty() {
out.insert(name.to_string(), value); out.insert(name.to_string(), value);
+343 -34
View File
@@ -1234,6 +1234,49 @@ fn read_state_safe(session_dir: &Path) -> (Option<String>, Option<String>, Optio
(None, None, None, None) (None, None, None, None)
} }
/// Whether a session is archived per its `state.json` metadata: the
/// kimi-code v2 engine writes `archived: true` (plus an `archivedAt` epoch-ms
/// timestamp) when archiving and clears both on restore. Unparseable or
/// missing state.json counts as not archived (and won't panic).
fn read_state_archived(session_dir: &Path) -> bool {
let sp = session_dir.join("state.json");
let Ok(content) = fs::read_to_string(&sp) else { return false };
let Ok(obj) = serde_json::from_str::<serde_json::Value>(&content) else { return false };
obj.get("archived").and_then(|v| v.as_bool()).unwrap_or(false)
}
/// Set or clear the archived metadata on a session's `state.json`, preserving
/// every other field. Mirrors upstream kimi-code v2 `SessionMetadata.setArchived`:
/// archive writes `{ archived: true, archivedAt: Date.now() }` (epoch ms, same
/// `Date.now()` format as the CLI), restore writes `{ archived: false }` and
/// drops the `archivedAt` key; `updatedAt` is untouched in both cases.
/// The write is atomic (tmp file + rename), same pattern as the plugin store.
/// Missing or malformed state.json returns a descriptive error, never a panic.
fn set_session_archived_meta(session_dir: &Path, archived: bool) -> Result<(), String> {
let sp = session_dir.join("state.json");
let content = fs::read_to_string(&sp).map_err(|e| format!("read state.json: {e}"))?;
let mut obj: serde_json::Value =
serde_json::from_str(&content).map_err(|e| format!("parse state.json: {e}"))?;
let obj = obj
.as_object_mut()
.ok_or_else(|| "state.json is not a JSON object".to_string())?;
obj.insert("archived".into(), serde_json::Value::Bool(archived));
if archived {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
obj.insert("archivedAt".into(), serde_json::Value::Number(now_ms.into()));
} else {
obj.remove("archivedAt");
}
let out = serde_json::to_string(&obj).map_err(|e| format!("serialize state.json: {e}"))?;
let tmp = sp.with_file_name("state.json.tmp");
fs::write(&tmp, out).map_err(|e| format!("write state.json: {e}"))?;
fs::rename(&tmp, &sp).map_err(|e| format!("commit state.json: {e}"))?;
Ok(())
}
fn humanize_workspace(id: &str) -> String { fn humanize_workspace(id: &str) -> String {
let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap(); let re = Regex::new(r"^wd_(.+)_[0-9a-fA-F]{8,}$").unwrap();
if let Some(caps) = re.captures(id) { if let Some(caps) = re.captures(id) {
@@ -1243,7 +1286,11 @@ fn humanize_workspace(id: &str) -> String {
} }
} }
fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec<SessionRow> { /// List session rows under one directory. `in_archive_dir` marks `.kcd-archive`
/// (legacy physical archive); a session is additionally archived when its own
/// `state.json` carries `archived: true` — so CLI-v2 metadata-archived sessions
/// that still live at the active root are correctly classified.
fn list_sessions_in_dir(dir: &Path, workspace_id: &str, in_archive_dir: bool) -> Vec<SessionRow> {
let mut sessions = Vec::new(); let mut sessions = Vec::new();
if !dir.exists() { return sessions; } if !dir.exists() { return sessions; }
let re = session_re(); let re = session_re();
@@ -1251,6 +1298,8 @@ fn list_sessions_in_dir(dir: &Path, workspace_id: &str, status: &str) -> Vec<Ses
if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; } if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { continue; }
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
if !re.is_match(&name) { continue; } if !re.is_match(&name) { continue; }
let archived = in_archive_dir || read_state_archived(&entry.path());
let status = if archived { "archived" } else { "active" };
let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path()); let (title, work_dir, created_at, updated_at) = read_state_safe(&entry.path());
let (bytes, files) = file_size_approx(&entry.path()); let (bytes, files) = file_size_approx(&entry.path());
let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok()) let mtime = entry.path().metadata().ok().and_then(|m| m.modified().ok())
@@ -1311,19 +1360,33 @@ fn list_sessions_cmd(home: &Path, status: &str, workspace_filter: Option<String>
let active_dir = root.join(wid); let active_dir = root.join(wid);
let arch_dir = archive_root.join(wid); let arch_dir = archive_root.join(wid);
let active_all = list_sessions_in_dir(&active_dir, wid, "active"); // Merge both sources, deduping by session id (ids are UUIDs and never
let arch_all = list_sessions_in_dir(&arch_dir, wid, "archived"); // reused; a stale legacy copy under .kcd-archive loses to the entry at
let active_list = if status == "archived" { vec![] } else { active_all.clone() }; // the active root). Status comes from each row, so metadata-archived
let arch_list = if status == "active" { vec![] } else { arch_all.clone() }; // sessions surface in the archived filter and the archive-only counts.
let mut by_id: HashMap<String, SessionRow> = HashMap::new();
for row in list_sessions_in_dir(&active_dir, wid, false) {
by_id.insert(row.id.clone(), row);
}
for row in list_sessions_in_dir(&arch_dir, wid, true) {
by_id.entry(row.id.clone()).or_insert(row);
}
let all: Vec<SessionRow> = by_id.into_values().collect();
let active_count = all.iter().filter(|s| s.status == "active").count();
let archived_count = all.iter().filter(|s| s.status == "archived").count();
let listed: Vec<SessionRow> = match status {
"active" => all.iter().filter(|s| s.status == "active").cloned().collect(),
"archived" => all.iter().filter(|s| s.status == "archived").cloned().collect(),
_ => all,
};
let empty = active_all.is_empty() && arch_all.is_empty(); let empty = active_count == 0 && archived_count == 0;
workspaces.push(WorkspaceRow { workspaces.push(WorkspaceRow {
id: wid.clone(), name, root: root_path, id: wid.clone(), name, root: root_path,
created_at: None, last_opened_at: None, created_at: None, last_opened_at: None,
active_count: active_all.len(), archived_count: arch_all.len(), empty, active_count, archived_count, empty,
}); });
all_sessions.extend(active_list); all_sessions.extend(listed);
all_sessions.extend(arch_list);
} }
all_sessions.sort_by(|a, b| { all_sessions.sort_by(|a, b| {
@@ -1356,38 +1419,57 @@ fn assert_safe_path(home: &Path, workspace_id: &str, session_id: &str) -> Result
} }
fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> { fn archive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let src = assert_safe_path(home, workspace_id, session_id)?; let dir = assert_safe_path(home, workspace_id, session_id)?;
let dest = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); if !dir.exists() { return Err("session not found".into()); }
if !src.exists() { return Err("session not found".into()); } // Metadata archive, matching upstream kimi-code v2: write `archived: true`
if let Some(parent) = dest.parent() { // + `archivedAt` into state.json, leave the session directory in place, and
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; // do NOT touch session_index.jsonl — archived state is derived from
// state.json, and the CLI reconciles its own index mirror. A session
// without a readable state.json gets a clear error rather than a panic.
let sp = dir.join("state.json");
if !sp.is_file() {
return Err(format!("session {session_id} has no state.json; cannot archive"));
} }
fs::rename(&src, &dest).or_else(|_| { set_session_archived_meta(&dir, true)?;
// cross-device fallback
fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&src).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
scrub_session_index(home, session_id);
Ok(ActionResponse { Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(), ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("archived".into()), session_id: session_id.to_string(), status: Some("archived".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None, path: Some(dir.to_string_lossy().to_string()), deleted: None,
}) })
} }
fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> { fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> Result<ActionResponse, String> {
let src = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
let dest = assert_safe_path(home, workspace_id, session_id)?; let dest = assert_safe_path(home, workspace_id, session_id)?;
if !src.exists() { return Err("archived session not found".into()); } // 1) Legacy recovery: sessions physically moved to .kcd-archive by older
if let Some(parent) = dest.parent() { // KimiSwitch versions are moved back, then their metadata flag is
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?; // cleared per upstream restore semantics (archived:false + archivedAt
// dropped). A missing state.json is fine here — there is no flag.
let archived_dir = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
if archived_dir.exists() {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| format!("mkdir: {}", e))?;
}
fs::rename(&archived_dir, &dest).or_else(|_| {
// cross-device fallback
fs_extra::dir::copy(&archived_dir, dest.parent().unwrap(), &Default::default()).ok();
fs::remove_dir_all(&archived_dir).ok();
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
// The move already restored the session; a corrupt state.json must not
// turn a successful restore into an error — the session simply lists
// as active with its flag intact.
if dest.join("state.json").is_file() {
let _ = set_session_archived_meta(&dest, false);
}
return Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()),
path: Some(dest.to_string_lossy().to_string()), deleted: None,
});
} }
fs::rename(&src, &dest).or_else(|_| { // 2) Metadata-archived session: still at the active root, just clear the flag.
fs_extra::dir::copy(&src, dest.parent().unwrap(), &Default::default()).ok(); if !dest.exists() { return Err("archived session not found".into()); }
fs::remove_dir_all(&src).ok(); set_session_archived_meta(&dest, false)?;
Ok::<(), String>(())
}).map_err(|e: String| format!("move: {}", e))?;
Ok(ActionResponse { Ok(ActionResponse {
ok: true, workspace_id: workspace_id.to_string(), ok: true, workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), status: Some("active".into()), session_id: session_id.to_string(), status: Some("active".into()),
@@ -1398,6 +1480,9 @@ fn unarchive_session_cmd(home: &Path, workspace_id: &str, session_id: &str) -> R
fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<ActionResponse, String> { fn delete_session_cmd(home: &Path, workspace_id: &str, session_id: &str, status_hint: Option<&str>) -> Result<ActionResponse, String> {
let active = assert_safe_path(home, workspace_id, session_id)?; let active = assert_safe_path(home, workspace_id, session_id)?;
let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id); let archived = sessions_root(home).join(".kcd-archive").join(workspace_id).join(session_id);
// Metadata-archived sessions still live at the active root, so a
// status_hint of "archived" with no legacy .kcd-archive copy falls through
// to the default arm and removes the in-place directory.
let target = match status_hint { let target = match status_hint {
Some("archived") if archived.exists() => archived, Some("archived") if archived.exists() => archived,
Some("active") if active.exists() => active, Some("active") if active.exists() => active,
@@ -1440,11 +1525,11 @@ fn delete_workspace_cmd(home: &Path, workspace_id: &str, _confirm: bool, _force:
let arch_dir = root.join(".kcd-archive").join(workspace_id); let arch_dir = root.join(".kcd-archive").join(workspace_id);
if active_dir.exists() { if active_dir.exists() {
let active_list = list_sessions_in_dir(&active_dir, workspace_id, "active"); let active_list = list_sessions_in_dir(&active_dir, workspace_id, false);
if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } if !active_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
} }
if arch_dir.exists() { if arch_dir.exists() {
let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, "archived"); let arch_list = list_sessions_in_dir(&arch_dir, workspace_id, true);
if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); } if !arch_list.is_empty() { return Err("workspace is not empty; archive/delete sessions first".into()); }
} }
@@ -1546,6 +1631,10 @@ fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, st
let archived = root.join(".kcd-archive").join(workspace_id).join(session_id); let archived = root.join(".kcd-archive").join(workspace_id).join(session_id);
let session_dir = match status_hint { let session_dir = match status_hint {
Some("archived") if archived.exists() => archived, Some("archived") if archived.exists() => archived,
// Metadata-archived sessions stay at the active root (kimi-code v2
// writes state.json.archived, no physical move) — read them in place.
Some("archived") if active.exists() => active,
Some("active") if active.exists() => active,
_ => if active.exists() { active } else if archived.exists() { archived } _ => if active.exists() { active } else if archived.exists() { archived }
else { return Err("session not found".into()); } else { return Err("session not found".into()); }
}; };
@@ -1666,7 +1755,11 @@ fn get_session_preview_cmd(home: &Path, workspace_id: &str, session_id: &str, st
Ok(PreviewResult { Ok(PreviewResult {
workspace_id: workspace_id.to_string(), workspace_id: workspace_id.to_string(),
session_id: session_id.to_string(), session_id: session_id.to_string(),
status: if session_dir.to_string_lossy().contains(".kcd-archive") { "archived".into() } else { "active".into() }, status: if session_dir.to_string_lossy().contains(".kcd-archive") || read_state_archived(&session_dir) {
"archived".into()
} else {
"active".into()
},
title, work_dir, created_at, updated_at, title, work_dir, created_at, updated_at,
message_count: messages.len(), message_count: messages.len(),
truncated, truncated,
@@ -1998,3 +2091,219 @@ mod pricing_tests {
assert_eq!(ch, 0.11); assert_eq!(ch, 0.11);
} }
} }
#[cfg(test)]
mod session_tests {
use super::*;
const WID: &str = "wd_proj_a1b2c3d4e5f6";
const SID: &str = "session_11111111-2222-3333-4444-555555555555";
fn setup_home() -> (tempfile::TempDir, PathBuf) {
let td = tempfile::tempdir().unwrap();
let home = td.path().join("home");
fs::create_dir_all(home.join("sessions").join(WID)).unwrap();
(td, home)
}
fn active_dir(home: &Path) -> PathBuf {
home.join("sessions").join(WID)
}
fn legacy_arch_dir(home: &Path) -> PathBuf {
home.join("sessions").join(".kcd-archive").join(WID)
}
fn write_state(session_dir: &Path, archived: bool, archived_at: Option<u64>) {
let mut obj = serde_json::json!({
"id": SID,
"version": 2,
"cwd": "/tmp/work",
"createdAt": 1_700_000_000_000u64,
"updatedAt": 1_700_000_000_000u64,
"archived": archived,
"title": "test session",
});
let o = obj.as_object_mut().unwrap();
match archived_at {
Some(at) => { o.insert("archivedAt".into(), serde_json::json!(at)); }
None => { o.remove("archivedAt"); }
}
fs::write(session_dir.join("state.json"), serde_json::to_string(&obj).unwrap()).unwrap();
}
fn read_state_json(session_dir: &Path) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(session_dir.join("state.json")).unwrap()).unwrap()
}
#[test]
fn metadata_archive_roundtrip() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, false, None);
// Fresh session lists as active.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
// Archive is metadata-only: dir stays put, flag + epoch-ms archivedAt written.
let ar = archive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ar.status.as_deref(), Some("archived"));
assert!(dir.exists(), "metadata archive must not move the session dir");
assert!(!legacy_arch_dir(&home).join(SID).exists(), "nothing moves into .kcd-archive");
let st = read_state_json(&dir);
assert_eq!(st["archived"], true);
let archived_at = st["archivedAt"].as_u64().expect("archivedAt as epoch ms");
assert!(archived_at > 0, "archivedAt is a Date.now()-style epoch-ms number");
// List reflects the new status.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
assert_eq!(list_sessions_cmd(&home, "archived", None).sessions.len(), 1);
// Unarchive clears the flag in place (archived:false, archivedAt dropped).
let ur = unarchive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ur.status.as_deref(), Some("active"));
assert!(dir.exists(), "unarchive must not move the dir either");
let st = read_state_json(&dir);
assert_eq!(st["archived"], false);
assert!(st.get("archivedAt").is_none(), "archivedAt key removed on restore");
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
}
#[test]
fn metadata_archive_preserves_other_state_fields() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
let mut obj = serde_json::json!({
"id": SID,
"version": 2,
"cwd": "/repo",
"createdAt": 1,
"updatedAt": 2,
"archived": false,
"title": "keep me",
"custom": { "goal": "x" },
"agents": { "main": { "type": "main" } },
});
obj.as_object_mut().unwrap().insert("someFutureField".into(), serde_json::json!([1, 2, 3]));
fs::write(dir.join("state.json"), serde_json::to_string(&obj).unwrap()).unwrap();
archive_session_cmd(&home, WID, SID).unwrap();
unarchive_session_cmd(&home, WID, SID).unwrap();
let st = read_state_json(&dir);
assert_eq!(st["title"], "keep me");
assert_eq!(st["custom"]["goal"], "x");
assert_eq!(st["agents"]["main"]["type"], "main");
assert_eq!(st["someFutureField"][0], 1);
assert_eq!(st["updatedAt"], 2, "updatedAt untouched, like upstream touchUpdatedAt:false");
}
#[test]
fn cli_archived_session_in_active_root_lists_as_archived() {
// A session already carrying archived:true in state.json while still
// living at the active root (written by kimi-code CLI v2 itself) must
// surface in the archived filter / counts.
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, true, Some(1_700_000_000_000u64));
fs::write(dir.join("wire.jsonl"), "").unwrap();
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
assert_eq!(list_sessions_cmd(&home, "archived", None).sessions.len(), 1);
// Preview resolves it in place with status "archived".
let pv = get_session_preview_cmd(&home, WID, SID, Some("archived")).unwrap();
assert_eq!(pv.status, "archived");
}
#[test]
fn legacy_kcd_archive_dir_still_listed_and_restored() {
let (_td, home) = setup_home();
let arch_dir = legacy_arch_dir(&home).join(SID);
fs::create_dir_all(&arch_dir).unwrap();
write_state(&arch_dir, false, None);
// Legacy physical archive still shows as archived.
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions.len(), 1);
assert_eq!(res.sessions[0].status, "archived");
assert_eq!(res.workspaces[0].active_count, 0);
assert_eq!(res.workspaces[0].archived_count, 1);
assert!(list_sessions_cmd(&home, "active", None).sessions.is_empty());
// Unarchive moves it back and clears the flag.
let ur = unarchive_session_cmd(&home, WID, SID).unwrap();
assert_eq!(ur.status.as_deref(), Some("active"));
assert!(!arch_dir.exists(), "legacy .kcd-archive copy moved back");
let restored = active_dir(&home).join(SID);
assert!(restored.exists());
let st = read_state_json(&restored);
assert_eq!(st["archived"], false);
assert!(st.get("archivedAt").is_none());
let res = list_sessions_cmd(&home, "all", None);
assert_eq!(res.sessions[0].status, "active");
assert_eq!(res.workspaces[0].active_count, 1);
assert_eq!(res.workspaces[0].archived_count, 0);
}
#[test]
fn corrupt_state_json_archive_errors_without_panic() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("state.json"), "{ this is not json").unwrap();
let err = archive_session_cmd(&home, WID, SID).unwrap_err();
assert!(err.contains("state.json"), "clear error mentioning state.json, got: {err}");
assert!(dir.exists(), "failed archive leaves the session dir in place");
assert_eq!(fs::read_to_string(dir.join("state.json")).unwrap(), "{ this is not json");
}
#[test]
fn missing_state_json_archive_errors_without_panic() {
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
let err = archive_session_cmd(&home, WID, SID).unwrap_err();
assert!(err.contains("no state.json"), "clear error, got: {err}");
assert!(dir.exists());
}
#[test]
fn delete_session_covers_metadata_archived() {
// Status hint "archived" without a legacy .kcd-archive copy falls back
// to removing the metadata-archived in-place directory.
let (_td, home) = setup_home();
let dir = active_dir(&home).join(SID);
fs::create_dir_all(&dir).unwrap();
write_state(&dir, true, Some(1_700_000_000_000u64));
let del = delete_session_cmd(&home, WID, SID, Some("archived")).unwrap();
assert_eq!(del.deleted, Some(true));
assert!(!dir.exists(), "metadata-archived session deleted in place");
assert!(!legacy_arch_dir(&home).join(SID).exists());
assert!(list_sessions_cmd(&home, "all", None).sessions.is_empty());
}
}
+4 -8
View File
@@ -207,6 +207,9 @@ pub fn load_config(agent: &Agent) -> DbResult<Config> {
providers, providers,
models, models,
raw_other: Value::Null, raw_other: Value::Null,
// The SQLite snapshot stores no import baseline; an empty baseline
// makes export's stale-key cleanup a no-op (safe degradation).
imported_section_keys: Vec::new(),
}) })
} }
@@ -328,12 +331,5 @@ pub fn delete_setting_pub(key: &str) -> DbResult<()> {
} }
fn provider_type_for_str(s: &str) -> ProviderType { fn provider_type_for_str(s: &str) -> ProviderType {
match s { ProviderType::from_kimi_type(s)
"anthropic" => ProviderType::Anthropic,
"openai" => ProviderType::Openai,
"openai_responses" => ProviderType::OpenaiResponses,
"google-genai" => ProviderType::GoogleGenai,
"vertexai" => ProviderType::Vertexai,
_ => ProviderType::Kimi,
}
} }
+147 -23
View File
@@ -148,7 +148,7 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
let provider_type = table let provider_type = table
.get("type") .get("type")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(provider_type_for_kimi_type) .map(ProviderType::from_kimi_type)
.unwrap_or(ProviderType::Kimi); .unwrap_or(ProviderType::Kimi);
let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); let base_url = table.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string());
@@ -216,19 +216,23 @@ pub fn kimi_code_to_config(value: &TomlValue) -> Config {
} }
} }
let raw_other = { let mut rest = root;
let mut rest = root; rest.remove("default_model");
rest.remove("default_model"); rest.remove("providers");
rest.remove("providers"); rest.remove("models");
rest.remove("models"); // Baseline of top-level keys seen at import time (table order). Export
toml_value_to_json(&TomlValue::Table(rest)) // only drops baseline keys that later disappeared from raw_other, i.e.
}; // sections the user removed in the UI — sections the CLI adds after
// import are not in the baseline and survive the round-trip.
let imported_section_keys: Vec<String> = rest.keys().cloned().collect();
let raw_other = toml_value_to_json(&TomlValue::Table(rest));
Config { Config {
default_model, default_model,
providers, providers,
models, models,
raw_other, raw_other,
imported_section_keys,
} }
} }
@@ -354,10 +358,13 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
// Sync UI-managed top-level sections (thinking, experimental, // Sync UI-managed top-level sections (thinking, experimental,
// secondary_model, ...) from config.raw_other back into the root. // secondary_model, ...) from config.raw_other back into the root.
// At import time every top-level key except default_model/providers/ // At import time every top-level key except default_model/providers/
// models was collected into raw_other, so raw_other is the authoritative // models was collected into raw_other, and their keys recorded in
// snapshot of those sections. This both persists UI edits to those // `imported_section_keys` (the import-time baseline). Keys present in
// sections and drops sections the user explicitly removed (absent from // raw_other are written back (persisting UI edits); keys that were in
// raw_other but still present in `existing`). // the baseline but are absent from raw_other were removed by the user
// and are dropped from `existing`. Top-level keys NOT in the baseline
// were added by the CLI after import and are preserved untouched. An
// empty baseline (Config not built via import) deletes nothing.
let mut managed_keys = std::collections::HashSet::new(); let mut managed_keys = std::collections::HashSet::new();
if let TomlValue::Table(extra) = if let TomlValue::Table(extra) =
json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new())) json_to_toml(&config.raw_other).unwrap_or(TomlValue::Table(Table::new()))
@@ -377,6 +384,7 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
&& *k != "providers" && *k != "providers"
&& *k != "models" && *k != "models"
&& !managed_keys.contains(*k) && !managed_keys.contains(*k)
&& config.imported_section_keys.iter().any(|b| b == *k)
}) })
.cloned() .cloned()
.collect(); .collect();
@@ -387,17 +395,6 @@ pub fn config_to_kimi_code(config: &Config, existing: Option<&TomlValue>) -> Tom
TomlValue::Table(root) TomlValue::Table(root)
} }
fn provider_type_for_kimi_type(typ: &str) -> ProviderType {
match typ {
"anthropic" => ProviderType::Anthropic,
"openai" => ProviderType::Openai,
"openai_responses" => ProviderType::OpenaiResponses,
"google-genai" => ProviderType::GoogleGenai,
"vertexai" => ProviderType::Vertexai,
_ => ProviderType::Kimi,
}
}
fn toml_value_to_json(value: &TomlValue) -> Value { fn toml_value_to_json(value: &TomlValue) -> Value {
match value { match value {
TomlValue::String(s) => Value::String(s.clone()), TomlValue::String(s) => Value::String(s.clone()),
@@ -572,6 +569,7 @@ api_key = ""
providers, providers,
models, models,
raw_other: Value::Null, raw_other: Value::Null,
imported_section_keys: Vec::new(),
}; };
let exported = config_to_kimi_code(&config, None); let exported = config_to_kimi_code(&config, None);
@@ -705,6 +703,7 @@ default_effort = "low"
providers, providers,
models, models,
raw_other: Value::Null, raw_other: Value::Null,
imported_section_keys: Vec::new(),
}; };
let exported = config_to_kimi_code(&config, None); let exported = config_to_kimi_code(&config, None);
@@ -818,6 +817,7 @@ max_context_size = 1048576
providers, providers,
models: IndexMap::new(), models: IndexMap::new(),
raw_other: Value::Null, raw_other: Value::Null,
imported_section_keys: Vec::new(),
}; };
let exported = config_to_kimi_code(&config, None); let exported = config_to_kimi_code(&config, None);
@@ -858,6 +858,7 @@ max_context_size = 1048576
providers, providers,
models: IndexMap::new(), models: IndexMap::new(),
raw_other: Value::Null, raw_other: Value::Null,
imported_section_keys: Vec::new(),
}; };
let exported = config_to_kimi_code(&config, None); let exported = config_to_kimi_code(&config, None);
@@ -908,6 +909,7 @@ max_context_size = 1048576
)]), )]),
models: IndexMap::new(), models: IndexMap::new(),
raw_other: Value::Null, raw_other: Value::Null,
imported_section_keys: Vec::new(),
}; };
let exported = config_to_kimi_code(&config, None); let exported = config_to_kimi_code(&config, None);
let provider = exported let provider = exported
@@ -927,6 +929,7 @@ max_context_size = 1048576
)]), )]),
models: IndexMap::new(), models: IndexMap::new(),
raw_other: Value::Null, raw_other: Value::Null,
imported_section_keys: Vec::new(),
}; };
let exported = config_to_kimi_code(&config, None); let exported = config_to_kimi_code(&config, None);
let provider = exported let provider = exported
@@ -940,4 +943,125 @@ max_context_size = 1048576
Some("sk-user-key") Some("sk-user-key")
); );
} }
#[test]
fn kimi_code_unknown_provider_type_roundtrip() {
// A provider `type` string the CLI knows but Kimi Switch does not
// must survive import → export verbatim, not be rewritten to "kimi".
let toml_str = r#"
default_model = "p1/custom-model"
[providers.p1]
type = "custom-xyz"
api_key = "sk-x"
[models."p1/custom-model"]
provider = "p1"
model = "custom-model"
max_context_size = 128000
"#;
let value: TomlValue = toml_str.parse().unwrap();
let config = kimi_code_to_config(&value);
let provider = config.providers.get("p1").unwrap();
assert_eq!(
provider.provider_type,
ProviderType::Unknown("custom-xyz".to_string())
);
let exported = config_to_kimi_code(&config, Some(&value));
let provider = exported
.as_table().unwrap()
.get("providers").unwrap()
.as_table().unwrap()
.get("p1").unwrap()
.as_table().unwrap();
assert_eq!(
provider.get("type").and_then(|v| v.as_str()),
Some("custom-xyz"),
"unknown provider type must round-trip verbatim"
);
}
#[test]
fn kimi_code_export_preserves_sections_added_after_import() {
// Top-level sections the CLI added AFTER we imported (not in the
// import-time baseline) must survive export instead of being wiped
// by the stale-key cleanup.
let imported_str = r#"
default_model = "glm-5.2"
[providers."glmzhongzhuan"]
type = "anthropic"
api_key = "sk-test"
[models."glm-5.2"]
provider = "glmzhongzhuan"
model = "glm-5.2"
max_context_size = 900000
[thinking]
enabled = true
"#;
let imported: TomlValue = imported_str.parse().unwrap();
let config = kimi_code_to_config(&imported);
// The on-disk file now contains an extra [swarm] section the CLI
// wrote after our import.
let existing: TomlValue = format!("{}\n[swarm]\ntimeout_ms = 123\n", imported_str.trim_end())
.parse()
.unwrap();
let exported = config_to_kimi_code(&config, Some(&existing));
let root = exported.as_table().unwrap();
let swarm = root.get("swarm").unwrap().as_table().unwrap();
assert_eq!(
swarm.get("timeout_ms").and_then(|v| v.as_integer()),
Some(123),
"CLI-added [swarm] section must be preserved"
);
// Baseline sections are still synced as before.
let thinking = root.get("thinking").unwrap().as_table().unwrap();
assert_eq!(thinking.get("enabled").and_then(|v| v.as_bool()), Some(true));
}
#[test]
fn kimi_code_export_drops_user_removed_sections() {
// A section that WAS in the import baseline but was removed from
// raw_other (the user deleted it in the UI) must still be dropped
// from the exported file.
let toml_str = r#"
default_model = "glm-5.2"
[providers."glmzhongzhuan"]
type = "anthropic"
api_key = "sk-test"
[models."glm-5.2"]
provider = "glmzhongzhuan"
model = "glm-5.2"
max_context_size = 900000
[thinking]
enabled = true
"#;
let value: TomlValue = toml_str.parse().unwrap();
let mut config = kimi_code_to_config(&value);
assert!(
config.imported_section_keys.contains(&"thinking".to_string()),
"baseline must record the imported [thinking] section"
);
// UI removed the thinking section.
let mut raw = config.raw_other.as_object().unwrap().clone();
raw.remove("thinking");
config.raw_other = Value::Object(raw);
let exported = config_to_kimi_code(&config, Some(&value));
let root = exported.as_table().unwrap();
assert!(
!root.contains_key("thinking"),
"user-removed section must be dropped on export"
);
}
} }
+54 -13
View File
@@ -1,5 +1,7 @@
use std::borrow::Cow;
use indexmap::IndexMap; use indexmap::IndexMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value; use serde_json::Value;
/// Target agent whose provider/model config is being edited. /// Target agent whose provider/model config is being edited.
@@ -19,18 +21,35 @@ impl Agent {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderType { pub enum ProviderType {
Anthropic, Anthropic,
Openai, Openai,
#[serde(rename = "openai_responses")]
OpenaiResponses, OpenaiResponses,
#[serde(rename = "google-genai")]
GoogleGenai, GoogleGenai,
Vertexai, Vertexai,
/// Kept for compatibility; mapped to OpenAI-compatible in Pi. /// Kept for compatibility; mapped to OpenAI-compatible in Pi.
Kimi, Kimi,
/// A provider type string the CLI knows but Kimi Switch does not.
/// Kept verbatim so new upstream `type` values survive the round-trip
/// instead of being rewritten to "kimi".
Unknown(String),
}
// Serde is hand-written to keep a pure string wire format: known variants
// map to their CLI string, `Unknown(s)` maps to `s` itself. A derived
// representation would expose the internal variant names.
impl Serialize for ProviderType {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.as_str())
}
}
impl<'de> Deserialize<'de> for ProviderType {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(ProviderType::from_kimi_type(&s))
}
} }
const fn default_true() -> bool { const fn default_true() -> bool {
@@ -38,14 +57,29 @@ const fn default_true() -> bool {
} }
impl ProviderType { impl ProviderType {
pub fn as_str(&self) -> &'static str { pub fn as_str(&self) -> Cow<'static, str> {
match self { match self {
ProviderType::Anthropic => "anthropic", ProviderType::Anthropic => Cow::Borrowed("anthropic"),
ProviderType::Openai => "openai", ProviderType::Openai => Cow::Borrowed("openai"),
ProviderType::OpenaiResponses => "openai_responses", ProviderType::OpenaiResponses => Cow::Borrowed("openai_responses"),
ProviderType::GoogleGenai => "google-genai", ProviderType::GoogleGenai => Cow::Borrowed("google-genai"),
ProviderType::Vertexai => "vertexai", ProviderType::Vertexai => Cow::Borrowed("vertexai"),
ProviderType::Kimi => "kimi", ProviderType::Kimi => Cow::Borrowed("kimi"),
ProviderType::Unknown(s) => Cow::Owned(s.clone()),
}
}
/// Map a CLI `type` string to a `ProviderType`; unrecognized values
/// become `Unknown(s)` so the original string round-trips on export.
pub fn from_kimi_type(s: &str) -> ProviderType {
match s {
"anthropic" => ProviderType::Anthropic,
"openai" => ProviderType::Openai,
"openai_responses" => ProviderType::OpenaiResponses,
"google-genai" => ProviderType::GoogleGenai,
"vertexai" => ProviderType::Vertexai,
"kimi" => ProviderType::Kimi,
other => ProviderType::Unknown(other.to_string()),
} }
} }
@@ -55,7 +89,7 @@ impl ProviderType {
Some("https://api.openai.com/v1") Some("https://api.openai.com/v1")
} }
ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"), ProviderType::GoogleGenai => Some("https://generativelanguage.googleapis.com"),
ProviderType::Anthropic | ProviderType::Vertexai => None, ProviderType::Anthropic | ProviderType::Vertexai | ProviderType::Unknown(_) => None,
} }
} }
@@ -203,6 +237,12 @@ pub struct Config {
pub models: IndexMap<String, Model>, pub models: IndexMap<String, Model>,
#[serde(default, skip_serializing_if = "Value::is_null")] #[serde(default, skip_serializing_if = "Value::is_null")]
pub raw_other: Value, pub raw_other: Value,
/// Top-level section keys captured at import time. Export uses this
/// baseline to distinguish "user removed this section in the UI" from
/// "the CLI added this section after we imported" — only baseline keys
/// absent from the current raw_other are dropped on export.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imported_section_keys: Vec<String>,
} }
impl PartialEq for Config { impl PartialEq for Config {
@@ -211,6 +251,7 @@ impl PartialEq for Config {
&& self.providers == other.providers && self.providers == other.providers
&& self.models == other.models && self.models == other.models
&& self.raw_other == other.raw_other && self.raw_other == other.raw_other
&& self.imported_section_keys == other.imported_section_keys
} }
} }
+5 -2
View File
@@ -205,8 +205,10 @@ pub fn pi_api_for_provider(provider_type: &ProviderType) -> &'static str {
ProviderType::Anthropic => "anthropic-messages", ProviderType::Anthropic => "anthropic-messages",
ProviderType::GoogleGenai => "google-generative-ai", ProviderType::GoogleGenai => "google-generative-ai",
ProviderType::Vertexai => "google-vertex", ProviderType::Vertexai => "google-vertex",
// Treat Kimi as OpenAI-compatible since it is not a native Pi API. // Treat Kimi as OpenAI-compatible since it is not a native Pi API;
ProviderType::Kimi => "openai-completions", // unknown upstream types get the same lenient handling (Pi is a
// legacy path).
ProviderType::Kimi | ProviderType::Unknown(_) => "openai-completions",
} }
} }
@@ -383,6 +385,7 @@ pub fn pi_file_to_config(file: &PiModelsFile) -> Config {
providers, providers,
models, models,
raw_other: file.extra.clone(), raw_other: file.extra.clone(),
imported_section_keys: Vec::new(),
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"productName": "Kimi Switch", "productName": "Kimi Switch",
"version": "0.7.9", "version": "0.7.12",
"identifier": "com.kimiswitch.app", "identifier": "com.kimiswitch.app",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+45 -5
View File
@@ -1,7 +1,14 @@
import { useEffect, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useTranslation } from "../i18n"; import { useTranslation } from "../i18n";
import type { TranslationKey } from "../i18n/zh"; import type { TranslationKey } from "../i18n/zh";
import { getAgentSettings, setAgentSettings } from "../lib/agent-settings"; import { getAgentSettings, setAgentSettings } from "../lib/agent-settings";
import type { AgentSettings, Hook, PermissionRule } from "../types"; import type {
AgentSettings,
ExperimentalEnvStatus,
Hook,
PermissionRule,
} from "../types";
import { Card, Checkbox, NumberField, Segmented } from "./ui/controls"; import { Card, Checkbox, NumberField, Segmented } from "./ui/controls";
interface AgentSettingsPanelProps { interface AgentSettingsPanelProps {
@@ -9,12 +16,12 @@ interface AgentSettingsPanelProps {
onChange: (nextRawOther: unknown) => void; onChange: (nextRawOther: unknown) => void;
} }
const THINKING_LEVELS = ["low", "medium", "high", "max"] as const; // Upstream removed the "max" effort tier (auto-migrates to "high").
const THINKING_LEVELS = ["low", "medium", "high"] as const;
const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = { const THINKING_LABELS: Record<(typeof THINKING_LEVELS)[number], TranslationKey> = {
low: "thinkingLow", low: "thinkingLow",
medium: "thinkingMedium", medium: "thinkingMedium",
high: "thinkingHigh", high: "thinkingHigh",
max: "thinkingMax",
}; };
const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const; const PERMISSION_DECISIONS = ["allow", "deny", "ask"] as const;
@@ -40,9 +47,23 @@ const COMMON_EVENTS = [
export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) { export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const settings = getAgentSettings(rawOther); const settings = getAgentSettings(rawOther);
/**
* KIMI_CODE_LEGACY_FLAG=1 keeps the v1 loop_control keys dual-written for
* v1-engine users; the default (v2) writes only max_attempts_per_step.
*/
const [legacyV1, setLegacyV1] = useState(false);
useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status")
.then((env) => {
const value = env?.KIMI_CODE_LEGACY_FLAG ?? "";
setLegacyV1(["1", "true", "yes", "on"].includes(value.trim().toLowerCase()));
})
.catch(() => setLegacyV1(false));
}, []);
const update = (patch: Partial<AgentSettings>) => { const update = (patch: Partial<AgentSettings>) => {
onChange(setAgentSettings(rawOther, patch)); onChange(setAgentSettings(rawOther, patch, { legacyV1 }));
}; };
const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => { const updateThinking = (patch: Partial<AgentSettings["thinking"]>) => {
@@ -95,7 +116,7 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
label={t("thinkingKeep")} label={t("thinkingKeep")}
checked={settings.thinking?.keep === "all"} checked={settings.thinking?.keep === "all"}
disabled={!thinkingEnabled} disabled={!thinkingEnabled}
onChange={(checked) => updateThinking({ keep: checked ? "all" : false })} onChange={(checked) => updateThinking({ keep: checked ? "all" : "off" })}
/> />
<p className="text-xs text-content-muted">{t("thinkingContextHint")}</p> <p className="text-xs text-content-muted">{t("thinkingContextHint")}</p>
</Card> </Card>
@@ -136,6 +157,25 @@ export function AgentSettingsPanel({ rawOther, onChange }: AgentSettingsPanelPro
</Card> </Card>
<Card title={t("permissionRules")}> <Card title={t("permissionRules")}>
{/* kimi-code 0.40.1 `[permission] dangerous_command_guard`. The env
var KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false")
outranks this config at runtime; no env-lock UI here (deliberate
minimal scope). Absent key = upstream default on. */}
<Checkbox
label={t("permissionDangerousGuard")}
checked={settings.permission?.dangerous_command_guard ?? true}
onChange={(checked) =>
update({
permission: {
...settings.permission,
dangerous_command_guard: checked,
},
})
}
/>
<p className="text-xs text-content-muted">
{t("permissionDangerousGuardDesc")}
</p>
<div className="space-y-2"> <div className="space-y-2">
{(settings.permission?.rules ?? []).map((rule, idx) => ( {(settings.permission?.rules ?? []).map((rule, idx) => (
<div key={idx} className="flex items-center gap-2"> <div key={idx} className="flex items-center gap-2">
+9
View File
@@ -54,6 +54,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
case "anthropic": case "anthropic":
case "vertexai": case "vertexai":
return ""; return "";
default:
return "";
} }
} }
// Pi defaults // Pi defaults
@@ -68,6 +70,8 @@ function defaultBaseUrl(agent: Agent, type: ProviderType): string {
case "anthropic": case "anthropic":
case "vertexai": case "vertexai":
return ""; return "";
default:
return "";
} }
} }
@@ -307,6 +311,11 @@ export function ProviderEdit({
{t} {t}
</option> </option>
))} ))}
{!PROVIDER_TYPES.includes(provider.provider_type) && (
<option value={provider.provider_type}>
{t("apiFormatUnknown", { type: provider.provider_type })}
</option>
)}
</select> </select>
</div> </div>
</div> </div>
+79 -23
View File
@@ -9,6 +9,7 @@ import {
forcedEnvValue, forcedEnvValue,
getExperimentalFlags, getExperimentalFlags,
getSubagentModelPool, getSubagentModelPool,
isExperimentalFlagSet,
isFlagLockedByEnv, isFlagLockedByEnv,
isMasterEnvOn, isMasterEnvOn,
removeSubagentPoolEntry, removeSubagentPoolEntry,
@@ -33,12 +34,12 @@ interface SubagentSettingsPageProps {
onBack: () => void; onBack: () => void;
} }
const EFFORTS = ["low", "medium", "high", "max"] as const; // Upstream removed the "max" effort tier (auto-migrates to "high").
const EFFORTS = ["low", "medium", "high"] as const;
const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = { const EFFORT_LABELS: Record<(typeof EFFORTS)[number], TranslationKey> = {
low: "thinkingLow", low: "thinkingLow",
medium: "thinkingMedium", medium: "thinkingMedium",
high: "thinkingHigh", high: "thinkingHigh",
max: "thinkingMax",
}; };
const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = { const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }> = {
@@ -55,6 +56,9 @@ const FLAG_LABELS: Record<string, { name: TranslationKey; desc: TranslationKey }
name: "flagAutoSessionTitle", name: "flagAutoSessionTitle",
desc: "flagAutoSessionTitleDesc", desc: "flagAutoSessionTitleDesc",
}, },
"remote-control": { name: "flagRemoteControl", desc: "flagRemoteControlDesc" },
search_worker: { name: "flagSearchWorker", desc: "flagSearchWorkerDesc" },
file_history: { name: "flagFileHistory", desc: "flagFileHistoryDesc" },
}; };
/** Validation-error i18n key per engine rule, for the pre-write self-check. */ /** Validation-error i18n key per engine rule, for the pre-write self-check. */
@@ -160,6 +164,8 @@ export function SubagentSettingsPage({
const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]); const [poolErrors, setPoolErrors] = useState<PoolValidationError[]>([]);
/** Local draft for the "add pool entry" select (reset after each pick). */ /** Local draft for the "add pool entry" select (reset after each pick). */
const [addSelection, setAddSelection] = useState(""); const [addSelection, setAddSelection] = useState("");
/** WebUI-open button in flight; disables both buttons while non-null. */
const [webuiBusy, setWebuiBusy] = useState<"embedded" | "browser" | null>(null);
useEffect(() => { useEffect(() => {
invoke<ExperimentalEnvStatus>("get_experimental_env_status") invoke<ExperimentalEnvStatus>("get_experimental_env_status")
@@ -183,13 +189,18 @@ export function SubagentSettingsPage({
const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model" const secondaryFlag = EXPERIMENTAL_FLAGS[0]; // "secondary-model"
const otherFlags = EXPERIMENTAL_FLAGS.slice(1); const otherFlags = EXPERIMENTAL_FLAGS.slice(1);
/** Effective state of the secondary-model flag (env overrides config). */ /** Effective state of the secondary-model flag. Mirrors the kimi-code
const secondaryEnabled = masterOn * 0.40.1 priority: single-flag env > explicit [experimental] value >
? true * master env (force-on only) > upstream default. The master env no
: isFlagLockedByEnv(env, secondaryFlag) * longer locks the toggle — only the flag's own env var does. */
? forcedEnvValue(env, secondaryFlag) const secondaryEnabled = isFlagLockedByEnv(env, secondaryFlag)
: flags["secondary-model"] === true; ? forcedEnvValue(env, secondaryFlag)
const secondaryLocked = masterOn || isFlagLockedByEnv(env, secondaryFlag); : isExperimentalFlagSet(rawOther, secondaryFlag.id)
? flags["secondary-model"] === true
: masterOn
? true
: (secondaryFlag.defaultEnabled ?? false);
const secondaryLocked = isFlagLockedByEnv(env, secondaryFlag);
const aliasList = Object.keys(models).sort(); const aliasList = Object.keys(models).sort();
@@ -235,7 +246,9 @@ export function SubagentSettingsPage({
? `${Math.round(n / 1000)}K` ? `${Math.round(n / 1000)}K`
: String(n); : String(n);
const effortLabel = (e: string): string => { const effortLabel = (e: string): string => {
const key = EFFORT_LABELS[e as (typeof EFFORTS)[number]]; // Stored "max" tiers from old configs are shown as "high" (upstream
// removed the tier; it auto-migrates to "high").
const key = EFFORT_LABELS[e === "max" ? "high" : (e as (typeof EFFORTS)[number])];
return key ? t(key) : e; return key ? t(key) : e;
}; };
@@ -296,16 +309,35 @@ export function SubagentSettingsPage({
const renderFlagRow = (flag: ExperimentalFlagDef) => { const renderFlagRow = (flag: ExperimentalFlagDef) => {
const labels = FLAG_LABELS[flag.id]; const labels = FLAG_LABELS[flag.id];
const locked = masterOn || isFlagLockedByEnv(env, flag); // Only the flag's own env var locks the toggle. The master env force-ons
const on = masterOn // undefined flags but an explicit config entry outranks it, so the user
? true // can still flip values here while it is set.
: isFlagLockedByEnv(env, flag) const locked = isFlagLockedByEnv(env, flag);
? forcedEnvValue(env, flag) // Resolution order mirrors kimi-code 0.40.1 flagService:
: flags[flag.id] === true; // single-flag env > explicit [experimental] value (true *or* false) >
// master env (on-only) > upstream default (`defaultEnabled`).
const explicitlySet = isExperimentalFlagSet(rawOther, flag.id);
const on = locked
? forcedEnvValue(env, flag)
: explicitlySet
? flags[flag.id] === true
: masterOn
? true
: (flag.defaultEnabled ?? false);
// Turning the flag off only sticks via an explicit `false` when the
// fallback (master env or the upstream default) would otherwise keep it on.
const explicitFalse = masterOn || flag.defaultEnabled === true;
return ( return (
<div key={flag.id} className="flex items-center gap-3"> <div key={flag.id} className="flex items-center gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-sm text-content-primary">{t(labels.name)}</div> <div className="flex items-center gap-2 text-sm text-content-primary">
{t(labels.name)}
{!locked && !explicitlySet && flag.defaultEnabled && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("flagDefaultOn")}
</span>
)}
</div>
<div className="text-xs text-content-muted"> <div className="text-xs text-content-muted">
{t(labels.desc)} {t(labels.desc)}
<code className="ml-2">{flag.envVar}</code> <code className="ml-2">{flag.envVar}</code>
@@ -321,7 +353,9 @@ export function SubagentSettingsPage({
disabled={locked} disabled={locked}
ariaLabel={t(labels.name)} ariaLabel={t(labels.name)}
onChange={(checked) => onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, flag.id, checked)) onChange(
setExperimentalFlag(rawOther, flag.id, checked, { explicitFalse })
)
} }
/> />
</div> </div>
@@ -355,29 +389,37 @@ export function SubagentSettingsPage({
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
<button <button
type="button" type="button"
disabled={webuiBusy !== null}
onClick={async () => { onClick={async () => {
setWebuiBusy("embedded");
try { try {
await invoke("open_kimi_web_embedded"); await invoke("open_kimi_web_embedded");
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : String(err)); alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
} }
}} }}
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none" className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-500 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t("openWebUIEmbedded")} {webuiBusy === "embedded" ? t("webuiOpening") : t("openWebUIEmbedded")}
</button> </button>
<button <button
type="button" type="button"
disabled={webuiBusy !== null}
onClick={async () => { onClick={async () => {
setWebuiBusy("browser");
try { try {
await invoke("open_kimi_web"); await invoke("open_kimi_web");
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : String(err)); alert(err instanceof Error ? err.message : String(err));
} finally {
setWebuiBusy(null);
} }
}} }}
className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none" className="px-3 py-1.5 text-sm border border-border rounded hover:bg-hover-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
> >
{t("openWebUIBrowser")} {webuiBusy === "browser" ? t("webuiOpening") : t("openWebUIBrowser")}
</button> </button>
</div> </div>
</Card> </Card>
@@ -534,6 +576,16 @@ export function SubagentSettingsPage({
<div className="border-t border-border" /> <div className="border-t border-border" />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 text-sm text-content-primary">
{t(FLAG_LABELS[secondaryFlag.id].name)}
{!secondaryLocked &&
!isExperimentalFlagSet(rawOther, secondaryFlag.id) &&
secondaryFlag.defaultEnabled && (
<span className="shrink-0 text-xs text-blue-600 dark:text-blue-400 border border-blue-500/30 rounded px-1">
{t("flagDefaultOn")}
</span>
)}
</div>
<div className="text-xs text-content-muted"> <div className="text-xs text-content-muted">
{t(FLAG_LABELS[secondaryFlag.id].desc)} {t(FLAG_LABELS[secondaryFlag.id].desc)}
<code className="ml-2">{secondaryFlag.envVar}</code> <code className="ml-2">{secondaryFlag.envVar}</code>
@@ -549,7 +601,11 @@ export function SubagentSettingsPage({
disabled={secondaryLocked} disabled={secondaryLocked}
ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)} ariaLabel={t(FLAG_LABELS[secondaryFlag.id].name)}
onChange={(checked) => onChange={(checked) =>
onChange(setExperimentalFlag(rawOther, secondaryFlag.id, checked)) onChange(
setExperimentalFlag(rawOther, secondaryFlag.id, checked, {
explicitFalse: masterOn || secondaryFlag.defaultEnabled === true,
})
)
} }
/> />
</div> </div>
+15 -2
View File
@@ -74,6 +74,7 @@ export const enTranslations: Record<TranslationKey, string> = {
kimiOAuthStart: "Start sign-in", kimiOAuthStart: "Start sign-in",
apiSettings: "API Settings", apiSettings: "API Settings",
apiFormat: "API Format", apiFormat: "API Format",
apiFormatUnknown: "Unknown format ({type}, kept verbatim)",
authField: "Auth Field", authField: "Auth Field",
apiKey: "API Key", apiKey: "API Key",
getApiKeyLink: "Get API Key", getApiKeyLink: "Get API Key",
@@ -137,7 +138,6 @@ export const enTranslations: Record<TranslationKey, string> = {
thinkingLow: "Low", thinkingLow: "Low",
thinkingMedium: "Medium", thinkingMedium: "Medium",
thinkingHigh: "High", thinkingHigh: "High",
thinkingMax: "Max",
thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.", thinkingContextHint: "Thinking uses more context. Ensure the model context length and reserved size are sufficient.",
loopControlSettings: "Loop Control", loopControlSettings: "Loop Control",
maxAttemptsPerStep: "Max attempts per step", maxAttemptsPerStep: "Max attempts per step",
@@ -151,6 +151,9 @@ export const enTranslations: Record<TranslationKey, string> = {
permissionAllow: "Allow", permissionAllow: "Allow",
permissionDeny: "Deny", permissionDeny: "Deny",
permissionAsk: "Ask", permissionAsk: "Ask",
permissionDangerousGuard: "Dangerous Command Guard",
permissionDangerousGuardDesc:
"When on (default): Auto mode refuses dangerous commands (rm -rf, shutdown, dd of=, ...) and other modes always prompt; turning it off restores the previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD overrides this config",
addRule: "+ Add rule", addRule: "+ Add rule",
addCommonRules: "Add common rules", addCommonRules: "Add common rules",
hooks: "Lifecycle Hooks", hooks: "Lifecycle Hooks",
@@ -475,8 +478,17 @@ export const enTranslations: Record<TranslationKey, string> = {
flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn", flagWaitForDesc: "The agent can wait for background tasks to finish within the current turn",
flagAutoSessionTitle: "Auto Session Title", flagAutoSessionTitle: "Auto Session Title",
flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool", flagAutoSessionTitleDesc: "Generate session titles automatically via the managed chat_title tool",
flagRemoteControl: "Remote Control",
flagRemoteControlDesc: "Enable the experimental remote control feature",
flagSearchWorker: "Search Worker",
flagSearchWorkerDesc: "Run the kap-server global search backend in a background worker host",
flagFileHistory: "File History",
flagFileHistoryDesc:
"Snapshot edited files before/after each turn (last 5 turns across 30 sessions, stored in the session data dir) for real whole-file diffs",
flagDefaultOn: "On by default",
lockedByEnv: "Locked by env var", lockedByEnv: "Locked by env var",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG is set: all experimental features are forced on by the env var", masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG is set: flags without an explicit [experimental] entry are forced on; explicit entries in config.toml still win",
secondaryModelSection: "Subagent Model (Secondary Model)", secondaryModelSection: "Subagent Model (Secondary Model)",
secondaryModelDisabledHint: secondaryModelDisabledHint:
"The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first", "The experimental flag \"Subagent Secondary Model\" is off and this config will not take effect — enable the toggle above first",
@@ -525,6 +537,7 @@ export const enTranslations: Record<TranslationKey, string> = {
"Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.", "Open the new Web UI in an independent in-app window, or in your system browser (built into kimi-code 0.33+). Requires the kimi CLI on PATH.",
openWebUIEmbedded: "Open in App", openWebUIEmbedded: "Open in App",
openWebUIBrowser: "Open in Browser", openWebUIBrowser: "Open in Browser",
webuiOpening: "Opening...",
// Plugin marketplace // Plugin marketplace
pluginMarketplace: "Plugin Marketplace", pluginMarketplace: "Plugin Marketplace",
+15 -2
View File
@@ -72,6 +72,7 @@ export const zhTranslations = {
kimiOAuthStart: "开始登录", kimiOAuthStart: "开始登录",
apiSettings: "API 设置", apiSettings: "API 设置",
apiFormat: "API 格式", apiFormat: "API 格式",
apiFormatUnknown: "未知格式({type},原样保留)",
authField: "认证字段", authField: "认证字段",
apiKey: "API Key", apiKey: "API Key",
getApiKeyLink: "获取 API Key", getApiKeyLink: "获取 API Key",
@@ -135,7 +136,6 @@ export const zhTranslations = {
thinkingLow: "低", thinkingLow: "低",
thinkingMedium: "中", thinkingMedium: "中",
thinkingHigh: "高", thinkingHigh: "高",
thinkingMax: "最大",
thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。", thinkingContextHint: "启用思考会占用更多上下文,请确保模型上下文长度和预留空间足够。",
loopControlSettings: "循环控制", loopControlSettings: "循环控制",
maxAttemptsPerStep: "单步最大尝试次数", maxAttemptsPerStep: "单步最大尝试次数",
@@ -149,6 +149,9 @@ export const zhTranslations = {
permissionAllow: "允许", permissionAllow: "允许",
permissionDeny: "拒绝", permissionDeny: "拒绝",
permissionAsk: "询问", permissionAsk: "询问",
permissionDangerousGuard: "危险命令防护",
permissionDangerousGuardDesc:
"开启(默认)时:Auto 模式直接拒绝危险命令(rm -rf、shutdown、dd of= 等),其它模式强制询问;关闭后回归旧行为。环境变量 KIMI_CODE_DANGEROUS_COMMAND_GUARD 会覆盖本配置",
addRule: "+ 添加规则", addRule: "+ 添加规则",
addCommonRules: "添加常用规则", addCommonRules: "添加常用规则",
hooks: "生命周期钩子", hooks: "生命周期钩子",
@@ -470,8 +473,17 @@ export const zhTranslations = {
flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒", flagWaitForDesc: "代理可在当前回合内等待后台任务完成,而无需结束回合再被唤醒",
flagAutoSessionTitle: "自动会话标题", flagAutoSessionTitle: "自动会话标题",
flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题", flagAutoSessionTitleDesc: "通过托管 chat_title 工具自动生成会话标题",
flagRemoteControl: "远程控制",
flagRemoteControlDesc: "启用实验性远程控制功能",
flagSearchWorker: "搜索 Worker",
flagSearchWorkerDesc: "kap-server 全局搜索的后台 worker 宿主服务",
flagFileHistory: "文件历史",
flagFileHistoryDesc:
"记录每轮编辑文件的前后快照(最近 30 个会话×最近 5 轮,存于会话数据目录),用于真实的整文件 diff",
flagDefaultOn: "默认开启",
lockedByEnv: "被环境变量锁定", lockedByEnv: "被环境变量锁定",
masterEnvOnHint: "KIMI_CODE_EXPERIMENTAL_FLAG 已设置:全部实验功能被环境变量强制开启", masterEnvOnHint:
"KIMI_CODE_EXPERIMENTAL_FLAG 已设置:未显式配置的实验项将被强制开启;config.toml 中的显式配置优先生效",
secondaryModelSection: "子代理模型(次主力模型)", secondaryModelSection: "子代理模型(次主力模型)",
secondaryModelDisabledHint: secondaryModelDisabledHint:
"实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关", "实验开关「子代理次主力模型」未启用,本配置不会生效——请先在上方打开对应开关",
@@ -517,6 +529,7 @@ export const zhTranslations = {
"以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。", "以独立窗口在应用内打开新版 Web 界面,也可以选择在系统浏览器打开(kimi-code 0.33+ 内置 code-app;需要 kimi 命令在 PATH 中)。",
openWebUIEmbedded: "在应用内打开", openWebUIEmbedded: "在应用内打开",
openWebUIBrowser: "在浏览器打开", openWebUIBrowser: "在浏览器打开",
webuiOpening: "打开中...",
// Plugin marketplace // Plugin marketplace
pluginMarketplace: "插件市场", pluginMarketplace: "插件市场",
+206
View File
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { getAgentSettings, setAgentSettings } from "./agent-settings";
// ---------------------------------------------------------------------------
// Helpers — drill into the written `[thinking]` / `[loop_control]` sections
// ---------------------------------------------------------------------------
function thinkingOf(raw: unknown): Record<string, unknown> {
const t = (raw as { thinking?: unknown })?.thinking;
return t && typeof t === "object" && !Array.isArray(t)
? (t as Record<string, unknown>)
: {};
}
function loopOf(raw: unknown): Record<string, unknown> {
const l = (raw as { loop_control?: unknown })?.loop_control;
return l && typeof l === "object" && !Array.isArray(l)
? (l as Record<string, unknown>)
: {};
}
// ---------------------------------------------------------------------------
// Reading — legacy off values normalized to "off", "max" → "high"
// ---------------------------------------------------------------------------
describe("getAgentSettings — thinking.keep normalization", () => {
it("normalizes a legacy boolean `false` to \"off\"", () => {
expect(getAgentSettings({ thinking: { keep: false } }).thinking?.keep).toBe(
"off"
);
});
it("normalizes the other legacy off values (0, null, \"no\", \"none\") to \"off\"", () => {
for (const v of [0, null, "no", "none"]) {
expect(
getAgentSettings({ thinking: { keep: v } }).thinking?.keep
).toBe("off");
}
});
it("keeps \"all\" and \"off\" as-is on read", () => {
expect(getAgentSettings({ thinking: { keep: "all" } }).thinking?.keep).toBe(
"all"
);
expect(getAgentSettings({ thinking: { keep: "off" } }).thinking?.keep).toBe(
"off"
);
});
it("keeps the default \"all\" when the key is absent", () => {
expect(getAgentSettings({}).thinking?.keep).toBe("all");
});
});
describe("getAgentSettings — effort \"max\" read mapping", () => {
it("normalizes a stored \"max\" to \"high\" on read", () => {
expect(getAgentSettings({ thinking: { effort: "max" } }).thinking?.effort).toBe(
"high"
);
});
});
// ---------------------------------------------------------------------------
// Writing — keep: "all" round-trips, "off" is written for legacy off values,
// absent keep is not materialized
// ---------------------------------------------------------------------------
describe("setAgentSettings — keep serialization", () => {
it("round-trips keep = \"all\"", () => {
const raw = { thinking: { keep: "all" } };
const next = setAgentSettings(raw, { thinking: { keep: "all" } });
expect(thinkingOf(next).keep).toBe("all");
});
it("writes \"off\" (string) for a legacy keep = false, never a boolean", () => {
const next = setAgentSettings({ thinking: { keep: false } }, {});
expect(thinkingOf(next).keep).toBe("off");
});
it("does not write the keep key when it was never set (absent in raw and patch)", () => {
expect(thinkingOf(setAgentSettings({}, {}))).not.toHaveProperty("keep");
const next = setAgentSettings(
{ thinking: { enabled: true } },
{ thinking: { effort: "high" } }
);
expect(thinkingOf(next)).not.toHaveProperty("keep");
expect(thinkingOf(next).effort).toBe("high");
});
it("writes the key when an absent raw config's keep is explicitly toggled off", () => {
const next = setAgentSettings({}, { thinking: { keep: "off" } });
expect(thinkingOf(next).keep).toBe("off");
});
});
// ---------------------------------------------------------------------------
// loop_control — v2 default writes only max_attempts_per_step; legacyV1 opts
// dual-writes; read fallback from the v1 key is preserved
// ---------------------------------------------------------------------------
describe("loop_control — v1/v2 key handling", () => {
const raw = { loop_control: { max_attempts_per_step: 5, max_retries_per_step: 5 } };
it("default (v2): a save strips the legacy max_retries_per_step key", () => {
const next = setAgentSettings(raw, { loop_control: { reserved_context_size: 60000 } });
expect(loopOf(next).max_attempts_per_step).toBe(5);
expect(loopOf(next)).not.toHaveProperty("max_retries_per_step");
});
it("default (v2): a dual-write patch ends up with only the v2 key", () => {
const next = setAgentSettings(
raw,
{ loop_control: { max_attempts_per_step: 7, max_retries_per_step: 7 } }
);
expect(loopOf(next).max_attempts_per_step).toBe(7);
expect(loopOf(next)).not.toHaveProperty("max_retries_per_step");
});
it("legacyV1: both keys are written", () => {
const next = setAgentSettings(
raw,
{ loop_control: { max_attempts_per_step: 7, max_retries_per_step: 7 } },
{ legacyV1: true }
);
expect(loopOf(next).max_attempts_per_step).toBe(7);
expect(loopOf(next).max_retries_per_step).toBe(7);
});
it("read fallback keeps a max_retries_per_step-only config editable", () => {
const s = getAgentSettings({ loop_control: { max_retries_per_step: 4 } });
expect(s.loop_control?.max_attempts_per_step).toBe(4);
expect(s.loop_control?.max_retries_per_step).toBe(4);
});
});
// ---------------------------------------------------------------------------
// [permission] dangerous_command_guard (kimi-code 0.40.1) — absent key means
// upstream default ON; an explicit false must survive even with no rules
// ---------------------------------------------------------------------------
describe("permission.dangerous_command_guard", () => {
it("reads as undefined when the key is absent (default on)", () => {
expect(
getAgentSettings({}).permission?.dangerous_command_guard
).toBeUndefined();
expect(
getAgentSettings({ permission: { rules: [] } }).permission
?.dangerous_command_guard
).toBeUndefined();
});
it("reads an explicit true / false", () => {
expect(
getAgentSettings({ permission: { dangerous_command_guard: false } })
.permission?.dangerous_command_guard
).toBe(false);
expect(
getAgentSettings({ permission: { dangerous_command_guard: true } })
.permission?.dangerous_command_guard
).toBe(true);
});
it("writes guard=false even with no rules (section kept)", () => {
const next = setAgentSettings(
{},
{ permission: { rules: [], dangerous_command_guard: false } }
) as { permission?: Record<string, unknown> };
expect(next.permission).toEqual({ dangerous_command_guard: false });
});
it("writes guard together with rules", () => {
const rules = [{ decision: "allow" as const, pattern: "Read" }];
const next = setAgentSettings(
{},
{ permission: { rules, dangerous_command_guard: true } }
) as { permission?: Record<string, unknown> };
expect(next.permission).toEqual({
rules,
dangerous_command_guard: true,
});
});
it("a rules-only update carries the existing guard through", () => {
const raw = { permission: { dangerous_command_guard: false } };
const next = setAgentSettings(raw, {
permission: { rules: [{ decision: "deny" as const, pattern: "Bash" }] },
}) as { permission?: Record<string, unknown> };
expect(next.permission?.dangerous_command_guard).toBe(false);
expect(next.permission?.rules).toHaveLength(1);
});
it("omits an empty rules array; keeps an explicit guard on plain saves", () => {
const raw = { permission: { dangerous_command_guard: true } };
const off = setAgentSettings(raw, {
permission: { rules: [], dangerous_command_guard: false },
}) as { permission?: Record<string, unknown> };
expect(off.permission).toEqual({ dangerous_command_guard: false });
expect(off.permission).not.toHaveProperty("rules");
// An explicit true is materialized on plain saves (harmless, and it
// documents the state the UI toggle shows).
const on = setAgentSettings(raw, {}) as {
permission?: Record<string, unknown>;
};
expect(on.permission).toEqual({ dangerous_command_guard: true });
});
});
+69 -13
View File
@@ -49,20 +49,43 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
) { ) {
loop.max_attempts_per_step = sectionLoop.max_retries_per_step; loop.max_attempts_per_step = sectionLoop.max_retries_per_step;
} }
const thinking = {
...DEFAULT_SETTINGS.thinking,
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
};
// Upstream engines only accept string off values ("off"/"none"/"no"; see
// KEEP_OFF_VALUES) — a `false` from an old config fails the v2 validator
// and the v1 strict parse. Normalize every legacy off value to "off" for
// display; the serialization path (setAgentSettings) writes the string.
if (thinking.keep !== undefined && thinking.keep !== "all") {
thinking.keep = "off";
}
// The "max" effort tier was removed upstream (auto-migrates to "high");
// old configs still carrying it are shown as "high" (not rewritten on read).
if (thinking.effort === "max") {
thinking.effort = "high";
}
const sectionPermission = getSection<AgentSettings["permission"]>(
rawOther,
"permission"
);
return { return {
thinking: { thinking,
...DEFAULT_SETTINGS.thinking,
...getSection<AgentSettings["thinking"]>(rawOther, "thinking"),
},
loop_control: loop, loop_control: loop,
background: { background: {
...DEFAULT_SETTINGS.background, ...DEFAULT_SETTINGS.background,
...getSection<AgentSettings["background"]>(rawOther, "background"), ...getSection<AgentSettings["background"]>(rawOther, "background"),
}, },
permission: { permission: {
rules: rules: sectionPermission?.rules ?? [],
getSection<AgentSettings["permission"]>(rawOther, "permission")?.rules ?? // `dangerous_command_guard` stays undefined unless the config carries
[], // an explicit boolean — upstream defaults it to ON (kimi-code 0.40.1),
// so an absent key means "on". The env var
// KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal "true"/"false" only)
// outranks this config value at kimi-code runtime.
...(typeof sectionPermission?.dangerous_command_guard === "boolean"
? { dangerous_command_guard: sectionPermission.dangerous_command_guard }
: {}),
}, },
hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [], hooks: getSection<AgentSettings["hooks"]>(rawOther, "hooks") ?? [],
}; };
@@ -70,25 +93,58 @@ export function getAgentSettings(rawOther: unknown): AgentSettings {
export function setAgentSettings( export function setAgentSettings(
rawOther: unknown, rawOther: unknown,
patch: Partial<AgentSettings> patch: Partial<AgentSettings>,
opts?: { legacyV1?: boolean }
): unknown { ): unknown {
const root = { ...asRecord(rawOther) }; const root = { ...asRecord(rawOther) };
const current = getAgentSettings(rawOther); const current = getAgentSettings(rawOther);
// Explicit guard value wins over the carried-over one; undefined keeps the
// key absent (upstream default = on).
const guard =
patch.permission?.dangerous_command_guard ??
current.permission?.dangerous_command_guard;
const permission: NonNullable<AgentSettings["permission"]> = {
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
...(typeof guard === "boolean" ? { dangerous_command_guard: guard } : {}),
};
const next: AgentSettings = { const next: AgentSettings = {
thinking: { ...current.thinking, ...patch.thinking }, thinking: { ...current.thinking, ...patch.thinking },
loop_control: { ...current.loop_control, ...patch.loop_control }, loop_control: { ...current.loop_control, ...patch.loop_control },
background: { ...current.background, ...patch.background }, background: { ...current.background, ...patch.background },
permission: { permission,
rules: patch.permission?.rules ?? current.permission?.rules ?? [],
},
hooks: patch.hooks ?? current.hooks ?? [], hooks: patch.hooks ?? current.hooks ?? [],
}; };
// `keep` is only written when the source config explicitly carries it or the
// patch sets it — an absent key keeps the engine default ("all") instead of
// being materialized, so a plain save does not add the key.
const rawThinking = asRecord(getSection(rawOther, "thinking"));
const patchHasKeep = patch.thinking !== undefined && "keep" in patch.thinking;
if (!("keep" in rawThinking) && !patchHasKeep) {
delete next.thinking?.keep;
}
// The v2 engine (default) only reads max_attempts_per_step; the legacy v1
// key is stripped on save unless KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat).
if (!opts?.legacyV1) {
delete next.loop_control?.max_retries_per_step;
}
if (next.thinking) root.thinking = next.thinking; if (next.thinking) root.thinking = next.thinking;
if (next.loop_control) root.loop_control = next.loop_control; if (next.loop_control) root.loop_control = next.loop_control;
if (next.background) root.background = next.background; if (next.background) root.background = next.background;
if (next.permission?.rules && next.permission.rules.length > 0) { // Keep `[permission]` when it carries rules *or* an explicit
root.permission = next.permission; // dangerous_command_guard — dropping the section would silently lose a
// guard=false write (absent key = upstream default on). An empty rules
// array is omitted rather than written as `rules = []`.
const hasRules = !!permission.rules && permission.rules.length > 0;
const hasGuard = typeof permission.dangerous_command_guard === "boolean";
if (hasRules || hasGuard) {
root.permission = {
...(hasRules ? { rules: permission.rules } : {}),
...(hasGuard
? { dangerous_command_guard: permission.dangerous_command_guard }
: {}),
};
} else { } else {
delete root.permission; delete root.permission;
} }
+6293 -3752
View File
File diff suppressed because it is too large. Load diff
+5304 -3101
View File
File diff suppressed because it is too large. Load diff
+102
View File
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
EXPERIMENTAL_FLAGS,
getExperimentalFlags,
getSecondaryModel, getSecondaryModel,
getSubagentModelPool, getSubagentModelPool,
isExperimentalFlagSet,
removeSubagentPoolEntry, removeSubagentPoolEntry,
setExperimentalFlag,
setSecondaryModelOnly, setSecondaryModelOnly,
setSubagentDefault, setSubagentDefault,
setSubagentForce, setSubagentForce,
@@ -231,6 +235,31 @@ describe("pool mutations", () => {
expect(section(updated).default_model).toBe("kimi-k1"); expect(section(updated).default_model).toBe("kimi-k1");
}); });
it("upsertSubagentPoolEntry materializes the implicit default into a new table", () => {
// Implicit single-entry form (default_model set, no models table): adding a
// second alias must pull the default into the table, otherwise the result
// violates "default_model must be a pool key" (rule 3) and gets rejected.
const raw = rawWith({ default_model: "kimi-k1", force: false });
const added = upsertSubagentPoolEntry(raw, "kimi-k2", "");
expect(modelsOf(added)).toEqual({ "kimi-k1": "", "kimi-k2": "" });
expect(section(added).default_model).toBe("kimi-k1");
const pool = getSubagentModelPool(added);
expect(pool).toBeDefined();
expect(
validateSubagentPool(pool!, ["kimi-k1", "kimi-k2"]),
).toEqual([]);
});
it("upsertSubagentPoolEntry upgrades a legacy `model`-only section to a valid pool", () => {
const raw = rawWith({ model: "kimi-k1" });
const added = upsertSubagentPoolEntry(raw, "kimi-k2", "K2");
expect(modelsOf(added)).toEqual({ "kimi-k1": "", "kimi-k2": "K2" });
expect(section(added).default_model).toBe("kimi-k1");
expect(section(added).model).toBe("kimi-k1");
const pool = getSubagentModelPool(added);
expect(validateSubagentPool(pool!, ["kimi-k1", "kimi-k2"])).toEqual([]);
});
it("removeSubagentPoolEntry keeps the default when removing a non-default alias", () => { it("removeSubagentPoolEntry keeps the default when removing a non-default alias", () => {
const raw = rawWith({ const raw = rawWith({
default_model: "kimi-k1", default_model: "kimi-k1",
@@ -424,3 +453,76 @@ describe("validateSubagentPool", () => {
expect(errors).toContainEqual({ key: "forceExcludesModels" }); expect(errors).toContainEqual({ key: "forceExcludesModels" });
}); });
}); });
// ---------------------------------------------------------------------------
// [experimental] — flag registry (kimi-code 0.40.1) + write semantics
// ---------------------------------------------------------------------------
describe("experimental flag registry", () => {
it("mirrors the 0.40.1 v2 registry (10 flags incl. file_history)", () => {
expect(EXPERIMENTAL_FLAGS.map((f) => f.id)).toEqual([
"secondary-model",
"tool-select",
"persistence_minidb_readmodel",
"tower",
"subagent_fork",
"wait_for",
"auto_session_title",
"remote-control",
"search_worker",
"file_history",
]);
const fh = EXPERIMENTAL_FLAGS.find((f) => f.id === "file_history");
expect(fh?.envVar).toBe("KIMI_CODE_EXPERIMENTAL_FILE_HISTORY");
expect(fh?.defaultEnabled).toBeUndefined();
});
it("secondary-model is on by default since 0.40.1", () => {
expect(
EXPERIMENTAL_FLAGS.find((f) => f.id === "secondary-model")?.defaultEnabled
).toBe(true);
});
});
describe("setExperimentalFlag", () => {
const rawExp = (section: Record<string, unknown>) => ({
experimental: section,
});
it("enabling writes true; disabling a default-off flag deletes the key", () => {
const on = setExperimentalFlag({}, "tool-select", true) as {
experimental: Record<string, unknown>;
};
expect(on.experimental["tool-select"]).toBe(true);
const off = setExperimentalFlag(on, "tool-select", false) as Record<
string,
unknown
>;
expect(off).not.toHaveProperty("experimental"); // empty section dropped
});
it("disabling a default-on flag with explicitFalse writes a literal false", () => {
const next = setExperimentalFlag(
rawExp({ wait_for: true }),
"wait_for",
false,
{ explicitFalse: true }
) as { experimental: Record<string, unknown> };
expect(next.experimental.wait_for).toBe(false);
expect(isExperimentalFlagSet(next, "wait_for")).toBe(true);
expect(getExperimentalFlags(next).wait_for).toBe(false);
});
it("an explicit false coexists with other flags and keeps the section", () => {
const next = setExperimentalFlag(
rawExp({ "secondary-model": false }),
"tower",
true,
{ explicitFalse: true }
) as { experimental: Record<string, unknown> };
expect(next.experimental).toEqual({
"secondary-model": false,
tower: true,
});
});
});
+67 -9
View File
@@ -28,32 +28,57 @@ export interface ExperimentalFlagDef {
id: string; id: string;
/** Single-feature env var that can force the flag (locks the UI toggle). */ /** Single-feature env var that can force the flag (locks the UI toggle). */
envVar: string; envVar: string;
/** Feature turned on by default upstream; an absent config key falls back
* to this, while an explicit `false` in `[experimental]` still wins. */
defaultEnabled?: boolean;
} }
/** Known experimental flags — mirrors the kimi-code v2 flag registry /** Known experimental flags — mirrors the kimi-code v2 flag registry
* (the per-feature flag.ts files under packages/agent-core-v2/src: * (the per-feature flag.ts files under packages/agent-core-v2/src:
* secondary-model, tool-select, persistence_minidb_readmodel, tower, * secondary-model, tool-select, persistence_minidb_readmodel, tower,
* subagent_fork, wait_for, auto_session_title). `acp-v2` was removed * subagent_fork, wait_for, auto_session_title, remote-control,
* upstream and is dropped here. */ * search_worker, file_history). `acp-v2` was removed upstream and is
* dropped here. Keep the env-var list in sync with
* `get_experimental_env_status` in src-tauri/src/commands.rs. */
export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [ export const EXPERIMENTAL_FLAGS: ExperimentalFlagDef[] = [
{ id: "secondary-model", envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL" }, {
id: "secondary-model",
envVar: "KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL",
defaultEnabled: true, // on by default since kimi-code 0.40.1
},
{ id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" }, { id: "tool-select", envVar: "KIMI_CODE_EXPERIMENTAL_TOOL_SELECT" },
{ {
id: "persistence_minidb_readmodel", id: "persistence_minidb_readmodel",
envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL", envVar: "KIMI_CODE_EXPERIMENTAL_PERSISTENCE_MINIDB_READMODEL",
defaultEnabled: true,
}, },
{ id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" }, { id: "tower", envVar: "KIMI_CODE_EXPERIMENTAL_TOWER" },
{ id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" }, { id: "subagent_fork", envVar: "KIMI_CODE_EXPERIMENTAL_SUBAGENT_FORK" },
{ id: "wait_for", envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR" }, {
id: "wait_for",
envVar: "KIMI_CODE_EXPERIMENTAL_WAIT_FOR",
defaultEnabled: true,
},
{ {
id: "auto_session_title", id: "auto_session_title",
envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE", envVar: "KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE",
}, },
{ id: "remote-control", envVar: "KIMI_CODE_EXPERIMENTAL_REMOTE_CONTROL" },
{
id: "search_worker",
envVar: "KIMI_CODE_EXPERIMENTAL_SEARCH_WORKER",
defaultEnabled: true,
},
{ id: "file_history", envVar: "KIMI_CODE_EXPERIMENTAL_FILE_HISTORY" },
]; ];
export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG"; export const EXPERIMENTAL_MASTER_ENV = "KIMI_CODE_EXPERIMENTAL_FLAG";
/** Whether the master env var is set to a truthy value (locks every flag on). */ /** Whether the master env var is set to a truthy value. Since kimi-code
* 0.40.1 it only force-ONs flags that have *no* explicit `[experimental]`
* entry (an explicit config value — true or false — outranks it, and the
* master env can never force a flag off). It locks no UI toggles; only a
* flag's own single-feature env var does. */
export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean { export function isMasterEnvOn(env: ExperimentalEnvStatus | null): boolean {
return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]); return isTruthyEnv(env?.[EXPERIMENTAL_MASTER_ENV]);
} }
@@ -87,16 +112,33 @@ export function getExperimentalFlags(rawOther: unknown): Record<string, boolean>
return out; return out;
} }
/** Set one flag in `[experimental]`. Removing all flags drops the section. */ /** Whether a flag is explicitly written in `[experimental]` (true *or* false).
* An absent flag falls back to the upstream default (`defaultEnabled`). */
export function isExperimentalFlagSet(rawOther: unknown, id: string): boolean {
return Object.prototype.hasOwnProperty.call(
asRecord(asRecord(rawOther)["experimental"]),
id
);
}
/** Set one flag in `[experimental]`. Removing all flags drops the section.
* Pass `explicitFalse` when deleting the key would NOT express "off" —
* i.e. the flag is on by upstream default (`defaultEnabled`) or the master
* env force-ons undefined flags — and write a literal `false` instead,
* which outranks both under the kimi-code 0.40.1 priority
* (single env > explicit config > master env > default). */
export function setExperimentalFlag( export function setExperimentalFlag(
rawOther: unknown, rawOther: unknown,
id: string, id: string,
enabled: boolean enabled: boolean,
opts?: { explicitFalse?: boolean }
): unknown { ): unknown {
const root = { ...asRecord(rawOther) }; const root = { ...asRecord(rawOther) };
const flags = { ...getExperimentalFlags(rawOther) }; const flags = { ...getExperimentalFlags(rawOther) };
if (enabled) { if (enabled) {
flags[id] = true; flags[id] = true;
} else if (opts?.explicitFalse) {
flags[id] = false;
} else { } else {
delete flags[id]; delete flags[id];
} }
@@ -256,8 +298,11 @@ export function setSubagentModelPool(
return root; return root;
} }
/** Add or update one alias in the pool `models` table. The rest of the /** Add or update one alias in the pool `models` table. When the section is
* section (default, force, unknown fields) is preserved untouched. */ * the implicit single-entry form (a `default_model`/`model` but no table),
* the effective default is materialized into the table first — otherwise the
* new table would fail the engine rule "default_model must be a pool key".
* The rest of the section (force, unknown fields) is preserved untouched. */
export function upsertSubagentPoolEntry( export function upsertSubagentPoolEntry(
rawOther: unknown, rawOther: unknown,
alias: string, alias: string,
@@ -269,8 +314,21 @@ export function upsertSubagentPoolEntry(
for (const [k, v] of Object.entries(section)) next[k] = v; for (const [k, v] of Object.entries(section)) next[k] = v;
const models: Record<string, unknown> = {}; const models: Record<string, unknown> = {};
for (const [k, v] of Object.entries(asRecord(next.models))) models[k] = v; for (const [k, v] of Object.entries(asRecord(next.models))) models[k] = v;
const effectiveDefault =
typeof next.default_model === "string" && next.default_model !== ""
? next.default_model
: typeof next.model === "string" && next.model !== ""
? next.model
: undefined;
if (effectiveDefault !== undefined && !(effectiveDefault in models)) {
models[effectiveDefault] = "";
}
models[alias] = description; models[alias] = description;
next.models = models; next.models = models;
if (effectiveDefault !== undefined) {
next.default_model = effectiveDefault;
next.model = effectiveDefault;
}
root.secondary_model = next; root.secondary_model = next;
return root; return root;
} }
+31 -3
View File
@@ -6,7 +6,9 @@ export type ProviderType =
| "openai" | "openai"
| "openai_responses" | "openai_responses"
| "google-genai" | "google-genai"
| "vertexai"; | "vertexai"
/** Any other string the CLI writes — preserved verbatim on round-trip. */
| (string & {});
export interface Provider { export interface Provider {
name: string; name: string;
@@ -62,6 +64,12 @@ export interface Config {
providers: Record<string, Provider>; providers: Record<string, Provider>;
models: Record<string, Model>; models: Record<string, Model>;
raw_other?: unknown; raw_other?: unknown;
/**
* Top-level section keys captured at import time by the Rust side.
* Export drops only baseline keys absent from raw_other (sections the
* user removed in the UI); CLI-added sections are preserved.
*/
imported_section_keys?: string[];
} }
/** /**
@@ -92,7 +100,18 @@ export interface DiscoveredModel {
export interface ThinkingConfig { export interface ThinkingConfig {
enabled?: boolean; enabled?: boolean;
/**
* Effort tier. `max` is read-compatible only — upstream removed the tier
* (old configs auto-migrate to `high`); the UI normalizes it and the
* serialization path never writes it.
*/
effort?: "low" | "medium" | "high" | "max"; effort?: "low" | "medium" | "high" | "max";
/**
* Keep thinking content. The legacy off values (`false`, `0`, "no", "none",
* `null`) are read-compatible — old configs may carry them; the UI
* normalizes them to `"off"` and the serialization path never emits a
* boolean (upstream engines only accept string off values).
*/
keep?: "all" | false | 0 | "no" | "off" | "none" | null; keep?: "all" | false | 0 | "no" | "off" | "none" | null;
} }
@@ -101,7 +120,8 @@ export interface LoopControlConfig {
max_attempts_per_step?: number; max_attempts_per_step?: number;
/** v2 engine key (kimi-code 0.33+): `max_steps_per_run` was renamed. */ /** v2 engine key (kimi-code 0.33+): `max_steps_per_run` was renamed. */
max_steps_per_turn?: number; max_steps_per_turn?: number;
/** Legacy v1 key — written in sync so KIMI_CODE_LEGACY_FLAG=1 still works. */ /** Legacy v1 key — read fallback only; written/kept only when
* KIMI_CODE_LEGACY_FLAG=1 (v1 engine compat). v2 saves strip it. */
max_retries_per_step?: number; max_retries_per_step?: number;
reserved_context_size?: number; reserved_context_size?: number;
} }
@@ -129,7 +149,15 @@ export interface AgentSettings {
thinking?: ThinkingConfig; thinking?: ThinkingConfig;
loop_control?: LoopControlConfig; loop_control?: LoopControlConfig;
background?: BackgroundConfig; background?: BackgroundConfig;
permission?: { rules?: PermissionRule[] }; permission?: {
rules?: PermissionRule[];
/** kimi-code 0.40.1 `[permission]` key. Default true when the key is
* absent: Auto mode refuses dangerous commands (rm -rf, shutdown,
* dd of=, …) and other modes always prompt; false restores the
* previous behavior. Env KIMI_CODE_DANGEROUS_COMMAND_GUARD (literal
* "true"/"false" only) outranks this config. */
dangerous_command_guard?: boolean;
};
hooks?: Hook[]; hooks?: Hook[];
} }