diff --git a/CHANGELOG.md b/CHANGELOG.md index acb5ec7b..615254d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.52] - 2026-06-03 + +### Added + +- **SiliconFlow China region provider.** Added the `siliconflow-CN` provider + variant for the China regional endpoint, sharing the existing + `[providers.siliconflow]` credentials and `SILICONFLOW_API_KEY` slot + instead of creating a second credential namespace; the provider picker and + registry docs now expose the regional route explicitly (#2588, #2615). +- **Multimodal `/attach` image forwarding.** Attached images are now sent as + OpenAI-compatible `image_url` content blocks so multimodal providers can + actually see image attachments (#2584, #2587, #2607). +- **Sub-agent lifecycle hooks and runtime metadata.** Sub-agent spawn/complete + hook events, mode-change runtime messages, mode metadata on turns, localized + context-inspector strings, and drag-to-resize sidebar width are included in + this release slice. + +### Fixed + +- **Sub-agents now auto-cancel after stale heartbeats.** Running sub-agents + track manager-visible progress and are auto-cancelled after the configurable + `[subagents] heartbeat_timeout_secs` window (default 300s), releasing their + concurrency slot and unblocking parent turns that would otherwise wait + forever (#2603, #2614, #2620). +- **Work panel state survives transient lock misses.** The sidebar caches the + last successful Work summary so checklist and strategy progress no longer + disappear into "Work state updating..." while the engine briefly owns the + shared todo/plan locks (#2606, #2616). +- **SiliconFlow-CN no longer breaks main.** Filled the missing CLI provider + exhaustiveness arms and removed the duplicate/unreachable TUI config arms + left by the #2615 landing; direct auth now stores the China-region variant in + the shared SiliconFlow provider table (#2616, #2618, #2619). +- **v0.8.51 image-attach closure corrected.** The `/attach` multimodal fix + landed after the v0.8.51 tag, so this release is the first version that + actually contains it for users installing from the published release line + (#2584, #2607). +- **Legacy SSE MCP reconnects are retryable again.** Closed or reset + `POST /messages` requests on stale legacy SSE sessions now trigger the same + reconnect-and-retry path as closed SSE streams, removing a release-gate flake + and matching the intended recovery behavior (#2597). + +### Community + +Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588), +**@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616), +**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603, +#2620) for reports, patches, retesting, and release-stabilization signals that +shaped this pass. + ## [0.8.51] - 2026-06-02 ### Added @@ -5329,7 +5378,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...HEAD +[0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52 [0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51 [0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50 [0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49 diff --git a/Cargo.lock b/Cargo.lock index 9b6465ab..80815155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -803,7 +803,7 @@ checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" [[package]] name = "codewhale-agent" -version = "0.8.51" +version = "0.8.52" dependencies = [ "codewhale-config", "serde", @@ -811,7 +811,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "axum", @@ -836,7 +836,7 @@ dependencies = [ [[package]] name = "codewhale-cli" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "chrono", @@ -863,7 +863,7 @@ dependencies = [ [[package]] name = "codewhale-config" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "codewhale-execpolicy", @@ -877,7 +877,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "chrono", @@ -895,7 +895,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "codewhale-protocol", @@ -904,7 +904,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "async-trait", @@ -918,7 +918,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "serde", @@ -927,7 +927,7 @@ dependencies = [ [[package]] name = "codewhale-protocol" -version = "0.8.51" +version = "0.8.52" dependencies = [ "serde", "serde_json", @@ -935,7 +935,7 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "reqwest", @@ -946,7 +946,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.8.51" +version = "0.8.52" dependencies = [ "dirs", "keyring", @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "chrono", @@ -971,7 +971,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "async-trait", @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.8.51" +version = "0.8.52" dependencies = [ "anyhow", "arboard", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "codewhale-tui-core" -version = "0.8.51" +version = "0.8.52" [[package]] name = "colorchoice" diff --git a/Cargo.toml b/Cargo.toml index 7daf7ea1..260c2058 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"] resolver = "2" [workspace.package] -version = "0.8.51" +version = "0.8.52" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index a3967b51..c4a2ab64 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,5 +7,5 @@ repository.workspace = true description = "Model/provider registry and fallback strategy for DeepSeek workspace architecture" [dependencies] -codewhale-config = { path = "../config", version = "0.8.51" } +codewhale-config = { path = "../config", version = "0.8.52" } serde.workspace = true diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index 7d2b3deb..77f80185 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -10,15 +10,15 @@ description = "Codex-style app-server transport for DeepSeek workspace architect anyhow.workspace = true axum.workspace = true clap.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.51" } -codewhale-config = { path = "../config", version = "0.8.51" } -codewhale-core = { path = "../core", version = "0.8.51" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" } -codewhale-hooks = { path = "../hooks", version = "0.8.51" } -codewhale-mcp = { path = "../mcp", version = "0.8.51" } -codewhale-protocol = { path = "../protocol", version = "0.8.51" } -codewhale-state = { path = "../state", version = "0.8.51" } -codewhale-tools = { path = "../tools", version = "0.8.51" } +codewhale-agent = { path = "../agent", version = "0.8.52" } +codewhale-config = { path = "../config", version = "0.8.52" } +codewhale-core = { path = "../core", version = "0.8.52" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" } +codewhale-hooks = { path = "../hooks", version = "0.8.52" } +codewhale-mcp = { path = "../mcp", version = "0.8.52" } +codewhale-protocol = { path = "../protocol", version = "0.8.52" } +codewhale-state = { path = "../state", version = "0.8.52" } +codewhale-tools = { path = "../tools", version = "0.8.52" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 338c6c80..345361d3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,14 +25,14 @@ path = "src/bin/deepseek_legacy_shim.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.51" } -codewhale-app-server = { path = "../app-server", version = "0.8.51" } -codewhale-config = { path = "../config", version = "0.8.51" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" } -codewhale-mcp = { path = "../mcp", version = "0.8.51" } -codewhale-release = { path = "../release", version = "0.8.51" } -codewhale-secrets = { path = "../secrets", version = "0.8.51" } -codewhale-state = { path = "../state", version = "0.8.51" } +codewhale-agent = { path = "../agent", version = "0.8.52" } +codewhale-app-server = { path = "../app-server", version = "0.8.52" } +codewhale-config = { path = "../config", version = "0.8.52" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" } +codewhale-mcp = { path = "../mcp", version = "0.8.52" } +codewhale-release = { path = "../release", version = "0.8.52" } +codewhale-secrets = { path = "../secrets", version = "0.8.52" } +codewhale-state = { path = "../state", version = "0.8.52" } chrono.workspace = true dirs.workspace = true serde.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 85901a06..e1b0a091 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -744,6 +744,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", ProviderKind::Siliconflow => "siliconflow", + ProviderKind::SiliconflowCN => "siliconflow", ProviderKind::Arcee => "arcee", ProviderKind::Moonshot => "moonshot", ProviderKind::Sglang => "sglang", @@ -753,7 +754,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 16] = [ +const PROVIDER_LIST: [ProviderKind; 17] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -765,6 +766,7 @@ const PROVIDER_LIST: [ProviderKind; 16] = [ ProviderKind::Novita, ProviderKind::Fireworks, ProviderKind::Siliconflow, + ProviderKind::SiliconflowCN, ProviderKind::Arcee, ProviderKind::Moonshot, ProviderKind::Sglang, @@ -822,6 +824,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], ProviderKind::Fireworks => &["FIREWORKS_API_KEY"], ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"], + ProviderKind::SiliconflowCN => &["SILICONFLOW_API_KEY"], ProviderKind::Arcee => &["ARCEE_API_KEY"], ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], ProviderKind::Sglang => &["SGLANG_API_KEY"], diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 9ffefcb7..b709ad9d 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -8,8 +8,8 @@ description = "Config schema and precedence model for DeepSeek workspace archite [dependencies] anyhow.workspace = true -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" } -codewhale-secrets = { path = "../secrets", version = "0.8.51" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" } +codewhale-secrets = { path = "../secrets", version = "0.8.52" } dirs.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 84d822ef..1c79e62f 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -2465,7 +2465,9 @@ impl EnvRuntimeOverrides { ProviderKind::XiaomiMimo => self.xiaomi_mimo_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => self.siliconflow_base_url.clone(), + ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => { + self.siliconflow_base_url.clone() + } ProviderKind::Arcee => self.arcee_base_url.clone(), ProviderKind::Moonshot => self.moonshot_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), @@ -2478,7 +2480,9 @@ impl EnvRuntimeOverrides { let model = match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), ProviderKind::Volcengine => self.volcengine_model.clone(), - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => self.siliconflow_model.clone(), + ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => { + self.siliconflow_model.clone() + } ProviderKind::Arcee => self.arcee_model.clone(), ProviderKind::Moonshot => self.moonshot_model.clone(), ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(), diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index a03f2805..9697bcfa 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -9,13 +9,13 @@ description = "Core runtime boundaries for DeepSeek workspace architecture" [dependencies] anyhow.workspace = true chrono.workspace = true -codewhale-agent = { path = "../agent", version = "0.8.51" } -codewhale-config = { path = "../config", version = "0.8.51" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.8.51" } -codewhale-hooks = { path = "../hooks", version = "0.8.51" } -codewhale-mcp = { path = "../mcp", version = "0.8.51" } -codewhale-protocol = { path = "../protocol", version = "0.8.51" } -codewhale-state = { path = "../state", version = "0.8.51" } -codewhale-tools = { path = "../tools", version = "0.8.51" } +codewhale-agent = { path = "../agent", version = "0.8.52" } +codewhale-config = { path = "../config", version = "0.8.52" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.8.52" } +codewhale-hooks = { path = "../hooks", version = "0.8.52" } +codewhale-mcp = { path = "../mcp", version = "0.8.52" } +codewhale-protocol = { path = "../protocol", version = "0.8.52" } +codewhale-state = { path = "../state", version = "0.8.52" } +codewhale-tools = { path = "../tools", version = "0.8.52" } serde_json.workspace = true uuid.workspace = true diff --git a/crates/execpolicy/Cargo.toml b/crates/execpolicy/Cargo.toml index 721064f3..fc0474ff 100644 --- a/crates/execpolicy/Cargo.toml +++ b/crates/execpolicy/Cargo.toml @@ -8,5 +8,5 @@ description = "Execution policy and approval model parity for DeepSeek workspace [dependencies] anyhow.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.51" } +codewhale-protocol = { path = "../protocol", version = "0.8.52" } serde.workspace = true diff --git a/crates/hooks/Cargo.toml b/crates/hooks/Cargo.toml index 1ea4bfd8..e2b67d09 100644 --- a/crates/hooks/Cargo.toml +++ b/crates/hooks/Cargo.toml @@ -10,7 +10,7 @@ description = "Hook dispatch and notifications parity for DeepSeek workspace arc anyhow.workspace = true async-trait.workspace = true chrono.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.51" } +codewhale-protocol = { path = "../protocol", version = "0.8.52" } reqwest.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index ca6314cf..65c9c185 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -681,7 +681,7 @@ impl Secrets { /// | `novita` | `NOVITA_API_KEY` | /// | `nvidia` / `nvidia-nim` / `nim` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY`, `DEEPSEEK_API_KEY` | /// | `fireworks` | `FIREWORKS_API_KEY` | -/// | `siliconflow` | `SILICONFLOW_API_KEY` | +/// | `siliconflow` / `siliconflow-cn` | `SILICONFLOW_API_KEY` | /// | `arcee` / `arcee-ai` | `ARCEE_API_KEY` | /// | `moonshot` / `kimi` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | /// | `sglang` | `SGLANG_API_KEY` | @@ -710,7 +710,8 @@ pub fn env_for(name: &str) -> Option { &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] } "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"], - "siliconflow" | "silicon-flow" | "silicon_flow" => &["SILICONFLOW_API_KEY"], + "siliconflow" | "silicon-flow" | "silicon_flow" | "siliconflow-cn" | "siliconflow_cn" + | "silicon-flow-cn" | "silicon_flow_cn" | "siliconflow-china" => &["SILICONFLOW_API_KEY"], "arcee" | "arcee-ai" | "arcee_ai" => &["ARCEE_API_KEY"], "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], "sglang" | "sg-lang" => &["SGLANG_API_KEY"], @@ -1144,6 +1145,8 @@ mod tests { assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key")); assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key")); assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key")); + assert_eq!(env_for("siliconflow-cn").as_deref(), Some("sf-key")); + assert_eq!(env_for("silicon_flow_cn").as_deref(), Some("sf-key")); // Safety: env mutation guarded by env_lock(). unsafe { std::env::remove_var("SILICONFLOW_API_KEY") }; } diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index e3790c94..7ddca6df 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -9,7 +9,7 @@ description = "Tool invocation lifecycle, schema validation, and scheduler paral [dependencies] anyhow.workspace = true async-trait.workspace = true -codewhale-protocol = { path = "../protocol", version = "0.8.51" } +codewhale-protocol = { path = "../protocol", version = "0.8.52" } serde.workspace = true serde_json.workspace = true thiserror.workspace = true diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index efb14a59..615254d5 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.52] - 2026-06-03 + +### Added + +- **SiliconFlow China region provider.** Added the `siliconflow-CN` provider + variant for the China regional endpoint, sharing the existing + `[providers.siliconflow]` credentials and `SILICONFLOW_API_KEY` slot + instead of creating a second credential namespace; the provider picker and + registry docs now expose the regional route explicitly (#2588, #2615). +- **Multimodal `/attach` image forwarding.** Attached images are now sent as + OpenAI-compatible `image_url` content blocks so multimodal providers can + actually see image attachments (#2584, #2587, #2607). +- **Sub-agent lifecycle hooks and runtime metadata.** Sub-agent spawn/complete + hook events, mode-change runtime messages, mode metadata on turns, localized + context-inspector strings, and drag-to-resize sidebar width are included in + this release slice. + +### Fixed + +- **Sub-agents now auto-cancel after stale heartbeats.** Running sub-agents + track manager-visible progress and are auto-cancelled after the configurable + `[subagents] heartbeat_timeout_secs` window (default 300s), releasing their + concurrency slot and unblocking parent turns that would otherwise wait + forever (#2603, #2614, #2620). +- **Work panel state survives transient lock misses.** The sidebar caches the + last successful Work summary so checklist and strategy progress no longer + disappear into "Work state updating..." while the engine briefly owns the + shared todo/plan locks (#2606, #2616). +- **SiliconFlow-CN no longer breaks main.** Filled the missing CLI provider + exhaustiveness arms and removed the duplicate/unreachable TUI config arms + left by the #2615 landing; direct auth now stores the China-region variant in + the shared SiliconFlow provider table (#2616, #2618, #2619). +- **v0.8.51 image-attach closure corrected.** The `/attach` multimodal fix + landed after the v0.8.51 tag, so this release is the first version that + actually contains it for users installing from the published release line + (#2584, #2607). +- **Legacy SSE MCP reconnects are retryable again.** Closed or reset + `POST /messages` requests on stale legacy SSE sessions now trigger the same + reconnect-and-retry path as closed SSE streams, removing a release-gate flake + and matching the intended recovery behavior (#2597). + +### Community + +Thanks to **@xyuai** (#2587), **@IcedOranges** (#2584), **@BH8GCJ** (#2588), +**@shenjackyuanjie** (#2618, #2619), **@idling11** (#2606, #2616), +**@AresNing** (#2578), **@gordonlu**, **@encyc**, and **@simuusang** (#2603, +#2620) for reports, patches, retesting, and release-stabilization signals that +shaped this pass. + ## [0.8.51] - 2026-06-02 ### Added @@ -66,10 +115,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Assistant turns no longer leave an orphaned role glyph (the stray "blue dot") when a turn streams only whitespace between reasoning and a tool call. -- Scrolling the mouse wheel over the right-hand sidebar no longer leaks into - the transcript scroll. -- The sidebar hover tooltip now appears only for truncated lines, sits below - the cursor, and uses a neutral surface color instead of the warning-orange +- Scrolling the mouse wheel over the right-hand sidebar no longer leaks into the + transcript scroll. +- The sidebar hover tooltip now appears only for truncated lines, sits below the + cursor, and uses a neutral surface color instead of the warning-orange highlight that overlapped neighbouring rows. - Corrected the README's description of the Constitution (Article VII is the hierarchy itself; Article II's truth duty overrides even a user request) to @@ -77,6 +126,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Repaired release-blocking unit and integration tests left failing by the cycle-removal and compaction-threshold refactors (relay instruction, model-reject message, compaction budget, mock-LLM threshold helper). +- Fixed DEC private-mode CSI fragment leakage into composer text after + terminal resets, restoring clean prompt editing (#2592). +- The engine now recovers from turn-level panics instead of killing the + main event loop, keeping the session alive through transient failures + (#2583, #1269). +- Deeply nested files are now discoverable via @-mention and Ctrl+P file + picker; the default walk depth was relaxed to handle monorepo layouts (#2488). +- Command-palette selection stays visible when scrolling through long lists + instead of scrolling off-screen (#2590). +- exec_shell child processes now inherit .NET/NuGet and Windows app-data + environment variables, fixing toolchain resolution on Windows (#1857). +- A warning is emitted when shell/sandbox config keys are nested under + unknown top-level sections instead of being silently ignored (#2589). +- Diff-render now preserves leading whitespace in patch content lines, + fixing an extra-space regression in PR previews (#2591). Thanks @zlh124. +- Model selection from the /model command now persists per-provider across + restarts, with a warning when persistence fails. + +### Community + +Thanks to **@zlh124** (#2591) and **@reidliu41** (#2601) for the fixes +harvested into this release. Thanks also to **@idling11** (#2602), +**@gordonlu** (#2585), **@cyq1017** (#2593), **@xyuai** (#2587, #2584), +and **@IcedOranges** (#2584) for reports, drafts, and investigations +that shaped this release cycle. ## [0.8.50] - 2026-06-02 @@ -5304,7 +5378,8 @@ Welcome — and thank you. - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.8.52...HEAD +[0.8.52]: https://github.com/Hmbown/CodeWhale/compare/v0.8.51...v0.8.52 [0.8.51]: https://github.com/Hmbown/CodeWhale/compare/v0.8.50...v0.8.51 [0.8.50]: https://github.com/Hmbown/CodeWhale/compare/v0.8.49...v0.8.50 [0.8.49]: https://github.com/Hmbown/CodeWhale/compare/v0.8.48...v0.8.49 diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 8f88110d..00b974fb 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,11 +27,11 @@ path = "src/bin/deepseek_tui_legacy_shim.rs" [dependencies] anyhow = "1.0.100" arboard = "3.4" -codewhale-config = { path = "../config", version = "0.8.51" } -codewhale-protocol = { path = "../protocol", version = "0.8.51" } -codewhale-release = { path = "../release", version = "0.8.51" } -codewhale-secrets = { path = "../secrets", version = "0.8.51" } -codewhale-tools = { path = "../tools", version = "0.8.51" } +codewhale-config = { path = "../config", version = "0.8.52" } +codewhale-protocol = { path = "../protocol", version = "0.8.52" } +codewhale-release = { path = "../release", version = "0.8.52" } +codewhale-secrets = { path = "../secrets", version = "0.8.52" } +codewhale-tools = { path = "../tools", version = "0.8.52" } schemaui = { version = "0.12.0", default-features = false, optional = true } async-stream = "0.3.6" async-trait = "0.1" diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5925e2dd..2ca4fd28 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1092,7 +1092,8 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Openrouter | ApiProvider::XiaomiMimo | ApiProvider::Novita - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Sglang | ApiProvider::Volcengine => { body["thinking"] = json!({ "type": "disabled" }); @@ -1128,7 +1129,8 @@ pub(super) fn apply_reasoning_effort( // DeepSeek compatibility: low/medium both map to high ApiProvider::Deepseek | ApiProvider::DeepseekCN - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Sglang | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("high"); @@ -1189,7 +1191,8 @@ pub(super) fn apply_reasoning_effort( "xhigh" | "max" | "highest" => match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Sglang | ApiProvider::Volcengine => { body["reasoning_effort"] = json!("max"); diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index c11e564d..34172c9e 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -30,7 +30,7 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let Some(target) = ApiProvider::parse(name) else { return CommandResult::error(format!( - "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, xiaomi-mimo, novita, fireworks, siliconflow, moonshot, sglang, vllm, or ollama." + "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, xiaomi-mimo, novita, fireworks, siliconflow, siliconflow-CN, moonshot, sglang, vllm, or ollama." )); }; @@ -279,6 +279,19 @@ mod tests { } } + #[test] + fn switch_to_siliconflow_cn_emits_action() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("siliconflow-CN flash")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::SiliconflowCn); + assert_eq!(model.as_deref(), Some("deepseek-v4-flash")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switch_to_sglang_flash_emits_action() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 7955aed9..28091d20 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -32,6 +32,13 @@ pub const MIN_SUBAGENT_API_TIMEOUT_SECS: u64 = 1; /// keeps a misconfigured per-step timeout from masking real model/network /// hangs forever. pub const MAX_SUBAGENT_API_TIMEOUT_SECS: u64 = 1800; +/// Default wall-clock interval without manager-visible sub-agent progress +/// before a running child can be auto-cancelled to release its slot (#2614). +pub const DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 300; +/// Minimum accepted `[subagents] heartbeat_timeout_secs`. +pub const MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 30; +/// Maximum accepted `[subagents] heartbeat_timeout_secs` (1 hour). +pub const MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS: u64 = 3600; pub const DEFAULT_TEXT_MODEL: &str = "deepseek-v4-pro"; pub const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta"; pub const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro"; @@ -178,10 +185,10 @@ impl ApiProvider { "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), "siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow), - "siliconflow-cn" | "siliconflow-CN" - | "silicon-flow-cn" | "silicon-flow-CN" - | "silicon_flow_cn" | "silicon_flow_CN" - | "siliconflow-china" => Some(Self::SiliconflowCn), + "siliconflow-cn" | "siliconflow-CN" | "silicon-flow-cn" | "silicon-flow-CN" + | "silicon_flow_cn" | "silicon_flow_CN" | "siliconflow-china" => { + Some(Self::SiliconflowCn) + } "arcee" | "arcee-ai" | "arcee_ai" => Some(Self::Arcee), "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), @@ -255,6 +262,7 @@ impl ApiProvider { Self::Novita, Self::Fireworks, Self::Siliconflow, + Self::SiliconflowCn, Self::Arcee, Self::Moonshot, Self::Sglang, @@ -697,7 +705,10 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> } return Some(canonical.to_string()); } - if matches!(provider, ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) { + if matches!( + provider, + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + ) { let provider_model = model_for_provider(provider, normalized.clone()); if provider_model != normalized { return Some(provider_model); @@ -1419,6 +1430,12 @@ pub struct SubagentsConfig { /// (1..=1800). Zero or unset uses the legacy 120s default (#1806, #1808). #[serde(default)] pub api_timeout_secs: Option, + /// Wall-clock timeout for a running sub-agent that stops making + /// manager-visible progress. Defaults to 5 minutes and is kept above the + /// per-step API timeout so slow but legitimate model calls are not + /// cancelled before their request timeout can fire (#2614). + #[serde(default)] + pub heartbeat_timeout_secs: Option, } /// `[auto]` table — knobs for the `--model auto` / `/model auto` router. @@ -2311,7 +2328,8 @@ impl Config { | ApiProvider::XiaomiMimo | ApiProvider::Novita | ApiProvider::Fireworks - | ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + | ApiProvider::Siliconflow + | ApiProvider::SiliconflowCn | ApiProvider::Arcee | ApiProvider::Moonshot | ApiProvider::Sglang @@ -2332,7 +2350,6 @@ impl Config { ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL, ApiProvider::Moonshot => { @@ -2387,8 +2404,7 @@ impl Config { ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", ApiProvider::Siliconflow => "siliconflow", - ApiProvider::SiliconflowCn => "siliconflow-CN", - ApiProvider::SiliconflowCn => "siliconflow-CN", + ApiProvider::SiliconflowCn => "siliconflow", ApiProvider::Arcee => "arcee", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", @@ -2658,6 +2674,35 @@ impl Config { raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS) } + /// Resolved no-progress heartbeat timeout for running sub-agents. + /// + /// Reads `[subagents] heartbeat_timeout_secs` and clamps to + /// `[MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS]`. + /// `None` or `0` resolve to the default 300 seconds. The final value is + /// also kept at least 30 seconds above `subagent_api_timeout_secs()` so a + /// configured long model request is not pre-empted by heartbeat cleanup. + #[must_use] + pub fn subagent_heartbeat_timeout_secs(&self) -> u64 { + let raw = self + .subagents + .as_ref() + .and_then(|cfg| cfg.heartbeat_timeout_secs) + .unwrap_or(DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS); + let configured = if raw == 0 { + DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + } else { + raw.clamp( + MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + ) + }; + let min_for_api = self.subagent_api_timeout_secs().saturating_add(30).clamp( + MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + ); + configured.max(min_for_api) + } + /// Raw sub-agent model override map. Values are validated at spawn time /// so an invalid role/type model fails before any partial agent spawn. #[must_use] @@ -3303,8 +3348,10 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } - if matches!(config.api_provider(), ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) - && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL") + if matches!( + config.api_provider(), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + ) && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL") && !value.trim().is_empty() { config @@ -3460,8 +3507,10 @@ fn apply_env_overrides(config: &mut Config) { .moonshot .model = Some(value); } - if matches!(config.api_provider(), ApiProvider::Siliconflow | ApiProvider::SiliconflowCn) - && let Ok(value) = std::env::var("SILICONFLOW_MODEL") + if matches!( + config.api_provider(), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn + ) && let Ok(value) = std::env::var("SILICONFLOW_MODEL") && !value.trim().is_empty() { config @@ -3829,8 +3878,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, + ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL, ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, @@ -3841,7 +3889,9 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { } fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool { - if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn) && siliconflow_base_url_is_official(base_url) { + if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn) + && siliconflow_base_url_is_official(base_url) + { return false; } normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider)) @@ -3922,12 +3972,14 @@ fn model_for_provider(provider: ApiProvider, normalized: String) -> String { // Flash not yet available on Fireworks; fall through to normalized name "accounts/fireworks/models/deepseek-v4-flash".to_string() } - (ApiProvider::Siliconflow, "deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1") => { - DEFAULT_SILICONFLOW_MODEL.to_string() - } - (ApiProvider::Siliconflow, "deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3") => { - DEFAULT_SILICONFLOW_FLASH_MODEL.to_string() - } + ( + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn, + "deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1", + ) => DEFAULT_SILICONFLOW_MODEL.to_string(), + ( + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn, + "deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3", + ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(), (ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(), (ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(), (ApiProvider::Vllm, "deepseek-v4-pro") => DEFAULT_VLLM_MODEL.to_string(), @@ -4758,8 +4810,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", ApiProvider::Siliconflow => "siliconflow", - ApiProvider::SiliconflowCn => "siliconflow-CN", - ApiProvider::SiliconflowCn => "siliconflow-CN", + ApiProvider::SiliconflowCn => "siliconflow", ApiProvider::Arcee => "arcee", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", @@ -4854,7 +4905,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { ApiProvider::Novita => Ok("novita"), ApiProvider::Fireworks => Ok("fireworks"), ApiProvider::Siliconflow => Ok("siliconflow"), - ApiProvider::SiliconflowCn => Ok("siliconflow-CN"), + ApiProvider::SiliconflowCn => Ok("siliconflow"), ApiProvider::Arcee => Ok("arcee"), ApiProvider::Moonshot => Ok("moonshot"), ApiProvider::Sglang => Ok("sglang"), @@ -5853,6 +5904,64 @@ mod tests { ); } + #[test] + fn subagent_heartbeat_timeout_defaults_clamps_and_respects_api_timeout() { + assert_eq!( + Config::default().subagent_heartbeat_timeout_secs(), + DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + ); + + let zero = Config { + subagents: Some(SubagentsConfig { + heartbeat_timeout_secs: Some(0), + ..SubagentsConfig::default() + }), + ..Config::default() + }; + assert_eq!( + zero.subagent_heartbeat_timeout_secs(), + DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + ); + + let low = Config { + subagents: Some(SubagentsConfig { + api_timeout_secs: Some(1), + heartbeat_timeout_secs: Some(1), + ..SubagentsConfig::default() + }), + ..Config::default() + }; + assert_eq!( + low.subagent_heartbeat_timeout_secs(), + MIN_SUBAGENT_API_TIMEOUT_SECS + 30 + ); + + let follows_long_api_timeout = Config { + subagents: Some(SubagentsConfig { + api_timeout_secs: Some(900), + heartbeat_timeout_secs: Some(300), + ..SubagentsConfig::default() + }), + ..Config::default() + }; + assert_eq!( + follows_long_api_timeout.subagent_heartbeat_timeout_secs(), + 930 + ); + + let high = Config { + subagents: Some(SubagentsConfig { + heartbeat_timeout_secs: Some(MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + 60), + ..SubagentsConfig::default() + }), + ..Config::default() + }; + assert_eq!( + high.subagent_heartbeat_timeout_secs(), + MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS + ); + } + #[test] fn save_api_key_writes_config_file_under_cfg_test() -> Result<()> { // `save_api_key` writes to the shared user config file. This @@ -6808,10 +6917,20 @@ api_key = "old-openrouter-key" normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-r1").as_deref(), Some(DEFAULT_SILICONFLOW_MODEL) ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::SiliconflowCn, "deepseek-reasoner") + .as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); assert_eq!( normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-chat").as_deref(), Some(DEFAULT_SILICONFLOW_FLASH_MODEL) ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::SiliconflowCn, "deepseek-chat") + .as_deref(), + Some(DEFAULT_SILICONFLOW_FLASH_MODEL) + ); assert_eq!( normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v3").as_deref(), Some(DEFAULT_SILICONFLOW_FLASH_MODEL) @@ -8243,14 +8362,14 @@ model = "arcee-trinity-large-preview" // Safety: test-only environment mutation guarded by a global mutex. unsafe { - env::set_var("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("CODEWHALE_PROVIDER", "siliconflow-CN"); env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); env::set_var("SILICONFLOW_MODEL", "deepseek-reasoner"); } let config = Config::load(None, None)?; - assert_eq!(config.api_provider(), ApiProvider::Siliconflow); + assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn); assert_eq!(config.deepseek_api_key()?, "sf-env-key"); assert_eq!(config.deepseek_base_url(), "https://api.siliconflow.cn/v1"); assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL); @@ -8922,6 +9041,23 @@ api_key = "moonshot-platform-key" .and_then(toml::Value::as_str), Some("sglang-saved-key") ); + save_api_key_for(ApiProvider::SiliconflowCn, "sf-cn-saved-key")?; + let contents = fs::read_to_string(&path)?; + let parsed: toml::Value = toml::from_str(&contents)?; + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("siliconflow")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("sf-cn-saved-key") + ); + assert!( + parsed + .get("providers") + .and_then(|p| p.get("siliconflow-CN")) + .is_none() + ); Ok(()) } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2887b848..28ba4799 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -46,7 +46,7 @@ use crate::tools::spec::RuntimeToolServices; use crate::tools::spec::{ApprovalRequirement, ToolError, ToolResult}; use crate::tools::subagent::{ Mailbox, SharedSubAgentManager, SubAgentCompletion, SubAgentForkContext, SubAgentResult, - SubAgentRuntime, SubAgentStatus, SubAgentType, new_shared_subagent_manager, + SubAgentRuntime, SubAgentStatus, SubAgentType, new_shared_subagent_manager_with_timeout, resolve_subagent_assignment_route, }; use crate::tools::todo::{SharedTodoList, TodoListSnapshot, new_shared_todo_list}; @@ -314,6 +314,10 @@ pub struct EngineConfig { /// once at engine construction, then threaded onto every /// `SubAgentRuntime` the engine builds (#1806, #1808). pub subagent_api_timeout: Duration, + /// No-progress heartbeat timeout for live sub-agents. Used by the manager + /// and parent wait loop to auto-cancel stuck children before they exhaust + /// the sub-agent slot pool indefinitely (#2614). + pub subagent_heartbeat_timeout: Duration, /// Native tools that should stay in the model-visible catalog even when /// they are outside the small default core surface (#2076). pub tools_always_load: HashSet, @@ -372,6 +376,9 @@ impl Default for EngineConfig { subagent_api_timeout: Duration::from_secs( crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, ), + subagent_heartbeat_timeout: Duration::from_secs( + crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + ), tools_always_load: HashSet::new(), prefer_bwrap: false, tools: None, @@ -642,8 +649,11 @@ impl Engine { crate::prefix_cache::PrefixStabilityManager::new_unpinned() }); - let subagent_manager = - new_shared_subagent_manager(config.workspace.clone(), config.max_subagents); + let subagent_manager = new_shared_subagent_manager_with_timeout( + config.workspace.clone(), + config.max_subagents, + config.subagent_heartbeat_timeout, + ); let shell_manager = config .runtime_services .shell_manager diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 33aae769..de71c5fa 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -79,7 +79,7 @@ impl Engine { const MAX_STREAM_RETRIES: u32 = 3; let mut stream_retry_attempts: u32 = 0; - loop { + 'turn_loop: loop { if self.cancel_token.is_cancelled() { let _ = self.tx_event.send(Event::status("Request cancelled")).await; return (TurnOutcomeStatus::Interrupted, None); @@ -1005,11 +1005,14 @@ impl Engine { completions.push(c); } if completions.is_empty() { - let running = { - let mgr = self.subagent_manager.read().await; - mgr.running_count() - }; - if should_hold_turn_for_subagents(completions.len(), running) { + loop { + let running = { + let mgr = self.subagent_manager.read().await; + mgr.running_count() + }; + if !should_hold_turn_for_subagents(completions.len(), running) { + break; + } let _ = self .tx_event .send(Event::status(format!( @@ -1032,6 +1035,7 @@ impl Engine { while let Ok(extra) = self.rx_subagent_completion.try_recv() { completions.push(extra); } + break; } Some(steer) = self.rx_steer.recv() => { let trimmed = steer.trim().to_string(); @@ -1052,7 +1056,21 @@ impl Engine { .await; } turn.next_step(); - continue; + continue 'turn_loop; + } + () = tokio::time::sleep(self.config.subagent_heartbeat_timeout) => { + let auto_cancelled = { + let mut mgr = self.subagent_manager.write().await; + mgr.cleanup(std::time::Duration::from_secs(60 * 60)) + }; + if auto_cancelled > 0 { + let _ = self + .tx_event + .send(Event::status(format!( + "Auto-cancelled {auto_cancelled} stale sub-agent(s) after no progress" + ))) + .await; + } } } } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 6df2b9f0..df867d98 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -2000,7 +2000,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "FIREWORKS_API_KEY", "codewhale auth set --provider fireworks --api-key \"...\"", ), - crate::config::ApiProvider::Siliconflow | crate::config::ApiProvider::SiliconflowCn => ( + crate::config::ApiProvider::Siliconflow + | crate::config::ApiProvider::SiliconflowCn => ( "SILICONFLOW_API_KEY", "codewhale auth set --provider siliconflow --api-key \"...\"", ), @@ -2044,7 +2045,8 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::XiaomiMimo => "xiaomi_mimo", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", - crate::config::ApiProvider::Siliconflow | crate::config::ApiProvider::SiliconflowCn => "siliconflow", + crate::config::ApiProvider::Siliconflow + | crate::config::ApiProvider::SiliconflowCn => "siliconflow", crate::config::ApiProvider::Arcee => "arcee", crate::config::ApiProvider::Moonshot => "moonshot", crate::config::ApiProvider::Sglang => "sglang", @@ -5709,6 +5711,9 @@ async fn run_exec_agent( runtime_services: crate::tools::spec::RuntimeToolServices::default(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: std::time::Duration::from_secs(config.subagent_api_timeout_secs()), + subagent_heartbeat_timeout: std::time::Duration::from_secs( + config.subagent_heartbeat_timeout_secs(), + ), prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index 978bee7e..08baea29 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1113,12 +1113,21 @@ fn is_mcp_stale_session_body(body: &str) -> bool { fn is_mcp_stale_session_error(err: &anyhow::Error) -> bool { let err = format!("{err:#}"); + let lower_err = err.to_ascii_lowercase(); err.contains("MCP Streamable HTTP session expired") || err.contains("MCP session expired") || err.contains("SSE transport closed") + || (err.contains("MCP SSE POST send failed") && is_connection_closed_error_text(&lower_err)) || is_mcp_stale_session_body(&err) } +fn is_connection_closed_error_text(err: &str) -> bool { + err.contains("connection closed") + || err.contains("connection reset") + || err.contains("broken pipe") + || err.contains("unexpected eof") +} + fn parse_sse_message_data(body: &str) -> Vec> { let normalized = body.replace("\r\n", "\n"); let mut messages = Vec::new(); @@ -1205,7 +1214,13 @@ impl McpTransport for SseTransport { ) .body(msg) .send() - .await?; + .await + .with_context(|| { + format!( + "MCP SSE POST send failed (transport=sse endpoint={})", + mask_url_secrets(endpoint) + ) + })?; let status = response.status(); if !status.is_success() { let body_excerpt = bounded_body_excerpt(response, ERROR_BODY_PREVIEW_BYTES).await; @@ -3659,6 +3674,25 @@ mod tests { ); } + #[test] + fn legacy_sse_post_disconnect_is_retryable() { + let err = anyhow::anyhow!( + "MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): connection closed before message completed" + ); + assert!( + is_mcp_stale_session_error(&err), + "closed legacy SSE POST should force reconnect before retry" + ); + + let err = anyhow::anyhow!( + "MCP SSE POST send failed (transport=sse endpoint=http://127.0.0.1:123/messages): connection reset by peer" + ); + assert!( + is_mcp_stale_session_error(&err), + "reset legacy SSE POST should force reconnect before retry" + ); + } + #[tokio::test] async fn discover_all_ignores_unsupported_optional_capabilities() { let sent = Arc::new(Mutex::new(Vec::new())); diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index db6a2cc3..6973a9c3 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -2020,6 +2020,9 @@ impl RuntimeThreadManager { subagent_api_timeout: std::time::Duration::from_secs( self.config.subagent_api_timeout_secs(), ), + subagent_heartbeat_timeout: std::time::Duration::from_secs( + self.config.subagent_heartbeat_timeout_secs(), + ), prefer_bwrap: self.config.prefer_bwrap.unwrap_or(false), memory_enabled: self.config.memory_enabled(), memory_path: self.config.memory_path(), diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 55b74985..08884906 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1017,6 +1017,7 @@ pub struct SubAgent { pub result: Option, pub steps_taken: u32, pub started_at: Instant, + pub last_activity_at: Instant, /// `None` = full registry inheritance, with approval-gated tools still /// blocked unless the parent runtime is auto-approved. /// `Some(list)` = explicit narrow allowlist (Custom agents, legacy). @@ -1046,6 +1047,7 @@ impl SubAgent { ) -> Self { let session_name = id.clone(); + let started_at = Instant::now(); Self { id, session_name, @@ -1058,7 +1060,8 @@ impl SubAgent { status: SubAgentStatus::Running, result: None, steps_taken: 0, - started_at: Instant::now(), + started_at, + last_activity_at: started_at, allowed_tools, session_boot_id, input_tx: Some(input_tx), @@ -1099,6 +1102,7 @@ pub struct SubAgentManager { state_path: Option, max_steps: u32, max_agents: usize, + running_heartbeat_timeout: Duration, /// Stable id assigned at manager construction (#405). Stamped on /// every agent the manager spawns; agents loaded from the /// persisted state file carry whatever id the prior session @@ -1118,6 +1122,9 @@ impl SubAgentManager { state_path: None, max_steps: DEFAULT_MAX_STEPS, max_agents, + running_heartbeat_timeout: Duration::from_secs( + crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + ), // Fresh boot id per manager. Used by #405 to classify // re-loaded persisted agents as "prior session". current_session_boot_id: format!("boot_{}", &Uuid::new_v4().to_string()[..12]), @@ -1145,6 +1152,16 @@ impl SubAgentManager { self } + #[must_use] + pub fn with_running_heartbeat_timeout(mut self, timeout: Duration) -> Self { + self.running_heartbeat_timeout = if timeout.is_zero() { + Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS) + } else { + timeout + }; + self + } + fn persist_state(&self) -> Result<()> { let Some(path) = self.state_path.as_ref() else { return Ok(()); @@ -1244,6 +1261,7 @@ impl SubAgentManager { result: persisted.result, steps_taken: persisted.steps_taken, started_at, + last_activity_at: started_at, allowed_tools, // Empty string when loading pre-#405 records; the // manager treats that the same as a non-matching id — @@ -1274,11 +1292,28 @@ impl SubAgentManager { // Keep recently finished handles counted until the terminal // status update has reconciled. Otherwise a fanout burst can // refill the cap before the UI/state catches up (#2211). - true + !self.running_heartbeat_timed_out(agent) }) .count() } + fn running_heartbeat_timed_out(&self, agent: &SubAgent) -> bool { + agent.status == SubAgentStatus::Running + && agent.task_handle.is_some() + && agent.last_activity_at.elapsed() >= self.running_heartbeat_timeout + } + + pub fn touch(&mut self, agent_id: &str) -> bool { + let Some(agent) = self.agents.get_mut(agent_id) else { + return false; + }; + if agent.status != SubAgentStatus::Running { + return false; + } + agent.last_activity_at = Instant::now(); + true + } + /// Spawn a new background sub-agent. pub fn spawn_background( &mut self, @@ -1548,6 +1583,7 @@ impl SubAgentManager { agent.result = None; agent.steps_taken = 0; agent.started_at = restarted_at; + agent.last_activity_at = restarted_at; agent.input_tx = Some(input_tx); agent.task_handle = Some(handle); @@ -1740,9 +1776,37 @@ impl SubAgentManager { .collect() } - /// Clean up completed agents older than the given duration. - pub fn cleanup(&mut self, max_age: Duration) { + /// Clean up stale running agents and completed agents older than the + /// given duration. Returns the number of running agents auto-cancelled + /// during this pass. + pub fn cleanup(&mut self, max_age: Duration) -> usize { let before = self.agents.len(); + let mut auto_cancelled = 0; + let timeout = self.running_heartbeat_timeout; + for agent in self.agents.values_mut() { + if agent.status == SubAgentStatus::Running + && agent.task_handle.is_some() + && agent.last_activity_at.elapsed() >= timeout + { + tracing::warn!( + target: "subagent", + agent_id = %agent.id, + timeout_secs = timeout.as_secs(), + "auto-cancelling stale sub-agent with no manager-visible progress" + ); + agent.status = SubAgentStatus::Cancelled; + agent.result = Some(format!( + "Auto-cancelled after {}s without sub-agent progress.", + timeout.as_secs() + )); + release_resident_leases_for(&agent.id); + if let Some(handle) = agent.task_handle.take() { + handle.abort(); + } + agent.input_tx = None; + auto_cancelled += 1; + } + } self.agents.retain(|_, agent| { if agent.status == SubAgentStatus::Running { true @@ -1750,9 +1814,10 @@ impl SubAgentManager { agent.started_at.elapsed() < max_age } }); - if self.agents.len() != before { + if self.agents.len() != before || auto_cancelled > 0 { self.persist_state_best_effort(); } + auto_cancelled } fn update_from_result(&mut self, agent_id: &str, result: SubAgentResult) { @@ -1918,9 +1983,26 @@ fn write_json_atomic(path: &Path, value: &T) -> Result<()> { /// Create a shared sub-agent manager with a configurable limit. #[must_use] pub fn new_shared_subagent_manager(workspace: PathBuf, max_agents: usize) -> SharedSubAgentManager { + new_shared_subagent_manager_with_timeout( + workspace, + max_agents, + Duration::from_secs(crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS), + ) +} + +/// Create a shared sub-agent manager with configurable concurrency and stale +/// running-agent heartbeat timeout. +#[must_use] +pub fn new_shared_subagent_manager_with_timeout( + workspace: PathBuf, + max_agents: usize, + running_heartbeat_timeout: Duration, +) -> SharedSubAgentManager { let max_agents = max_agents.clamp(1, MAX_SUBAGENTS); let state_path = default_state_path(&workspace); - let mut manager = SubAgentManager::new(workspace, max_agents).with_state_path(state_path); + let mut manager = SubAgentManager::new(workspace, max_agents) + .with_running_heartbeat_timeout(running_heartbeat_timeout) + .with_state_path(state_path); if let Err(err) = manager.load_state() { // Routed through tracing instead of stderr — see comment in // `persist_state_best_effort` above. @@ -3735,6 +3817,18 @@ async fn insert_subagent_full_transcript_handle( store.insert_json(format!("agent:{agent_id}"), "full_transcript", payload) } +fn record_agent_progress(runtime: &SubAgentRuntime, agent_id: &str, message: impl Into) { + if let Ok(mut manager) = runtime.manager.try_write() { + manager.touch(agent_id); + } + emit_agent_progress( + runtime.event_tx.as_ref(), + runtime.mailbox.as_ref(), + agent_id, + message.into(), + ); +} + #[allow(clippy::too_many_arguments, clippy::too_many_lines)] async fn run_subagent( runtime: &SubAgentRuntime, @@ -3779,9 +3873,8 @@ async fn run_subagent( if let Some(mb) = runtime.mailbox.as_ref() { let _ = mb.send(MailboxMessage::started(&agent_id, agent_type.clone())); } - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("started ({})", agent_type.as_str()), ); @@ -3796,9 +3889,8 @@ async fn run_subagent( // while we were between steps. Top-level model-visible sub-agents use // a detached token so parent turn cancellation does not stop them. if runtime.cancel_token.is_cancelled() { - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: cancelled"), ); @@ -3845,9 +3937,8 @@ async fn run_subagent( } steps += 1; - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: requesting model response"), ); @@ -3892,9 +3983,8 @@ async fn run_subagent( let response = tokio::select! { biased; () = runtime.cancel_token.cancelled() => { - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: cancelled mid-request"), ); @@ -3980,9 +4070,8 @@ async fn run_subagent( tool_uses.len() ) }; - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: {progress}"), ); @@ -4006,9 +4095,8 @@ async fn run_subagent( pending_inputs.push_back(input); } if pending_inputs.is_empty() { - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: complete"), ); @@ -4017,9 +4105,8 @@ async fn run_subagent( continue; } - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!( "step {steps}/{max_steps}: executing {} tool call(s)", @@ -4028,9 +4115,8 @@ async fn run_subagent( ); let mut tool_results: Vec = Vec::new(); for (tool_id, tool_name, tool_input) in tool_uses { - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: running tool '{tool_name}'"), ); @@ -4053,9 +4139,8 @@ async fn run_subagent( Err(_) => format!("Error: Tool {tool_name} timed out"), }; let tool_ok = !result.starts_with("Error:"); - emit_agent_progress( - runtime.event_tx.as_ref(), - runtime.mailbox.as_ref(), + record_agent_progress( + runtime, &agent_id, format!("step {steps}/{max_steps}: finished tool '{tool_name}'"), ); diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 50469c09..2731870a 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -962,6 +962,122 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() { assert_eq!(manager.running_count(), 1); } +#[tokio::test] +async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 1) + .with_running_heartbeat_timeout(Duration::from_secs(300)); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + "test_agent_stale".to_string(), + SubAgentType::Explore, + "prompt".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + "boot_test".to_string(), + ); + agent.last_activity_at = instant_from_duration(Duration::from_secs(600)); + agent.task_handle = Some(tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + })); + let agent_id = agent.id.clone(); + manager.agents.insert(agent_id.clone(), agent); + + assert_eq!( + manager.running_count(), + 0, + "stale running agents must not keep the concurrency slot occupied" + ); + assert_eq!(manager.cleanup(Duration::from_secs(60 * 60)), 1); + + let snapshot = manager + .get_result(&agent_id) + .expect("agent should remain inspectable"); + assert_eq!(snapshot.status, SubAgentStatus::Cancelled); + assert_eq!(manager.running_count(), 0); + assert!( + snapshot + .result + .as_deref() + .unwrap_or_default() + .contains("Auto-cancelled") + ); +} + +#[tokio::test] +async fn cleanup_keeps_recent_running_agent() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 1) + .with_running_heartbeat_timeout(Duration::from_secs(300)); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + "test_agent_recent".to_string(), + SubAgentType::Explore, + "prompt".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + "boot_test".to_string(), + ); + agent.last_activity_at = Instant::now(); + agent.task_handle = Some(tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + })); + let agent_id = agent.id.clone(); + manager.agents.insert(agent_id.clone(), agent); + + assert_eq!(manager.running_count(), 1); + assert_eq!(manager.cleanup(Duration::from_secs(60 * 60)), 0); + assert_eq!( + manager.get_result(&agent_id).expect("agent").status, + SubAgentStatus::Running + ); + manager + .agents + .get_mut(&agent_id) + .and_then(|agent| agent.task_handle.take()) + .expect("live task handle") + .abort(); +} + +#[tokio::test] +async fn touch_refreshes_stale_running_agent_heartbeat() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 1) + .with_running_heartbeat_timeout(Duration::from_secs(300)); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + "test_agent_touched".to_string(), + SubAgentType::Explore, + "prompt".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + "boot_test".to_string(), + ); + agent.last_activity_at = instant_from_duration(Duration::from_secs(600)); + agent.task_handle = Some(tokio::spawn(async { + tokio::time::sleep(Duration::from_secs(60)).await; + })); + let agent_id = agent.id.clone(); + manager.agents.insert(agent_id.clone(), agent); + + assert_eq!(manager.running_count(), 0); + assert!(manager.touch(&agent_id)); + assert_eq!(manager.running_count(), 1); + assert_eq!(manager.cleanup(Duration::from_secs(60 * 60)), 0); + manager + .agents + .get_mut(&agent_id) + .and_then(|agent| agent.task_handle.take()) + .expect("live task handle") + .abort(); +} + #[test] fn test_assign_updates_running_agent_and_sends_message() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 8307b5b8..3eb494f1 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -40,6 +40,7 @@ use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; use crate::tui::paste_burst::{FlushResult, PasteBurst}; use crate::tui::scrolling::{MouseScrollState, TranscriptLineMeta, TranscriptScroll}; use crate::tui::selection::{SelectionAutoscroll, TranscriptSelection}; +use crate::tui::sidebar::SidebarWorkSummary; use crate::tui::streaming::StreamingState; use crate::tui::transcript::TranscriptViewCache; use crate::tui::views::ViewStack; @@ -1297,6 +1298,9 @@ pub struct App { pub sidebar_hover: SidebarHoverState, /// Current hover tooltip text, if any. pub sidebar_hover_tooltip: Option, + /// Last successfully rendered Work panel summary. Transient mutex misses + /// should not wipe completed checklist/strategy state from the sidebar. + pub(crate) cached_work_summary: Option, /// Last known mouse position for tooltip placement. pub last_mouse_pos: Option<(u16, u16)>, /// Whether the user is currently dragging the sidebar resize handle. @@ -2034,6 +2038,7 @@ impl App { sidebar_focus, sidebar_hover: SidebarHoverState::default(), sidebar_hover_tooltip: None, + cached_work_summary: None, last_mouse_pos: None, sidebar_resizing: false, sidebar_resize_anchor_x: 0, diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index b624de0c..84f43191 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -699,7 +699,7 @@ pub fn latest_assistant_text(messages: &[Message]) -> Option { | ContentBlock::ServerToolUse { .. } | ContentBlock::ToolSearchToolResult { .. } | ContentBlock::CodeExecutionToolResult { .. } => None, - | ContentBlock::ImageUrl { .. } => None, + ContentBlock::ImageUrl { .. } => None, }) .collect::>() .join("\n"); diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 92f583bd..2f111319 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -482,6 +482,7 @@ mod tests { "Novita AI", "Fireworks AI", "SiliconFlow", + "SiliconFlow (China)", "Arcee AI", "Moonshot/Kimi", "SGLang", @@ -643,7 +644,7 @@ mod tests { let config = Config::default(); let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); - let rendered = render_text(&picker, 80, 22); + let rendered = render_text(&picker, 80, 23); assert!(rendered.contains("DeepSeek *")); assert!(rendered.contains("Ollama")); diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index 64a7225e..489438a2 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -790,9 +790,7 @@ fn message_text_for_history(message: &crate::models::Message) -> String { | crate::models::ContentBlock::CodeExecutionToolResult { content, .. } => { format!("tool result: {}", truncate(&content.to_string(), 220)) } - crate::models::ContentBlock::ImageUrl { .. } => { - String::from("[image]") - } + crate::models::ContentBlock::ImageUrl { .. } => String::from("[image]"), }; let part = part.trim(); if !part.is_empty() { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 9645bd8f..fa1b26ce 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -168,7 +168,7 @@ struct SidebarWorkStrategyStep { } #[derive(Debug, Clone, Default)] -struct SidebarWorkSummary { +pub(crate) struct SidebarWorkSummary { goal_objective: Option, goal_token_budget: Option, goal_completed: bool, @@ -226,56 +226,77 @@ impl SidebarWorkSummary { } } -fn sidebar_work_summary(app: &App) -> SidebarWorkSummary { - let mut summary = SidebarWorkSummary { - goal_objective: app.hunt.quarry.clone(), - goal_token_budget: app.hunt.token_budget, - goal_completed: app.hunt.verdict == HuntVerdict::Hunted, - goal_started_at: app.hunt.started_at, - tokens_used: app.session.total_conversation_tokens, - ..SidebarWorkSummary::default() - }; +fn sidebar_work_summary(app: &mut App) -> SidebarWorkSummary { + let fresh = (|| { + let todos = app.todos.try_lock().ok()?; + let plan = app.plan_state.try_lock().ok()?; - match app.todos.try_lock() { - Ok(todos) => { - let snapshot = todos.snapshot(); - summary.checklist_completion_pct = snapshot.completion_pct; - summary.checklist_items = snapshot - .items - .into_iter() - .map(|item| SidebarWorkChecklistItem { - id: item.id, - content: item.content, - status: item.status, - }) - .collect(); - } - Err(_) => { - summary.state_updating = true; - } - } + let snapshot = todos.snapshot(); + let checklist_completion_pct = snapshot.completion_pct; + let checklist_items = snapshot + .items + .into_iter() + .map(|item| SidebarWorkChecklistItem { + id: item.id, + content: item.content, + status: item.status, + }) + .collect(); - match app.plan_state.try_lock() { - Ok(plan) => { - if !plan.is_empty() { - summary.strategy_explanation = plan.explanation().map(str::to_string); - summary.strategy_steps = plan - .steps() + let (strategy_explanation, strategy_steps) = if plan.is_empty() { + (None, Vec::new()) + } else { + ( + plan.explanation().map(str::to_string), + plan.steps() .iter() .map(|step| SidebarWorkStrategyStep { text: step.text.clone(), status: step.status.clone(), elapsed: step.elapsed_str(), }) - .collect(); - } - } - Err(_) => { - summary.state_updating = true; - } + .collect(), + ) + }; + + Some(SidebarWorkSummary { + goal_objective: app.hunt.quarry.clone(), + goal_token_budget: app.hunt.token_budget, + goal_completed: app.hunt.verdict == HuntVerdict::Hunted, + goal_started_at: app.hunt.started_at, + tokens_used: app.session.total_conversation_tokens, + checklist_completion_pct, + checklist_items, + strategy_explanation, + strategy_steps, + state_updating: false, + }) + })(); + + if let Some(summary) = fresh { + app.cached_work_summary = Some(summary.clone()); + return summary; } - summary + if let Some(cached) = app.cached_work_summary.as_ref() { + let mut summary = cached.clone(); + summary.goal_objective = app.hunt.quarry.clone(); + summary.goal_token_budget = app.hunt.token_budget; + summary.goal_completed = app.hunt.verdict == HuntVerdict::Hunted; + summary.goal_started_at = app.hunt.started_at; + summary.tokens_used = app.session.total_conversation_tokens; + return summary; + } + + SidebarWorkSummary { + goal_objective: app.hunt.quarry.clone(), + goal_token_budget: app.hunt.token_budget, + goal_completed: app.hunt.verdict == HuntVerdict::Hunted, + goal_started_at: app.hunt.started_at, + tokens_used: app.session.total_conversation_tokens, + state_updating: true, + ..SidebarWorkSummary::default() + } } fn work_panel_lines( @@ -1946,8 +1967,8 @@ mod tests { AutoSidebarState, SidebarAgentRow, SidebarHoverSection, SidebarHoverState, SidebarSubagentSummary, SidebarToolRow, SidebarWorkChecklistItem, SidebarWorkStrategyStep, SidebarWorkSummary, ToolRowOrder, auto_sidebar_panels, editorial_tool_rows, - normalize_activity_text, subagent_panel_lines, task_panel_lines, work_panel_empty_hint, - work_panel_lines, + normalize_activity_text, sidebar_work_summary, subagent_panel_lines, task_panel_lines, + work_panel_empty_hint, work_panel_lines, }; use crate::config::Config; use crate::palette; @@ -1955,7 +1976,7 @@ mod tests { use crate::tools::plan::StepStatus; use crate::tools::todo::TodoStatus; use crate::tui::active_cell::ActiveCell; - use crate::tui::app::{App, TaskPanelEntry, TuiOptions}; + use crate::tui::app::{App, HuntVerdict, TaskPanelEntry, TuiOptions}; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, }; @@ -2245,6 +2266,82 @@ mod tests { ); } + #[test] + fn sidebar_work_summary_caches_on_success() { + let mut app = create_test_app(); + { + let mut todos = app.todos.try_lock().expect("todos lock"); + todos.add("cache test".to_string(), TodoStatus::InProgress); + } + + let summary = sidebar_work_summary(&mut app); + + assert!(!summary.state_updating, "should not be updating"); + assert_eq!(summary.checklist_items.len(), 1); + assert!( + app.cached_work_summary.is_some(), + "cache should be populated" + ); + } + + #[test] + fn sidebar_work_summary_falls_back_to_cache_when_todos_lock_busy() { + let mut app = create_test_app(); + { + let mut todos = app.todos.try_lock().expect("todos lock"); + todos.add("will be cached".to_string(), TodoStatus::Completed); + } + let _first = sidebar_work_summary(&mut app); + assert!(app.cached_work_summary.is_some()); + + let held_arc = app.todos.clone(); + let _held = held_arc.try_lock().expect("hold todos lock"); + + let summary = sidebar_work_summary(&mut app); + + assert!(!summary.state_updating, "should fall back to cache"); + assert!( + summary + .checklist_items + .iter() + .any(|item| item.content == "will be cached"), + "cached item should be present" + ); + } + + #[test] + fn sidebar_work_summary_returns_updating_when_no_cache_and_locks_busy() { + let mut app = create_test_app(); + let held_arc = app.todos.clone(); + let _held = held_arc.try_lock().expect("hold todos lock"); + + let summary = sidebar_work_summary(&mut app); + + assert!(summary.state_updating, "should be updating without cache"); + } + + #[test] + fn sidebar_work_summary_keeps_live_fields_on_cache_fallback() { + let mut app = create_test_app(); + app.hunt.quarry = Some("test quarry".to_string()); + app.hunt.verdict = HuntVerdict::Hunted; + { + let mut todos = app.todos.try_lock().expect("todos lock"); + todos.add("item".to_string(), TodoStatus::Pending); + } + let _first = sidebar_work_summary(&mut app); + + app.hunt.quarry = Some("updated quarry".to_string()); + app.hunt.verdict = HuntVerdict::Hunting; + let held_arc = app.todos.clone(); + let _held = held_arc.try_lock().expect("hold todos lock"); + + let summary = sidebar_work_summary(&mut app); + + assert_eq!(summary.goal_objective.as_deref(), Some("updated quarry")); + assert!(!summary.goal_completed, "verdict should be live"); + } + #[test] fn tasks_panel_renders_active_tool_rows_before_background_empty_state() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 34d1cd6f..f040835b 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -871,6 +871,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { runtime_services: app.runtime_services.clone(), subagent_model_overrides: config.subagent_model_overrides(), subagent_api_timeout: Duration::from_secs(config.subagent_api_timeout_secs()), + subagent_heartbeat_timeout: Duration::from_secs(config.subagent_heartbeat_timeout_secs()), prefer_bwrap: config.prefer_bwrap.unwrap_or(false), memory_enabled: config.memory_enabled(), memory_path: config.memory_path(), @@ -6604,7 +6605,9 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::XiaomiMimo => Some("MiMo"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), - crate::config::ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Some("SiliconFlow"), + crate::config::ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => { + Some("SiliconFlow") + } crate::config::ApiProvider::Arcee => Some("Arcee"), crate::config::ApiProvider::Moonshot => Some("Kimi"), crate::config::ApiProvider::Sglang => Some("SGLang"), diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d18f2156..cd1e3ae5 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,7 +87,8 @@ provider's keyring entry. For hosted, generic OpenAI-compatible, or self-hosted providers, set `provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, `"volcengine"`, `"openrouter"`, `"xiaomi-mimo"`, `"novita"`, `"fireworks"`, -`"siliconflow"`, `"arcee"`, `"moonshot"`, `"sglang"`, `"vllm"`, or `"ollama"` or pass +`"siliconflow"`, `"siliconflow-CN"`, `"arcee"`, `"moonshot"`, `"sglang"`, +`"vllm"`, or `"ollama"` or pass `codewhale --provider `. For the provider-by-provider registry, including auth variables, default base URLs, model IDs, and capability metadata, see [PROVIDERS.md](PROVIDERS.md). @@ -118,8 +119,9 @@ unless API-key auth is explicitly requested; use an env var or config-file key when a local server does require bearer auth. SiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts `SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default. -`https://api.siliconflow.cn/v1` can still be configured explicitly when a user -needs the regional endpoint. +`provider = "siliconflow-CN"` selects the China regional default +`https://api.siliconflow.cn/v1` while sharing the same +`[providers.siliconflow]` table and `SILICONFLOW_API_KEY` credential slot. Arcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`, and uses `trinity-large-thinking` by default for CodeWhale agent work. `trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's @@ -302,7 +304,7 @@ aliases. When both forms are set the `CODEWHALE_*` value wins; the `DEEPSEEK_*` form is kept for older shells: - `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) — - `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|moonshot|sglang|vllm|ollama` + `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|siliconflow-CN|arcee|moonshot|sglang|vllm|ollama` - `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider - `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider @@ -681,9 +683,9 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint at `https://api.xiaomimimo.com/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint while sharing `[providers.siliconflow]`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://ark.cn-beijing.volces.com/api/coding/v3` for `volcengine`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.xiaomimimo.com/v1` for `xiaomi-mimo`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.siliconflow.cn/v1` for `siliconflow-CN`, `https://api.arcee.ai/api/v1` for `arcee`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. - `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `DeepSeek-V4-Pro` for Volcengine Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `mimo-v2.5-pro` for Xiaomi MiMo, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `trinity-large-thinking` for Arcee AI, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. OpenRouter also recognizes recent large IDs such as `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, and `moonshotai/kimi-k2.6`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro` and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, and Ollama model IDs are passed through unchanged after known aliases are normalized. OpenRouter and SiliconFlow provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `false`; shell tools must be explicitly enabled. @@ -708,11 +710,14 @@ If you are upgrading from older releases: related persistent sub-agent sessions. Explicit tool `model` values win, then role/type overrides, then the parent runtime model. Supported convenience keys are `default_model`, `worker_model`, `explorer_model`, `awaiter_model`, - `review_model`, `custom_model`, `max_concurrent`, and `api_timeout_secs`. The - `[subagents] max_concurrent` value overrides top-level `max_subagents` and is - also clamped to `1..=20`; `[subagents] api_timeout_secs` controls the - per-step API timeout for sub-agent model calls and is clamped to `1..=1800`, - with `0` or unset preserving the legacy 120 second default. + `review_model`, `custom_model`, `max_concurrent`, `api_timeout_secs`, and + `heartbeat_timeout_secs`. The `[subagents] max_concurrent` value overrides + top-level `max_subagents` and is also clamped to `1..=20`; `[subagents] + api_timeout_secs` controls the per-step API timeout for sub-agent model calls + and is clamped to `1..=1800`, with `0` or unset preserving the legacy 120 + second default. `[subagents] heartbeat_timeout_secs` controls stale running + agent cleanup, defaults to `300`, and is clamped to `30..=3600` while staying + above the resolved API timeout. `[subagents.models]` accepts lower-case role or type keys such as `worker`, `explorer`, `general`, `explore`, `plan`, and `review`. Values must normalize to a supported DeepSeek model id before an agent is spawned. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 94c2f475..aa6ed750 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -30,8 +30,8 @@ Sources to keep in sync: The canonical provider IDs are: `deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, -`openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, -`moonshot`, `sglang`, `vllm`, and `ollama`. +`openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, +`siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, and `ollama`. Use any of these surfaces to select a provider: @@ -121,7 +121,8 @@ endpoint. | `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | -| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint; users who need the regional endpoint can set `https://api.siliconflow.cn/v1` explicitly. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | +| `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | +| `siliconflow-CN` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. This intentionally shares `[providers.siliconflow]` and `SILICONFLOW_API_KEY`; do not create `[providers.siliconflow_CN]`. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi CLI OAuth credentials when present. | | `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 8ef1fd8a..e3f06e54 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -127,6 +127,22 @@ api_timeout_secs = 900 # 15 minutes; clamped to 1..=1800 Values are clamped to `1..=1800`. `0` and `unset` keep the legacy `120` second default, so existing installs see no behavior change. +## Stale-agent heartbeat (#2614) + +Running agents also track manager-visible progress. If a child stops emitting +progress for the heartbeat window, the manager auto-cancels it, releases its +sub-agent slot, and keeps the cancelled record inspectable via `agent_eval` / +`agent_list`. The default is 5 minutes: + +```toml +[subagents] +heartbeat_timeout_secs = 300 # clamped to 30..=3600 +``` + +The effective heartbeat is kept at least 30 seconds above +`api_timeout_secs`, so a configured long model request is not cancelled before +its own request timeout can fire. + ## Lifecycle Each opened session produces a record that progresses through: diff --git a/npm/codewhale/package.json b/npm/codewhale/package.json index 7a2230d2..663e0181 100644 --- a/npm/codewhale/package.json +++ b/npm/codewhale/package.json @@ -1,7 +1,7 @@ { "name": "codewhale", - "version": "0.8.51", - "codewhaleBinaryVersion": "0.8.51", + "version": "0.8.52", + "codewhaleBinaryVersion": "0.8.52", "description": "Install and run CodeWhale, the agentic terminal for open-source and open-weight coding models, from GitHub release artifacts.", "author": "Hmbown", "license": "MIT", diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 2b805067..85d7eea6 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -27,6 +27,9 @@ PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md" API_PROVIDER_ONLY_IDS = {"deepseek-cn"} +SHARED_PROVIDER_TABLES = { + "siliconflow-CN": "siliconflow", +} def read(path: Path) -> str: @@ -195,6 +198,10 @@ def report_provider_enum_drift( return errors +def provider_table_name(provider_id: str) -> str: + return SHARED_PROVIDER_TABLES.get(provider_id, provider_id.replace("-", "_")) + + def main() -> int: try: config_rs = read(CONFIG_RS) @@ -205,7 +212,7 @@ def main() -> int: variant_to_id = provider_kind_ids(config_rs) canonical_ids = set(variant_to_id.values()) live_api_provider_ids = set(api_provider_ids(tui_config_rs).values()) - expected_tables = {provider_id.replace("-", "_") for provider_id in canonical_ids} + expected_tables = {provider_table_name(provider_id) for provider_id in canonical_ids} errors: list[str] = [] errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids)