Merge pull request #2626 from Hmbown/codex/v0.8.52-stabilization

fix(release): stabilize v0.8.52
This commit is contained in:
Hunter Bown
2026-06-03 03:07:40 -07:00
committed by GitHub
37 changed files with 898 additions and 207 deletions
+51 -1
View File
@@ -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
Generated
+15 -15
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9 -9
View File
@@ -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
+8 -8
View File
@@ -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
+4 -1
View File
@@ -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"],
+2 -2
View File
@@ -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
+6 -2
View File
@@ -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(),
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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<String> {
&["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") };
}
+1 -1
View File
@@ -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
+80 -5
View File
@@ -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
+5 -5
View File
@@ -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"
+6 -3
View File
@@ -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");
+14 -1
View File
@@ -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();
+163 -27
View File
@@ -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<u64>,
/// 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<u64>,
}
/// `[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<PathBuf>
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(())
}
+13 -3
View File
@@ -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<String>,
@@ -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
+25 -7
View File
@@ -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;
}
}
}
}
+7 -2
View File
@@ -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(),
+35 -1
View File
@@ -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<Vec<u8>> {
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()));
+3
View File
@@ -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(),
+118 -33
View File
@@ -1017,6 +1017,7 @@ pub struct SubAgent {
pub result: Option<String>,
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<PathBuf>,
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<T: Serialize>(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<String>) {
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<ContentBlock> = 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}'"),
);
+116
View File
@@ -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);
+5
View File
@@ -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<String>,
/// 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<SidebarWorkSummary>,
/// 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,
+1 -1
View File
@@ -699,7 +699,7 @@ pub fn latest_assistant_text(messages: &[Message]) -> Option<String> {
| ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => None,
| ContentBlock::ImageUrl { .. } => None,
ContentBlock::ImageUrl { .. } => None,
})
.collect::<Vec<_>>()
.join("\n");
+2 -1
View File
@@ -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"));
+1 -3
View File
@@ -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() {
+141 -44
View File
@@ -168,7 +168,7 @@ struct SidebarWorkStrategyStep {
}
#[derive(Debug, Clone, Default)]
struct SidebarWorkSummary {
pub(crate) struct SidebarWorkSummary {
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
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();
+4 -1
View File
@@ -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"),
+16 -11
View File
@@ -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 <name>`.
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.
+4 -3
View File
@@ -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. |
+16
View File
@@ -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:
+2 -2
View File
@@ -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",
+8 -1
View File
@@ -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)