From c6d90e477b167d8a277513fed02eafd7fb8a9d1c Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Fri, 16 Oct 2026 23:44:53 +0200 Subject: [PATCH 1/9] test(commands): reconcile baseline capability and config proof guards Match /init's existing WORKSPACE|PRESENTATION authority after 8867749f5. Approve only the already-declared pure sanitizer in the config normal graph; preserve all existing service rejection controls. FEAT-031 P0 baseline: 19066 nextest passed, 34 skipped; 3 doctests passed, 8 ignored; config proof 35 passed and 7 controls. Full Clippy, debug/release builds and format passed. No navigation implementation or product behavior change. Refs #5316. --- scripts/test_check_command_config_proof.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test_check_command_config_proof.py b/scripts/test_check_command_config_proof.py index 20801d8e1a..74049bb9f8 100644 --- a/scripts/test_check_command_config_proof.py +++ b/scripts/test_check_command_config_proof.py @@ -99,7 +99,7 @@ def test_shapes_cannot_move_to_dev_dependencies(self): def test_direct_and_transitive_runtime_or_storage_edges_are_rejected(self): allowed = ("codewhale-portable-config v0.10.2\ncodewhale-command-contract v0.10.2\n" - "codewhale-protocol v0.10.2\nserde v1.0.0\n") + "codewhale-protocol v0.10.2\ncodewhale-sanitize v0.10.2\nserde v1.0.0\n") self.assertEqual(proof.graph_violations(allowed), []) for package in ["codewhale-tui", "codewhale-config", "codewhale-core", "codewhale-runtime", "codewhale-execpolicy", "codewhale-secrets", "codewhale-state", From c363a0d73e6730052b63d7c5b6f12107eb3f147f Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sat, 17 Oct 2026 01:35:33 +0200 Subject: [PATCH 2/9] refactor(commands): adopt pure core navigation outcomes and registration FEAT-031 P2 replaces hotbar/transcript/turn FunctionCommand App wrappers with shared pure handlers, real navigation inventory and exhaustive host action conversion. Preserve the original metadata, grammar, output and nine leaf assertions; core remains pending. Verified: 79 contract and 482 scoped native tests pass; 1125 actual observations across 15 locales plus all 40 metadata entries replay byte-identically. Full Clippy, rustfmt, 30 boundary and 63 migration controls pass. Complete 13-command portability proof remains P4; no physical move. Refs #5316. --- crates/command-contract/src/outcome.rs | 14 +++ crates/tui/src/commands/contract.rs | 1 + .../tui/src/commands/core_navigation_host.rs | 60 +++++++++ crates/tui/src/commands/groups/core/hotbar.rs | 114 ++++++++---------- crates/tui/src/commands/groups/core/mod.rs | 19 +-- .../src/commands/groups/core/navigation.rs | 24 ++++ .../src/commands/groups/core/transcript.rs | 22 ++-- crates/tui/src/commands/groups/core/turn.rs | 62 ++++------ crates/tui/src/commands/mod.rs | 18 +++ 9 files changed, 205 insertions(+), 129 deletions(-) create mode 100644 crates/tui/src/commands/core_navigation_host.rs create mode 100644 crates/tui/src/commands/groups/core/navigation.rs diff --git a/crates/command-contract/src/outcome.rs b/crates/command-contract/src/outcome.rs index a13508361a..e7d5cb73d8 100644 --- a/crates/command-contract/src/outcome.rs +++ b/crates/command-contract/src/outcome.rs @@ -162,3 +162,17 @@ pub type ConfigCommandResult = CommandResult; pub type ConfigPolicyAction = ConfigAction; pub type ConfigPolicyCommandResult = ConfigCommandResult; pub type ConfigStatusCommandResult = CommandResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreNavigationAction { + OpenHotbarSetup, + DisableHotbar, + RestoreHotbarDefaults, + OpenLiveTranscript, + OpenTurnInspector, + OpenWorktreeManager, + SwitchWorkspace { workspace: std::path::PathBuf }, + VoiceCapture, +} + +pub type CoreNavigationCommandResult = CommandResult; diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 3e631b747b..5a67a74271 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -269,6 +269,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_tree_description" => MessageId::CmdTreeDescription, "cmd_trust_description" => MessageId::CmdTrustDescription, "cmd_turn_inspect_description" => MessageId::CmdTurnInspectDescription, + "kb_live_transcript" => MessageId::KbLiveTranscript, "cmd_undo_description" => MessageId::CmdUndoDescription, "cmd_update_description" => MessageId::CmdUpdateDescription, "cmd_verbose_description" => MessageId::CmdVerboseDescription, diff --git a/crates/tui/src/commands/core_navigation_host.rs b/crates/tui/src/commands/core_navigation_host.rs new file mode 100644 index 0000000000..9dc4362930 --- /dev/null +++ b/crates/tui/src/commands/core_navigation_host.rs @@ -0,0 +1,60 @@ +use super::CommandResult; +use super::groups::core::navigation::portable_handlers; +use super::traits::{Command, ContextualCommand}; +use crate::tui::app::AppAction; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{CoreNavigationAction, CoreNavigationCommandResult}; + +struct Registration; + +impl RegisterCommand for Registration { + fn info() -> &'static CommandInfo { + portable_handlers()[INDEX].0 + } + + fn handler() -> CommandHandler { + match portable_handlers()[INDEX].1 { + CommandHandler::Pure(_) => { + CommandHandler::Pure(|args| match portable_handlers()[INDEX].1 { + CommandHandler::Pure(run) => host_result(run(args)), + _ => CommandResult::error("navigation handler shape changed"), + }) + } + CommandHandler::Contextual { capabilities, .. } => CommandHandler::Contextual { + capabilities, + handler: |contexts, args| match portable_handlers()[INDEX].1 { + CommandHandler::Contextual { handler, .. } => { + host_result(handler(contexts, args)) + } + _ => CommandResult::error("navigation handler shape changed"), + }, + }, + } + } +} + +fn host_result(result: CoreNavigationCommandResult) -> CommandResult { + CommandResult { + message: result.message, + action: result.action.map(|action| match action { + CoreNavigationAction::OpenHotbarSetup => AppAction::OpenHotbarSetup, + CoreNavigationAction::DisableHotbar => AppAction::DisableHotbar, + CoreNavigationAction::RestoreHotbarDefaults => AppAction::RestoreHotbarDefaults, + CoreNavigationAction::OpenLiveTranscript => AppAction::OpenLiveTranscript, + CoreNavigationAction::OpenTurnInspector => AppAction::OpenTurnInspector, + CoreNavigationAction::OpenWorktreeManager => AppAction::OpenWorktreeManager, + CoreNavigationAction::SwitchWorkspace { workspace } => { + AppAction::SwitchWorkspace { workspace } + } + CoreNavigationAction::VoiceCapture => AppAction::VoiceCapture, + }), + is_error: result.is_error, + } +} + +pub(in crate::commands) fn registration() -> Box { + Box::new( + ContextualCommand::from_contract::>().expect("navigation registration"), + ) +} diff --git a/crates/tui/src/commands/groups/core/hotbar.rs b/crates/tui/src/commands/groups/core/hotbar.rs index f23d0e2961..478015bf2c 100644 --- a/crates/tui/src/commands/groups/core/hotbar.rs +++ b/crates/tui/src/commands/groups/core/hotbar.rs @@ -1,93 +1,78 @@ //! `/hotbar` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "hotbar", aliases: &["hotkeys"], usage: "/hotbar", - description_id: MessageId::CmdHotbarDescription, + description_key: "cmd_hotbar_description", }; -pub(in crate::commands) struct HotbarCmd; +pub struct HotbarCmd; -impl RegisterCommand for HotbarCmd { +impl RegisterCommand for HotbarCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - fn execute(_app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.map(str::trim).filter(|arg| !arg.is_empty()) { - None | Some("setup" | "edit" | "configure" | "config") => { - CommandResult::action(AppAction::OpenHotbarSetup) - } - // Hide the Hotbar: persist `hotbar = []` and clear the live slots. - Some("off" | "disable" | "hide") => CommandResult::action(AppAction::DisableHotbar), - // Restore the default recommended slots (explicit reset). - Some("on" | "reset" | "defaults" | "default") => { - CommandResult::action(AppAction::RestoreHotbarDefaults) - } - Some("help" | "?") => CommandResult::message( - "Hotbar gives you Alt+1 through Alt+8 shortcuts (Option key on macOS, Alt \ - elsewhere). Use `/hotbar` to customize, `/hotbar off` to hide it \ - (`hotbar = []`), and `/hotbar on` to restore the default slots. \ - Hotbar slots dispatch only when no modal, inline picker, or \ - onboarding surface owns input. Bare 1-8 insert text in the \ - composer. Cmd-number and F-keys are not Hotbar shortcuts unless \ - a future release documents and implements them.", - ), - Some(other) => CommandResult::error(format!( - "Unknown /hotbar target '{other}'. Try `/hotbar`, `/hotbar off`, \ - `/hotbar on`, or `/hotbar help`." - )), + fn handler() -> CommandHandler { + CommandHandler::Pure(hotbar) + } +} + +fn hotbar(arg: Option<&str>) -> CommandResult { + match arg.map(str::trim).filter(|arg| !arg.is_empty()) { + None | Some("setup" | "edit" | "configure" | "config") => { + CommandResult::action(Action::OpenHotbarSetup) + } + // Hide the Hotbar: persist `hotbar = []` and clear the live slots. + Some("off" | "disable" | "hide") => CommandResult::action(Action::DisableHotbar), + // Restore the default recommended slots (explicit reset). + Some("on" | "reset" | "defaults" | "default") => { + CommandResult::action(Action::RestoreHotbarDefaults) } + Some("help" | "?") => CommandResult::message( + "Hotbar gives you Alt+1 through Alt+8 shortcuts (Option key on macOS, Alt \ + elsewhere). Use `/hotbar` to customize, `/hotbar off` to hide it \ + (`hotbar = []`), and `/hotbar on` to restore the default slots. \ + Hotbar slots dispatch only when no modal, inline picker, or \ + onboarding surface owns input. Bare 1-8 insert text in the \ + composer. Cmd-number and F-keys are not Hotbar shortcuts unless \ + a future release documents and implements them.", + ), + Some(other) => CommandResult::error(format!( + "Unknown /hotbar target '{other}'. Try `/hotbar`, `/hotbar off`, \ + `/hotbar on`, or `/hotbar help`." + )), } } #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::TuiOptions; - use std::path::PathBuf; - - fn test_app() -> App { - let options = TuiOptions { - ..crate::tui::test_support::test_tui_options(PathBuf::from(".")) - }; - App::new(options, &Config::default()) - } #[test] fn hotbar_command_opens_setup_view() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, None); - - assert_eq!(result.action, Some(AppAction::OpenHotbarSetup)); + let result = hotbar(None); + assert_eq!(result.action, Some(Action::OpenHotbarSetup)); assert!(result.message.is_none()); } #[test] fn hotbar_setup_alias_opens_setup_view() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, Some("setup")); - - assert_eq!(result.action, Some(AppAction::OpenHotbarSetup)); + let result = hotbar(Some("setup")); + assert_eq!(result.action, Some(Action::OpenHotbarSetup)); assert!(result.message.is_none()); } #[test] fn hotbar_help_arg_explains_customize_and_disable() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, Some("help")); - + let result = hotbar(Some("help")); assert!(!result.is_error); assert!(result.action.is_none()); let message = result @@ -115,11 +100,10 @@ mod tests { #[test] fn hotbar_off_and_disable_aliases_return_disable_action() { for arg in ["off", "disable", "hide"] { - let mut app = test_app(); - let result = HotbarCmd::execute(&mut app, Some(arg)); + let result = hotbar(Some(arg)); assert_eq!( result.action, - Some(AppAction::DisableHotbar), + Some(Action::DisableHotbar), "`/hotbar {arg}` should disable the hotbar" ); assert!( @@ -132,11 +116,10 @@ mod tests { #[test] fn hotbar_on_and_reset_aliases_return_restore_action() { for arg in ["on", "reset", "defaults", "default"] { - let mut app = test_app(); - let result = HotbarCmd::execute(&mut app, Some(arg)); + let result = hotbar(Some(arg)); assert_eq!( result.action, - Some(AppAction::RestoreHotbarDefaults), + Some(Action::RestoreHotbarDefaults), "`/hotbar {arg}` should restore default hotbar slots" ); assert!(result.message.is_none()); @@ -145,10 +128,7 @@ mod tests { #[test] fn hotbar_unknown_arg_reports_error() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, Some("bogus")); - + let result = hotbar(Some("bogus")); assert!(result.is_error); assert!(result.action.is_none()); assert!( diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs index 7262a8d6ca..5043198571 100644 --- a/crates/tui/src/commands/groups/core/mod.rs +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -22,12 +22,12 @@ mod help; mod hf; mod home; mod hooks; -mod hotbar; mod lane; mod links; mod model; mod modeldb; mod models; +pub(in crate::commands) mod navigation; mod pin; mod profile; mod provider; @@ -37,9 +37,7 @@ pub(in crate::commands) mod router; mod setup; mod stash; mod subagents; -mod transcript; mod translate; -mod turn; pub mod util; pub mod voice; mod workflow; @@ -136,10 +134,7 @@ impl CommandGroup for CoreCommands { workflow::AutoCmd::info(), workflow::AutoCmd::execute, )), - Box::new(FunctionCommand::new( - hotbar::HotbarCmd::info(), - hotbar::HotbarCmd::execute, - )), + crate::commands::core_navigation_host::registration::<0>(), Box::new(FunctionCommand::new( setup::SetupCmd::info(), setup::SetupCmd::execute, @@ -156,14 +151,8 @@ impl CommandGroup for CoreCommands { links::LinksCmd::info(), links::LinksCmd::execute, )), - Box::new(FunctionCommand::new( - transcript::TranscriptCmd::info(), - transcript::TranscriptCmd::execute, - )), - Box::new(FunctionCommand::new( - turn::TurnCmd::info(), - turn::TurnCmd::execute, - )), + crate::commands::core_navigation_host::registration::<1>(), + crate::commands::core_navigation_host::registration::<2>(), Box::new(FunctionCommand::new( feedback::FeedbackCmd::info(), feedback::FeedbackCmd::execute, diff --git a/crates/tui/src/commands/groups/core/navigation.rs b/crates/tui/src/commands/groups/core/navigation.rs new file mode 100644 index 0000000000..4f083f73a1 --- /dev/null +++ b/crates/tui/src/commands/groups/core/navigation.rs @@ -0,0 +1,24 @@ +#[path = "hotbar.rs"] +pub mod hotbar; +#[path = "transcript.rs"] +pub mod transcript; +#[path = "turn.rs"] +pub mod turn; + +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult; + +pub fn portable_handlers() -> [( + &'static CommandInfo, + CommandHandler, +); 3] { + [ + (hotbar::HotbarCmd::info(), hotbar::HotbarCmd::handler()), + ( + transcript::TranscriptCmd::info(), + transcript::TranscriptCmd::handler(), + ), + (turn::TurnCmd::info(), turn::TurnCmd::handler()), + ] +} diff --git a/crates/tui/src/commands/groups/core/transcript.rs b/crates/tui/src/commands/groups/core/transcript.rs index 542813e9e4..332a89397c 100644 --- a/crates/tui/src/commands/groups/core/transcript.rs +++ b/crates/tui/src/commands/groups/core/transcript.rs @@ -1,28 +1,28 @@ //! `/transcript` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "transcript", aliases: &[], usage: "/transcript", // Reuse the keybinding description so the command palette and shortcut // catalog describe the same live-transcript surface in every locale. - description_id: MessageId::KbLiveTranscript, + description_key: "kb_live_transcript", }; -pub(in crate::commands) struct TranscriptCmd; +pub struct TranscriptCmd; -impl RegisterCommand for TranscriptCmd { +impl RegisterCommand for TranscriptCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - fn execute(_app: &mut App, _arg: Option<&str>) -> CommandResult { - CommandResult::action(AppAction::OpenLiveTranscript) + fn handler() -> CommandHandler { + CommandHandler::Pure(|_| CommandResult::action(Action::OpenLiveTranscript)) } } diff --git a/crates/tui/src/commands/groups/core/turn.rs b/crates/tui/src/commands/groups/core/turn.rs index 206ad06e10..669e255abd 100644 --- a/crates/tui/src/commands/groups/core/turn.rs +++ b/crates/tui/src/commands/groups/core/turn.rs @@ -1,72 +1,62 @@ //! `/turn` command — inspect the current or latest completed turn. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "turn", aliases: &["turns"], usage: "/turn inspect", - description_id: MessageId::CmdTurnInspectDescription, + description_key: "cmd_turn_inspect_description", }; -pub(in crate::commands) struct TurnCmd; +pub struct TurnCmd; -impl RegisterCommand for TurnCmd { +impl RegisterCommand for TurnCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - fn execute(_app: &mut App, arg: Option<&str>) -> CommandResult { - let verb = arg.map(str::trim).unwrap_or(""); - if matches!(verb, "inspect" | "i" | "") { - return CommandResult::action(AppAction::OpenTurnInspector); - } - CommandResult::error(format!( - "Unknown /turn verb '{verb}'. Use /turn inspect to open the whole-turn inspector." - )) + fn handler() -> CommandHandler { + CommandHandler::Pure(turn) } } +fn turn(arg: Option<&str>) -> CommandResult { + let verb = arg.map(str::trim).unwrap_or(""); + if matches!(verb, "inspect" | "i" | "") { + return CommandResult::action(Action::OpenTurnInspector); + } + CommandResult::error(format!( + "Unknown /turn verb '{verb}'. Use /turn inspect to open the whole-turn inspector." + )) +} + #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use std::path::PathBuf; - - fn test_app() -> App { - let options = TuiOptions { - model: "deepseek-v4-flash".to_string(), - ..crate::tui::test_support::test_tui_options(PathBuf::from(".")) - }; - App::new(options, &Config::default()) - } #[test] fn bare_turn_inspect_opens_turn_inspector() { - let mut app = test_app(); - let result = TurnCmd::execute(&mut app, None); + let result = turn(None); assert!(!result.is_error); - assert_eq!(result.action, Some(AppAction::OpenTurnInspector)); + assert_eq!(result.action, Some(Action::OpenTurnInspector)); assert!(result.message.is_none()); } #[test] fn turn_inspect_verb_opens_turn_inspector() { - let mut app = test_app(); - let result = TurnCmd::execute(&mut app, Some("inspect")); + let result = turn(Some("inspect")); assert!(!result.is_error); - assert_eq!(result.action, Some(AppAction::OpenTurnInspector)); + assert_eq!(result.action, Some(Action::OpenTurnInspector)); } #[test] fn unknown_turn_verb_returns_error() { - let mut app = test_app(); - let result = TurnCmd::execute(&mut app, Some("bad")); + let result = turn(Some("bad")); assert!(result.is_error); assert!( result diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9a14446e27..25844c485b 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -13,6 +13,7 @@ mod config_main_import_host; #[cfg(test)] mod config_policy_host; mod contract; +mod core_navigation_host; pub mod discovery; pub(crate) mod groups; @@ -1348,6 +1349,20 @@ mod tests { #[test] fn command_info_resolves_canonical_names_and_aliases() { + for (name, expected) in [ + ("hotbar", AppAction::OpenHotbarSetup), + ("transcript", AppAction::OpenLiveTranscript), + ("turn", AppAction::OpenTurnInspector), + ] { + let handler = registry().get(name).unwrap().contextual_handler().unwrap(); + let codewhale_command_contract::handler::CommandHandler::Pure(run) = handler else { + panic!("/{name} must be pure and require no host envelope"); + }; + let result = run(None); + assert_eq!(result.action, Some(expected)); + assert!(result.message.is_none()); + assert!(!result.is_error); + } for command in command_infos() { for lookup in [command.name.to_string(), format!("/{}", command.name)] { let resolved = get_command_info(&lookup) @@ -2171,6 +2186,9 @@ mod tests { // register bounded portable groups/slices; every entry outside the // explicit list must still use the original legacy dispatcher. const MIGRATED_GROUPS: &[&str] = &[ + "hotbar", + "transcript", + "turn", // FEAT-018 utility group. "attach", "automation", From e6df81c0dfd192f861e82870821ad2258f07aefa Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sat, 17 Oct 2026 03:31:17 +0200 Subject: [PATCH 3/9] refactor(commands): adopt stateful core navigation Shapes FEAT-031 P3 replaces the App-bound help/home/overview/links/anchor/workspace/translate wrappers and nine core.rs helpers with command-owned parsing/rendering, NAVIGATION bit30, narrow workspace/config operations and explicit host adapters. Retain all34 relocated host tests, old capability identities and confinement controls. No physical move or complete-core claim. Verified:79 contract and1289 commands/help/voice tests pass;1125 native observations across15 locales plus40 metadata entries remain byte-identical. Full Clippy, formatting,30 boundary/63 migration controls and locale guards pass; all failures/retries retained. Refs #5316. --- crates/command-contract/src/config.rs | 2 + crates/command-contract/src/facets.rs | 10 +- crates/command-contract/src/handler.rs | 16 + crates/command-contract/src/lib.rs | 1 + crates/command-contract/src/navigation.rs | 95 +++ crates/command-contract/src/tests.rs | 39 + crates/tui/src/commands/contract.rs | 55 +- .../tui/src/commands/contract/config_main.rs | 8 + .../src/commands/contract/core_navigation.rs | 231 +++++ .../commands/core_navigation_host_tests.rs | 670 +++++++++++++++ .../debug_diagnostics_surface_tests.rs | 2 + .../src/commands/debug_mutation_host_tests.rs | 2 + .../tui/src/commands/groups/config/config.rs | 6 + crates/tui/src/commands/groups/core/anchor.rs | 307 ++----- crates/tui/src/commands/groups/core/core.rs | 787 +----------------- crates/tui/src/commands/groups/core/help.rs | 160 +++- crates/tui/src/commands/groups/core/home.rs | 237 +++--- crates/tui/src/commands/groups/core/links.rs | 135 ++- crates/tui/src/commands/groups/core/mod.rs | 47 +- .../src/commands/groups/core/navigation.rs | 27 +- .../tui/src/commands/groups/core/translate.rs | 46 +- .../tui/src/commands/groups/core/workspace.rs | 107 ++- crates/tui/src/commands/groups/memory/note.rs | 22 + .../tui/src/commands/groups/utility/task.rs | 44 + crates/tui/src/commands/mod.rs | 147 ++++ .../commands/session_export_test_support.rs | 2 + .../commands/session_structcopy_host_tests.rs | 2 + 27 files changed, 1971 insertions(+), 1236 deletions(-) create mode 100644 crates/command-contract/src/navigation.rs create mode 100644 crates/tui/src/commands/contract/core_navigation.rs create mode 100644 crates/tui/src/commands/core_navigation_host_tests.rs diff --git a/crates/command-contract/src/config.rs b/crates/command-contract/src/config.rs index c8070a3716..bc36fb0030 100644 --- a/crates/command-contract/src/config.rs +++ b/crates/command-contract/src/config.rs @@ -222,6 +222,8 @@ pub const EFFECT_REPLY_TEMPLATES: &[(&str, &[&str])] = &[ pub trait CommandConfigContext { /// Path of the active user configuration file, when one is resolved. fn config_path(&self) -> Option; + fn translation_enabled(&self) -> bool; + fn set_translation_enabled(&mut self, enabled: bool); /// `/config status|audit|editability|editable` — typed audit rows. fn audit(&mut self) -> Result; diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index cb8a8d601f..f54fea1660 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -107,9 +107,17 @@ pub trait CommandSkillsContext { fn refresh_skill_cache(&mut self); } -/// Workspace path and a bounded serialized work-state snapshot. +/// Workspace path, bounded work-state observations and confined anchor storage. pub trait CommandWorkspaceContext { fn workspace(&self) -> PathBuf; + fn effective_home_dir(&self) -> Option; + fn inspect_workspace_target(&self, candidate: &Path) -> crate::navigation::WorkspaceTarget; + fn read_anchor_document(&self) -> Result, String>; + fn append_anchor_bytes( + &mut self, + parts: &[&[u8]], + ) -> Result; + fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String>; fn work_state_snapshot(&self) -> Result, String>; /// Session-aware canonical operation digest. Returns the final user-facing /// digest text or a safe explicit error; never a serialized snapshot. diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index f31c5289e2..7d7c8acc3d 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -101,6 +101,7 @@ impl CommandCapabilities { pub const LOGIN: Self = Self(1 << 28); /// Workbar placement/panel state for `/workbar` (FEAT-028 D3). pub const WORK_SURFACE: Self = Self(1 << 29); + pub const NAVIGATION: Self = Self(1 << 30); /// Raw bit pattern, for tests that pin the capability-space capacity. /// @@ -174,6 +175,7 @@ pub struct CommandContexts<'a> { trust: Option<&'a mut dyn crate::config::CommandTrustContext>, login: Option<&'a mut dyn crate::config::CommandLoginContext>, work_surface: Option<&'a mut dyn crate::config::CommandWorkSurfaceContext>, + navigation: Option<&'a mut dyn crate::navigation::CommandNavigationContext>, } /// Consumed envelope used when one handler needs several independent facets. @@ -208,6 +210,7 @@ pub struct ContextParts<'a> { pub trust: Option<&'a mut dyn crate::config::CommandTrustContext>, pub login: Option<&'a mut dyn crate::config::CommandLoginContext>, pub work_surface: Option<&'a mut dyn crate::config::CommandWorkSurfaceContext>, + pub navigation: Option<&'a mut dyn crate::navigation::CommandNavigationContext>, } impl<'a> CommandContexts<'a> { @@ -243,6 +246,7 @@ impl<'a> CommandContexts<'a> { trust: None, login: None, work_surface: None, + navigation: None, } } @@ -278,6 +282,7 @@ impl<'a> CommandContexts<'a> { trust: self.trust, login: self.login, work_surface: self.work_surface, + navigation: self.navigation, } } @@ -321,6 +326,17 @@ impl<'a> CommandContexts<'a> { self } + pub fn with_navigation( + mut self, + value: &'a mut dyn crate::navigation::CommandNavigationContext, + ) -> Self { + assert!( + self.navigation.replace(value).is_none(), + "navigation facet already set" + ); + self + } + pub fn with_permissions(mut self, value: &'a mut dyn CommandPermissionsContext) -> Self { assert!( self.permissions.replace(value).is_none(), diff --git a/crates/command-contract/src/lib.rs b/crates/command-contract/src/lib.rs index 6c645fbe11..72817f6250 100644 --- a/crates/command-contract/src/lib.rs +++ b/crates/command-contract/src/lib.rs @@ -9,6 +9,7 @@ pub mod elapsed; pub mod facets; pub mod handler; pub mod metadata; +pub mod navigation; pub mod outcome; pub mod types; diff --git a/crates/command-contract/src/navigation.rs b/crates/command-contract/src/navigation.rs new file mode 100644 index 0000000000..9f087c630f --- /dev/null +++ b/crates/command-contract/src/navigation.rs @@ -0,0 +1,95 @@ +use std::path::PathBuf; + +use crate::types::CommandMode; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationUserHelp { + pub name: String, + pub description: Option, + pub usage: Option, + pub aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationBuiltinHelp { + pub name: String, + pub usage: String, + pub description: String, + pub aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationHelpTopic { + pub user: Option, + pub builtin: Option, + pub shadowed_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationSkill { + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationOverview { + pub model: String, + pub mode: CommandMode, + pub mode_label: String, + pub workspace: PathBuf, + pub history_count: usize, + pub total_tokens: u32, + pub queued_count: usize, + pub subagent_count: usize, + pub active_skill: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavigationProviderKind { + Moonshot, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationProviderLinks { + pub id: String, + pub name: String, + pub credential_url: Option, + pub docs_url: Option, + pub guidance: String, + pub env_names: Vec, + pub kind: NavigationProviderKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationLinks { + pub active_provider: String, + pub locale_tag: String, + pub providers: Vec, + pub kimi_route: String, + pub kimi_console: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceTarget { + Missing, + NotDirectory, + Directory { canonical_or_candidate: PathBuf }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnchorWriteError { + Open(String), + Write(String), +} + +pub trait CommandNavigationContext { + fn help_topic(&self, topic: &str) -> NavigationHelpTopic; + fn cached_skills(&self) -> Vec; + fn help_is_open(&self) -> bool; + fn open_help(&mut self); + fn home_is_blocked(&self) -> bool; + fn restore_home(&mut self); + fn overview(&self) -> NavigationOverview; + fn links(&self) -> NavigationLinks; +} diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index e71af81f47..7d5129f166 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -121,6 +121,24 @@ impl CommandSkillsContext for Skills { struct Workspace; impl CommandWorkspaceContext for Workspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by this fixture") + } + fn inspect_workspace_target(&self, _: &std::path::Path) -> crate::navigation::WorkspaceTarget { + unreachable!("navigation is not used by this fixture") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by this fixture") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result { + unreachable!("anchors are not used by this fixture") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by this fixture") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } @@ -646,6 +664,24 @@ impl CommandMediaContext for Media { struct DigestWorkspace; impl CommandWorkspaceContext for DigestWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by digest") + } + fn inspect_workspace_target(&self, _: &std::path::Path) -> crate::navigation::WorkspaceTarget { + unreachable!("navigation is not used by digest") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by digest") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result { + unreachable!("anchors are not used by digest") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by digest") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } @@ -3556,6 +3592,7 @@ fn structcopy_capability_preserves_all_published_identities() { CommandCapabilities::TRUST, CommandCapabilities::LOGIN, CommandCapabilities::WORK_SURFACE, + CommandCapabilities::NAVIGATION, ]; let exact = CommandCapabilities::SESSION_STRUCTCOPY | CommandCapabilities::PRESENTATION; for (index, capability) in capabilities.into_iter().enumerate() { @@ -3620,6 +3657,7 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { trust, login, work_surface, + navigation, } = CommandContexts::empty() .with_structcopy(&mut fixture) .with_presentation(&mut presentation) @@ -3669,6 +3707,7 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { trust.is_some(), login.is_some(), work_surface.is_some(), + navigation.is_some(), ] { assert!(!present); } diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 5a67a74271..dcb1544d86 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -14,7 +14,7 @@ //! //! ## Authoritative host-proxy design (D1) //! -//! `CommandContexts` has twenty-five independently optional facet slots, all +//! `CommandContexts` has independently optional facet slots, all //! constructed here. The diagnostics adapter joins the host bundle in FEAT-029. Important behavior (mode transitions, model //! invalidation, cost accounting, skill refresh) is authoritative on `App`. The adapters therefore share a //! synchronous TUI-owned host proxy. Each trait call borrows `App` only for the @@ -32,6 +32,8 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; mod config_main; +mod core_navigation; +use core_navigation::NavigationAdapter; pub(in crate::commands) mod config_policy; #[cfg(test)] mod config_policy_baseline; @@ -205,6 +207,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_help_description" => MessageId::CmdHelpDescription, "cmd_hf_description" => MessageId::CmdHfDescription, "cmd_home_description" => MessageId::CmdHomeDescription, + "cmd_overview_description" => MessageId::CmdOverviewDescription, "cmd_hooks_description" => MessageId::CmdHooksDescription, "cmd_import_claude_description" => MessageId::CmdImportClaudeDescription, "cmd_inline_description" => MessageId::CmdInlineDescription, @@ -2130,7 +2133,7 @@ impl CommandSkillsContext for SkillsAdapter<'_> { } } -/// Workspace path and bounded serialized work-state snapshot. +/// Workspace observations and confined anchor operations. pub(crate) struct WorkspaceAdapter<'a> { host: SharedCommandHost<'a>, } @@ -2140,6 +2143,43 @@ impl CommandWorkspaceContext for WorkspaceAdapter<'_> { self.host.app.borrow().workspace.clone() } + fn effective_home_dir(&self) -> Option { + crate::config::effective_home_dir() + } + + fn inspect_workspace_target( + &self, + candidate: &Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + use codewhale_command_contract::navigation::WorkspaceTarget; + if !candidate.exists() { + WorkspaceTarget::Missing + } else if !candidate.is_dir() { + WorkspaceTarget::NotDirectory + } else { + WorkspaceTarget::Directory { + canonical_or_candidate: candidate + .canonicalize() + .unwrap_or_else(|_| candidate.to_path_buf()), + } + } + } + + fn read_anchor_document(&self) -> Result, String> { + core_navigation::read_anchor_document(&self.workspace()) + } + + fn append_anchor_bytes( + &mut self, + parts: &[&[u8]], + ) -> Result { + core_navigation::append_anchor_bytes(&self.workspace(), parts) + } + + fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String> { + core_navigation::replace_anchor_document(&self.workspace(), bytes) + } + fn work_state_snapshot(&self) -> Result, String> { self.host.app.borrow().work_state_snapshot().map(|state| { state.and_then(|state| crate::todo_snapshot::todo_snapshot_body(&state.todos)) @@ -2181,6 +2221,7 @@ impl CommandPresentationContext for PresentationAdapter<'_> { .or_else(|| diagnostics_messages::resolve(key)) .or_else(|| config_policy_messages::resolve(key)) .or_else(|| config_messages::resolve(key)) + .or_else(|| core_navigation::resolve_message(key)) else { return Err("unknown translation key".to_string()); }; @@ -4626,6 +4667,7 @@ pub(crate) struct CommandContextBundle<'a> { trust: TrustAdapter<'a>, login: LoginAdapter<'a>, work_surface: WorkSurfaceAdapter<'a>, + navigation: NavigationAdapter<'a>, } impl<'a> CommandContextBundle<'a> { @@ -4722,6 +4764,9 @@ impl<'a> CommandContextBundle<'a> { if capabilities.contains(CommandCapabilities::WORK_SURFACE) { contexts = contexts.with_work_surface(&mut self.work_surface); } + if capabilities.contains(CommandCapabilities::NAVIGATION) { + contexts = contexts.with_navigation(&mut self.navigation); + } contexts } @@ -4757,7 +4802,8 @@ impl<'a> CommandContextBundle<'a> { .union(CommandCapabilities::PET) .union(CommandCapabilities::TRUST) .union(CommandCapabilities::LOGIN) - .union(CommandCapabilities::WORK_SURFACE); + .union(CommandCapabilities::WORK_SURFACE) + .union(CommandCapabilities::NAVIGATION); self.contexts(all_test_capabilities).into_parts() } } @@ -4808,7 +4854,8 @@ impl App { pet: PetAdapter { host: host.clone() }, trust: TrustAdapter { host: host.clone() }, login: LoginAdapter { host: host.clone() }, - work_surface: WorkSurfaceAdapter { host }, + work_surface: WorkSurfaceAdapter { host: host.clone() }, + navigation: NavigationAdapter { host }, } } } diff --git a/crates/tui/src/commands/contract/config_main.rs b/crates/tui/src/commands/contract/config_main.rs index e1f550fc62..9450382b88 100644 --- a/crates/tui/src/commands/contract/config_main.rs +++ b/crates/tui/src/commands/contract/config_main.rs @@ -23,6 +23,14 @@ pub(crate) struct ConfigAdapter<'a> { } impl CommandConfigContext for ConfigAdapter<'_> { + fn translation_enabled(&self) -> bool { + self.host.app.borrow().translation_enabled + } + + fn set_translation_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().translation_enabled = enabled; + } + fn config_path(&self) -> Option { self.host.app.borrow().config_path.clone() } diff --git a/crates/tui/src/commands/contract/core_navigation.rs b/crates/tui/src/commands/contract/core_navigation.rs new file mode 100644 index 0000000000..6011887eef --- /dev/null +++ b/crates/tui/src/commands/contract/core_navigation.rs @@ -0,0 +1,231 @@ +use super::{SharedCommandHost, to_command_mode}; +use crate::tui::app::AppModeUi; +use crate::tui::views::{HelpView, ModalKind}; +use codewhale_command_contract::navigation::{ + CommandNavigationContext, NavigationBuiltinHelp, NavigationHelpTopic, NavigationLinks, + NavigationOverview, NavigationProviderKind, NavigationProviderLinks, NavigationSkill, + NavigationUserHelp, +}; +use codewhale_localization::MessageId; + +pub(super) struct NavigationAdapter<'a> { + pub(super) host: SharedCommandHost<'a>, +} + +impl CommandNavigationContext for NavigationAdapter<'_> { + fn help_topic(&self, topic: &str) -> NavigationHelpTopic { + let app = self.host.app.borrow(); + let builtin = crate::commands::get_command_info(topic); + crate::commands::user_registry::with_registry_for_app(&app, |registry| { + NavigationHelpTopic { + user: registry.get(topic).map(|command| NavigationUserHelp { + name: command.name.clone(), + description: command.description.clone(), + usage: command.display_usage().map(str::to_owned), + aliases: command.aliases.clone(), + }), + shadowed_names: builtin + .map(|command| { + command + .aliases + .iter() + .filter(|alias| registry.get(alias).is_some()) + .map(|alias| (*alias).to_owned()) + .collect() + }) + .unwrap_or_default(), + builtin: builtin.map(|command| NavigationBuiltinHelp { + name: command.name.to_owned(), + usage: command.usage.to_owned(), + description: command.description_for(app.ui_locale).into_owned(), + aliases: command + .aliases + .iter() + .map(|alias| (*alias).to_owned()) + .collect(), + }), + } + }) + } + + fn cached_skills(&self) -> Vec { + self.host + .app + .borrow() + .cached_skills + .iter() + .map(|(name, description)| NavigationSkill { + name: name.clone(), + description: description.clone(), + }) + .collect() + } + + fn help_is_open(&self) -> bool { + self.host.app.borrow().view_stack.top_kind() == Some(ModalKind::Help) + } + + fn open_help(&mut self) { + let mut app = self.host.app.borrow_mut(); + let help = HelpView::new_for_app(&app, false).with_groups_expanded(app.help_expand_groups); + app.view_stack.push(help); + } + + fn home_is_blocked(&self) -> bool { + self.host.app.borrow().session_transition_blocked() + } + + fn restore_home(&mut self) { + let mut app = self.host.app.borrow_mut(); + app.launch.workspace = app.workspace.clone(); + app.launch.restore_card(); + app.launch.visible = true; + app.launch.return_to_session = true; + app.needs_redraw = true; + } + + fn overview(&self) -> NavigationOverview { + let app = self.host.app.borrow(); + NavigationOverview { + model: app.model.clone(), + mode: to_command_mode(app.mode), + mode_label: app.mode.display_name_localized(app.ui_locale).into_owned(), + workspace: app.workspace.clone(), + history_count: app.history.len(), + total_tokens: app.session.displayed_total_conversation_tokens(), + queued_count: app.queued_messages.len(), + subagent_count: app.subagent_cache.len(), + active_skill: app.active_skill.clone(), + } + } + + fn links(&self) -> NavigationLinks { + let app = self.host.app.borrow(); + NavigationLinks { + active_provider: app.api_provider.as_str().to_owned(), + locale_tag: app.ui_locale.tag().to_owned(), + providers: codewhale_config::provider::providers_sorted_for_display() + .into_iter() + .map(|provider| { + let links = provider.credential_help(); + NavigationProviderLinks { + id: provider.id().to_owned(), + name: provider.display_name().to_owned(), + credential_url: links.credential_url.map(str::to_owned), + docs_url: links.docs_url.map(str::to_owned), + guidance: links.guidance.to_owned(), + env_names: provider + .env_vars() + .iter() + .map(|name| (*name).to_owned()) + .collect(), + kind: if provider.kind() == codewhale_config::ProviderKind::Moonshot { + NavigationProviderKind::Moonshot + } else { + NavigationProviderKind::Other + }, + } + }) + .collect(), + kimi_route: crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_owned(), + kimi_console: crate::config::KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL.to_owned(), + } + } +} + +pub(super) fn anchors_path(workspace: &std::path::Path) -> std::path::PathBuf { + let primary = workspace.join(".codewhale").join("anchors.md"); + if primary.symlink_metadata().is_ok() || workspace.join(".codewhale").is_symlink() { + return primary; + } + workspace.join(".deepseek").join("anchors.md") +} + +pub(super) fn read_anchor_document(workspace: &std::path::Path) -> Result, String> { + match crate::fs_confined::read_to_string(workspace, &anchors_path(workspace)) { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +pub(super) fn append_anchor_bytes( + workspace: &std::path::Path, + parts: &[&[u8]], +) -> Result { + use codewhale_command_contract::navigation::AnchorWriteError; + use std::io::Write; + let path = anchors_path(workspace); + let mut file = crate::fs_confined::open_append(workspace, &path) + .map_err(|error| AnchorWriteError::Open(error.to_string()))?; + for bytes in parts { + file.write_all(bytes) + .map_err(|error| AnchorWriteError::Write(error.to_string()))?; + } + Ok(path) +} + +pub(super) fn replace_anchor_document( + workspace: &std::path::Path, + bytes: &[u8], +) -> Result<(), String> { + crate::fs_confined::write(workspace, &anchors_path(workspace), bytes) + .map_err(|error| error.to_string()) +} + +pub(super) fn resolve_message(key: &str) -> Option { + Some(match key { + "cmd_translate_on" => MessageId::CmdTranslateOn, + "cmd_translate_off" => MessageId::CmdTranslateOff, + "workspace_current" => MessageId::WorkspaceCurrent, + "workspace_not_found" => MessageId::WorkspaceNotFound, + "workspace_not_directory" => MessageId::WorkspaceNotDirectory, + "workspace_switching" => MessageId::WorkspaceSwitching, + "workspace_home_unresolved" => MessageId::WorkspaceHomeUnresolved, + "help_aliases_label" => MessageId::HelpAliasesLabel, + "help_unknown_command" => MessageId::HelpUnknownCommand, + "config_help_discoverable" => MessageId::ConfigHelpDiscoverable, + "home_navigation_busy" => MessageId::HomeNavigationBusy, + "home_dashboard_title" => MessageId::HomeDashboardTitle, + "home_model" => MessageId::HomeModel, + "home_mode" => MessageId::HomeMode, + "home_workspace" => MessageId::HomeWorkspace, + "home_history" => MessageId::HomeHistory, + "home_tokens" => MessageId::HomeTokens, + "home_queued" => MessageId::HomeQueued, + "home_subagents" => MessageId::HomeSubagents, + "home_skill" => MessageId::HomeSkill, + "home_quick_actions" => MessageId::HomeQuickActions, + "home_quick_workspace" => MessageId::HomeQuickWorkspace, + "home_quick_restore" => MessageId::HomeQuickRestore, + "home_quick_tokens" => MessageId::HomeQuickTokens, + "home_quick_links" => MessageId::HomeQuickLinks, + "home_quick_skills" => MessageId::HomeQuickSkills, + "home_quick_config" => MessageId::HomeQuickConfig, + "home_quick_settings" => MessageId::HomeQuickSettings, + "home_quick_model" => MessageId::HomeQuickModel, + "home_quick_subagents" => MessageId::HomeQuickSubagents, + "home_quick_task_list" => MessageId::HomeQuickTaskList, + "home_quick_help" => MessageId::HomeQuickHelp, + "home_mode_tips" => MessageId::HomeModeTips, + "home_agent_mode_tip" => MessageId::HomeAgentModeTip, + "home_agent_mode_review_tip" => MessageId::HomeAgentModeReviewTip, + "home_agent_mode_yolo_tip" => MessageId::HomeAgentModeYoloTip, + "home_operate_mode_tip" => MessageId::HomeOperateModeTip, + "home_operate_mode_fleet_tip" => MessageId::HomeOperateModeFleetTip, + "home_plan_mode_tip" => MessageId::HomePlanModeTip, + "home_plan_mode_checklist_tip" => MessageId::HomePlanModeChecklistTip, + "links_project_title" => MessageId::LinksProjectTitle, + "links_documentation" => MessageId::LinksDocumentation, + "links_community" => MessageId::LinksCommunity, + "links_git_hub" => MessageId::LinksGitHub, + "links_managed_app" => MessageId::LinksManagedApp, + "links_managed_app_note" => MessageId::LinksManagedAppNote, + "links_title" => MessageId::LinksTitle, + "links_dashboard" => MessageId::LinksDashboard, + "links_docs" => MessageId::LinksDocs, + "links_kimi_code_route_note" => MessageId::LinksKimiCodeRouteNote, + "links_tip" => MessageId::LinksTip, + _ => return None, + }) +} diff --git a/crates/tui/src/commands/core_navigation_host_tests.rs b/crates/tui/src/commands/core_navigation_host_tests.rs new file mode 100644 index 0000000000..2609bcf6e0 --- /dev/null +++ b/crates/tui/src/commands/core_navigation_host_tests.rs @@ -0,0 +1,670 @@ +use crate::commands::CommandResult; +use crate::config::Config; +use crate::tui::app::{App, AppAction, TuiOptions}; +use crate::tui::views::ModalKind; +use codewhale_config::AppMode; +use codewhale_localization::Locale; +use std::path::PathBuf; +use tempfile::tempdir; + +fn help(app: &mut App, topic: Option<&str>) -> CommandResult { + let input = topic + .map(|topic| format!("/help {topic}")) + .unwrap_or_else(|| "/help".into()); + crate::commands::execute(&input, app) +} + +fn codewhale_links(app: &mut App) -> CommandResult { + crate::commands::execute("/links", app) +} + +fn home_dashboard(app: &mut App) -> CommandResult { + crate::commands::execute("/overview", app) +} + +fn create_test_app() -> App { + let options = TuiOptions { + skills_dir: PathBuf::from("/tmp/test-skills"), + ..crate::tui::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace")) + }; + let mut app = App::new(options, &Config::default()); + app.ui_locale = codewhale_localization::Locale::En; + app.api_provider = crate::config::ProviderKind::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.model_ids_passthrough = false; + app +} + +#[test] +fn help_topic_resolves_a_discovered_skill_and_both_shapes() { + // #3912: `/help ` said "Unknown command" for a skill that + // executes and autocompletes. + let mut app = create_test_app(); + app.cached_skills = vec![( + "codereview".to_string(), + "Review a diff for defects".to_string(), + )]; + + for topic in [ + "codereview", + "CodeReview", + "$codereview", + "/skill codereview", + ] { + let result = help(&mut app, Some(topic)); + let message = result + .message + .as_deref() + .unwrap_or_else(|| panic!("{topic} should resolve to skill help")); + assert!(message.contains("Review a diff for defects"), "{message}"); + assert!(message.contains("$codereview"), "{message}"); + assert!(message.contains("/skill codereview"), "{message}"); + } +} + +#[test] +fn help_topic_still_errors_for_an_unknown_name() { + let mut app = create_test_app(); + app.cached_skills = vec![("codereview".to_string(), "Review a diff".to_string())]; + let result = help(&mut app, Some("definitely-not-a-thing")); + assert!(result.is_error, "unknown topics must still be an error"); + assert!( + !result + .message + .as_deref() + .unwrap_or_default() + .contains("Review a diff"), + "the skill fallback must not match an unrelated topic" + ); +} + +#[test] +fn test_help_unknown_command() { + let mut app = create_test_app(); + let result = help(&mut app, Some("nonexistent")); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Unknown command")); + assert!(result.action.is_none()); +} + +#[test] +fn test_help_known_command() { + let mut app = create_test_app(); + let result = help(&mut app, Some("clear")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("clear")); + assert!(msg.contains("Clear conversation history")); + assert!(msg.contains("Usage: /clear")); +} + +#[test] +fn test_help_config_topic_uses_interactive_editor_text() { + let mut app = create_test_app(); + let result = help(&mut app, Some("config")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("config")); + assert!(msg.contains("Inspect and change settings")); + assert!(msg.contains("Usage: /config")); + assert!(msg.contains("context_window = 262144")); + assert!(msg.contains("/config context_window")); + assert!(msg.contains("/config search.provider")); + assert!(msg.contains("/config prompt_suggestion")); + assert!(msg.contains("/config notifications")); +} + +#[test] +fn test_help_links_topic_shows_aliases() { + let mut app = create_test_app(); + let result = help(&mut app, Some("links")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("links")); + assert!(msg.contains("Show Codewhale, community, and provider links")); + assert!(msg.contains("Usage: /links")); + assert!(msg.contains("Aliases: dashboard, api")); +} + +#[test] +fn test_help_memory_topic_shows_usage_and_description() { + let mut app = create_test_app(); + let result = help(&mut app, Some("memory")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("memory")); + assert!(msg.contains("persistent structured user memory")); + assert!(msg.contains( + "Usage: /memory [status|path|search|get|remember|import|export|reindex|clear|help]" + )); +} + +#[test] +fn test_help_pushes_overlay() { + let mut app = create_test_app(); + assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help)); + let result = help(&mut app, None); + assert_eq!(result.message, None); + assert_eq!(result.action, None); + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); +} + +#[test] +fn test_help_does_not_duplicate_overlay() { + let mut app = create_test_app(); + help(&mut app, None); + let initial_kind = app.view_stack.top_kind(); + help(&mut app, None); + assert_eq!(app.view_stack.top_kind(), initial_kind); +} + +#[test] +fn test_codewhale_links() { + let mut app = create_test_app(); + let result = codewhale_links(&mut app); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Codewhale & community")); + assert!(msg.contains("https://codewhale.net/en/docs")); + assert!(msg.contains("https://codewhale.net/en/community")); + assert!(msg.contains("https://github.com/codewhale-hq/CodeWhale")); + assert!(msg.contains("https://app.codewhale.net")); + assert!(msg.contains("separate sign-in")); + assert!(msg.contains("not connected to the current local session")); + assert!(msg.contains("Provider Links")); + assert!(msg.contains("DeepSeek (deepseek) <- current")); + assert!(msg.contains("https://platform.deepseek.com/api_keys")); + assert!(msg.contains("Xiaomi MiMo (xiaomi-mimo)")); + assert!(msg.contains("https://platform.xiaomimimo.com/token-plan")); + assert!(msg.contains("Moonshot/Kimi (moonshot)")); + assert!(msg.contains("https://platform.kimi.ai/console/api-keys")); + assert!(msg.contains("https://platform.kimi.ai/docs/overview")); + assert!(msg.contains("https://api.kimi.com/coding/v1")); + assert!(msg.contains("https://www.kimi.com/code/console")); + assert!(msg.contains("never imports Kimi CLI credentials")); + assert!(msg.contains("https://console.openmodel.ai/")); + assert!(msg.contains("https://docs.openmodel.ai/en/docs/getting-started/authentication")); + assert!(msg.contains("https://console.sakana.ai/api-keys")); + assert!(msg.contains("https://console.sakana.ai/get-started")); + assert!(msg.contains("Baidu Qianfan (qianfan)")); + assert!(msg.contains("https://cloud.baidu.com/doc/qianfan/index.html")); + assert!(msg.contains("Local Ollama is keyless by default")); + assert!(msg.contains("codewhale auth chatgpt")); + assert!( + !msg.contains("codex login"), + "official sign-in must not direct users to external CLI credentials" + ); + assert!(msg.contains("no canonical vendor credential page exists")); + assert!(msg.contains("OPENAI_API_KEY")); + assert!(msg.contains("XIAOMI_MIMO_TOKEN_PLAN_API_KEY")); + assert!(!msg.contains("https://codewhale.dev/docs/providers")); + assert!(result.action.is_none()); +} + +#[test] +fn provider_links_emit_urls_as_inline_code_for_narrow_transcripts() { + let mut app = create_test_app(); + let result = codewhale_links(&mut app); + let msg = result.message.expect("links should return a message"); + + assert!(msg.contains("`https://platform.openai.com/api-keys`")); + assert!( + msg.contains("`https://platform.minimax.io/user-center/basic-information/interface-key`") + ); + + for line in msg.lines().filter(|line| line.contains("http")) { + let Some(url_start) = line.find("http") else { + continue; + }; + assert!( + line[..url_start].ends_with('`') && line[url_start..].contains('`'), + "provider URL should be inline-code wrapped so narrow TUI renders do not emit oversized OSC8 link payloads: {line}" + ); + } +} + +#[test] +fn project_links_follow_the_available_public_site_locale() { + let mut app = create_test_app(); + app.ui_locale = Locale::ZhHans; + + let msg = codewhale_links(&mut app) + .message + .expect("links should return a message"); + + assert!(msg.contains("`https://codewhale.net/zh/docs`")); + assert!(msg.contains("`https://codewhale.net/zh/community`")); + assert!(msg.contains("`https://app.codewhale.net`")); +} + +#[test] +fn test_home_dashboard_includes_all_sections() { + let mut app = create_test_app(); + app.session.total_conversation_tokens = 1234; + let result = home_dashboard(&mut app); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("codewhale")); + assert!(!msg.contains("codewhale Home Dashboard")); + assert!(msg.contains("Model:")); + assert!(msg.contains("Mode:")); + assert!(msg.contains("Workspace:")); + assert!(msg.contains("History:")); + assert!(msg.contains("Tokens:")); + assert!(msg.contains("Quick Actions")); + assert!(msg.contains("Mode Tips")); + assert!(result.action.is_none()); +} + +#[test] +fn test_home_dashboard_shows_queued_when_present() { + let mut app = create_test_app(); + app.queued_messages + .push_back(crate::tui::app::QueuedMessage::new( + "test".to_string(), + None, + )); + let result = home_dashboard(&mut app); + let msg = result.message.unwrap(); + assert!(msg.contains("Queued:")); +} + +#[test] +fn test_home_dashboard_mode_tips_for_each_mode() { + let modes = [AppMode::Agent, AppMode::Plan, AppMode::Operate]; + for mode in modes { + let mut app = create_test_app(); + app.mode = mode; + let result = home_dashboard(&mut app); + let msg = result.message.unwrap(); + assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); + } +} + +#[test] +fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { + let mut app = create_test_app(); + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!(msg.contains("/workspace - Switch folders or worktrees")); + assert!(msg.contains("/restore - Roll files back to a turn snapshot")); + assert!(msg.contains("/tokens - Show session spend and context")); + assert!(msg.contains("/links - Codewhale, community & provider links")); + assert!(msg.contains("/config - Inspect and change settings")); + assert!( + !msg.lines() + .any(|line| line.trim_start().starts_with("/set ")) + ); + assert!(!msg.contains("/codewhale")); +} + +#[test] +fn home_dashboard_localizes_in_zh_hans() { + use codewhale_localization::Locale; + let mut app = create_test_app(); + app.ui_locale = Locale::ZhHans; + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!( + msg.contains("codewhale"), + "missing canonical product title:\n{msg}" + ); + assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}"); + assert!( + msg.contains("快捷操作"), + "missing zh-Hans quick actions:\n{msg}" + ); + assert!( + msg.contains("模式提示"), + "missing zh-Hans mode tips:\n{msg}" + ); +} + +#[cfg(test)] +mod home { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn home_preserves_session_draft_and_reveal_and_escape_returns() { + let mut app = crate::tui::test_support::test_app_with_options( + crate::tui::test_support::test_tui_options("."), + ); + app.launch.dismiss(); + app.current_session_id = Some("retained-session".into()); + app.api_messages_mut().push(codewhale_models::Message { + role: codewhale_models::Role::User, + content: vec![codewhale_models::ContentBlock::Text { + text: "Retain this conversation".into(), + cache_control: None, + }], + }); + app.add_message(crate::tui::history::HistoryCell::System { + content: "Retain this transcript".into(), + }); + app.input = "unsent draft".into(); + app.cursor_position = app.input.chars().count(); + app.launch.mark_reveal_started_at = Some(std::time::Instant::now()); + let reveal = app.launch.mark_reveal_started_at; + let messages = app.api_messages.clone(); + let history_len = app.history.len(); + let result = crate::commands::execute("/home", &mut app); + assert!(!result.is_error, "{:?}", result.message); + assert!( + result.action.is_none(), + "navigation must not sync/reset the Engine" + ); + assert!(crate::tui::widgets::should_render_empty_state(&app)); + assert!(app.launch.return_to_session); + assert!(!crate::tui::underwater::launch_motion_active( + &app, false, true + )); + crate::tui::underwater::refresh_launch_row_hitboxes( + &mut app, + ratatui::layout::Rect::new(0, 0, 80, 20), + ); + let rows = crate::tui::underwater::launch_rows_for_app(&app); + assert_eq!( + crate::tui::underwater::launch_row_click_action(&rows[0].id), + crate::tui::underwater::LaunchAction::ReturnToSession, + ); + app.launch.menu_selected = Some(0); + assert_eq!( + crate::tui::underwater::handle_launch_composer_key( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + crate::tui::underwater::LaunchComposerKey::MenuRun, + ); + assert_eq!(app.input, "unsent draft"); + crate::tui::underwater::handle_launch_composer_key( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + assert!(!app.launch.visible); + assert!(!app.launch.return_to_session); + assert_eq!(app.current_session_id.as_deref(), Some("retained-session")); + assert_eq!(app.api_messages, messages); + assert_eq!(app.history.len(), history_len); + assert_eq!(app.input, "unsent draft"); + assert_eq!(app.launch.mark_reveal_started_at, reveal); + assert!(!crate::tui::widgets::should_render_empty_state(&app)); + + for command in ["/overview", "/stats"] { + let result = crate::commands::execute(command, &mut app); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Quick Actions")); + assert!(!app.launch.visible); + } + } + + #[test] + fn home_refuses_to_cover_active_work() { + let mut app = crate::tui::test_support::test_app_with_options( + crate::tui::test_support::test_tui_options("."), + ); + app.launch.dismiss(); + app.is_loading = true; + let result = crate::commands::execute("/home", &mut app); + assert!(result.is_error); + assert!(!app.launch.visible); + } +} + +fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { + let input = arg + .map(|arg| format!("/workspace {arg}")) + .unwrap_or_else(|| "/workspace".into()); + crate::commands::execute(&input, app) +} + +#[test] +fn workspace_without_arg_shows_current_workspace() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, None); + let msg = result.message.expect("workspace should be shown"); + assert!(msg.contains("Current workspace:")); + assert!(msg.contains("/tmp/test-workspace")); + assert!(result.action.is_none()); +} + +#[test] +fn workspace_existing_absolute_dir_returns_switch_action() { + let mut app = create_test_app(); + let dir = tempdir().expect("temp dir"); + let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); +} + +#[test] +fn workspace_relative_dir_resolves_from_current_workspace() { + let root = tempdir().expect("temp dir"); + let child = root.path().join("child"); + std::fs::create_dir(&child).expect("child dir"); + let mut app = create_test_app(); + app.workspace = root.path().to_path_buf(); + + let result = workspace_switch(&mut app, Some("child")); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() + )); +} + +#[test] +fn workspace_rejects_missing_path() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, Some("definitely-missing")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); +} + +#[test] +fn workspace_rejects_file_path() { + let root = tempdir().expect("temp dir"); + let file = root.path().join("file.txt"); + std::fs::write(&file, "not a directory").expect("test file"); + let mut app = create_test_app(); + + let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); + assert!(result.is_error); + assert!(result.message.unwrap().contains("not a directory")); +} + +#[cfg(test)] +mod anchor { + use crate::commands::CommandResult; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use tempfile::TempDir; + + fn anchor(app: &mut App, arg: Option<&str>) -> CommandResult { + let input = arg + .map(|arg| format!("/anchor {arg}")) + .unwrap_or_else(|| "/anchor".into()); + crate::commands::execute(&input, app) + } + + fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + ..crate::tui::test_support::test_tui_options(tmpdir.path()) + }; + App::new(options, &Config::default()) + } + + #[cfg(unix)] + #[test] + fn confined_anchors_refuse_linked_files_and_directories() { + use std::os::unix::fs::symlink; + + for directory in [".codewhale", ".deepseek"] { + for linked_directory in [false, true] { + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&workspace); + let original = "first anchor\n---\nsecond anchor"; + let target = outside.path().join("anchors.md"); + std::fs::write(&target, original).unwrap(); + let parent = workspace.path().join(directory); + if linked_directory { + symlink(outside.path(), &parent).unwrap(); + } else { + std::fs::create_dir_all(&parent).unwrap(); + symlink(&target, parent.join("anchors.md")).unwrap(); + } + for command in ["list", "next anchor", "remove 1"] { + assert!(anchor(&mut app, Some(command)).is_error); + assert_eq!(std::fs::read_to_string(&target).unwrap(), original); + } + // Exercise the truncate path independently of the read refusal. + { + let mut bundle = app.command_contexts(); + let parts = bundle + .contexts( + codewhale_command_contract::handler::CommandCapabilities::WORKSPACE, + ) + .into_parts(); + assert!( + parts + .workspace + .unwrap() + .replace_anchor_document(&[]) + .is_err() + ); + } + assert_eq!(std::fs::read_to_string(&target).unwrap(), original); + } + } + } + + #[cfg(unix)] + #[test] + fn confined_anchors_refuse_dangling_links() { + for directory in [".codewhale", ".deepseek"] { + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&workspace); + let target = outside.path().join("missing.md"); + let parent = workspace.path().join(directory); + std::fs::create_dir_all(&parent).unwrap(); + std::os::unix::fs::symlink(&target, parent.join("anchors.md")).unwrap(); + assert!(anchor(&mut app, Some("list")).is_error); + assert!(anchor(&mut app, Some("next anchor")).is_error); + { + let mut bundle = app.command_contexts(); + let parts = bundle + .contexts(codewhale_command_contract::handler::CommandCapabilities::WORKSPACE) + .into_parts(); + assert!( + parts + .workspace + .unwrap() + .replace_anchor_document(&[]) + .is_err() + ); + } + assert!(!target.exists()); + } + } + + #[test] + fn test_anchor_without_content_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, None); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage:")); + } + + #[test] + fn test_anchor_with_empty_content_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some(" ")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage:")); + } + + #[test] + fn test_anchor_add() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("API status field is unreliable")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Anchor pinned")); + + let path = tmpdir.path().join(".deepseek").join("anchors.md"); + assert!(path.exists()); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("API status field is unreliable")); + } + + #[test] + fn test_anchor_list_empty() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("list")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("No anchors set")); + } + + #[test] + fn test_anchor_list_with_items() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("First anchor")); + anchor(&mut app, Some("Second anchor")); + + let result = anchor(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("2 total")); + assert!(msg.contains("1. First anchor")); + assert!(msg.contains("2. Second anchor")); + } + + #[test] + fn test_anchor_remove() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("First anchor")); + anchor(&mut app, Some("Second anchor")); + + let result = anchor(&mut app, Some("remove 1")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Removed anchor #1")); + + let result = anchor(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("1 total")); + assert!(msg.contains("Second anchor")); + assert!(!msg.contains("First anchor")); + } + + #[test] + fn test_anchor_remove_invalid_index() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("Only anchor")); + + let result = anchor(&mut app, Some("remove 5")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); + } + + #[test] + fn test_anchor_remove_non_numeric() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("remove abc")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Invalid index")); + } +} diff --git a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs index 74c0fa8ae7..9ae5c80085 100644 --- a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs @@ -258,6 +258,7 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { trust, login, work_surface, + navigation, } = parts; assert!( permissions.is_none() @@ -267,6 +268,7 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() ); assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics"); assert_eq!( diff --git a/crates/tui/src/commands/debug_mutation_host_tests.rs b/crates/tui/src/commands/debug_mutation_host_tests.rs index 068f45b79f..24015bb047 100644 --- a/crates/tui/src/commands/debug_mutation_host_tests.rs +++ b/crates/tui/src/commands/debug_mutation_host_tests.rs @@ -1611,6 +1611,7 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { trust, login, work_surface, + navigation, } = bundle.contexts(capabilities).into_parts(); assert!( permissions.is_none() @@ -1620,6 +1621,7 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() ); for (name, present, capability) in [ ("session", session.is_some(), Caps::SESSION), diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 6056c8ae70..1d590aa1f2 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -1564,6 +1564,12 @@ mod tests { struct PanicConfig; impl CommandConfigContext for PanicConfig { + fn translation_enabled(&self) -> bool { + panic!("config facet touched") + } + fn set_translation_enabled(&mut self, _: bool) { + panic!("config facet touched") + } fn config_path(&self) -> Option { panic!("config facet touched") } diff --git a/crates/tui/src/commands/groups/core/anchor.rs b/crates/tui/src/commands/groups/core/anchor.rs index 4465152c1e..7a41a2cfcf 100644 --- a/crates/tui/src/commands/groups/core/anchor.rs +++ b/crates/tui/src/commands/groups/core/anchor.rs @@ -5,327 +5,124 @@ //! preserve invariants like "This API's status field is unreliable" or //! ".ssh/ must never be touched". -use std::io::Write; - -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::facets::CommandWorkspaceContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::AnchorWriteError; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; const USAGE: &str = "/anchor | /anchor list | /anchor remove "; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "anchor", aliases: &["maodian"], usage: USAGE, - description_id: MessageId::CmdAnchorDescription, + description_key: "cmd_anchor_description", }; -pub(in crate::commands) struct AnchorCmd; +pub struct AnchorCmd; -impl RegisterCommand for AnchorCmd { +impl RegisterCommand for AnchorCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - anchor(app, arg) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::WORKSPACE, + handler: anchor, + } } } -/// Handle the `/anchor` command with subcommands: -/// - `/anchor ` — add a new anchor -/// - `/anchor list` — list all anchors -/// - `/anchor remove ` — remove anchor by 1-based index -pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { +fn anchor(contexts: CommandContexts<'_>, content: Option<&str>) -> CommandResult { let input = match content { - Some(c) => c.trim(), - None => { - return CommandResult::error(format!("Usage: {USAGE}")); - } + Some(content) => content.trim(), + None => return CommandResult::error(format!("Usage: {USAGE}")), }; - if input.is_empty() { return CommandResult::error(format!("Usage: {USAGE}")); } - - // Parse subcommands. + let Some(workspace) = contexts.into_parts().workspace else { + return CommandResult::error("Command capability unavailable: workspace"); + }; if input.eq_ignore_ascii_case("list") { - return list_anchors(app); + return list_anchors(workspace); } - if let Some(rest) = input .strip_prefix("remove ") .or_else(|| input.strip_prefix("rm ")) .or_else(|| input.strip_prefix("delete ")) { - return remove_anchor(app, rest.trim()); + return remove_anchor(workspace, rest.trim()); } - - // Default: add a new anchor. - add_anchor(app, input) -} - -fn anchors_path(app: &App) -> std::path::PathBuf { - let primary = app.workspace.join(".codewhale").join("anchors.md"); - if primary.symlink_metadata().is_ok() || app.workspace.join(".codewhale").is_symlink() { - return primary; + let parts: &[&[u8]] = &[b"\n---\n", input.as_bytes(), b"\n"]; + match workspace.append_anchor_bytes(parts) { + Ok(path) => { + CommandResult::message(format!("Anchor pinned.\nStored in: {}", path.display())) + } + Err(AnchorWriteError::Open(error)) => { + CommandResult::error(format!("Failed to open anchors file: {error}")) + } + Err(AnchorWriteError::Write(error)) => { + CommandResult::error(format!("Failed to write anchor: {error}")) + } } - app.workspace.join(".deepseek").join("anchors.md") } -/// Read and split anchors from the file. Each anchor is separated by "\n---\n". -fn read_anchors(app: &App) -> Result, String> { - let path = anchors_path(app); - let content = match crate::fs_confined::read_to_string(&app.workspace, &path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("Failed to read anchors file: {e}")), +fn read_anchors(workspace: &dyn CommandWorkspaceContext) -> Result, String> { + let Some(content) = workspace + .read_anchor_document() + .map_err(|error| format!("Failed to read anchors file: {error}"))? + else { + return Ok(Vec::new()); }; - Ok(content .split("\n---\n") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) .collect()) } -/// Write anchors back to the file, joined by "\n---\n". -fn write_anchors(app: &App, anchors: &[String]) -> Result<(), String> { - let path = anchors_path(app); - - let content = anchors.join("\n---\n"); - crate::fs_confined::write(&app.workspace, &path, content.as_bytes()) - .map_err(|e| format!("Failed to write anchors file: {e}")) -} - -fn add_anchor(app: &mut App, text: &str) -> CommandResult { - let path = anchors_path(app); - - let mut file = match crate::fs_confined::open_append(&app.workspace, &path) { - Ok(f) => f, - Err(e) => { - return CommandResult::error(format!("Failed to open anchors file: {e}")); - } - }; - - // Write separator and anchor content. - if let Err(e) = writeln!(file, "\n---\n{text}") { - return CommandResult::error(format!("Failed to write anchor: {e}")); - } - - CommandResult::message(format!("Anchor pinned.\nStored in: {}", path.display())) -} - -fn list_anchors(app: &App) -> CommandResult { - let anchors = match read_anchors(app) { +fn list_anchors(workspace: &dyn CommandWorkspaceContext) -> CommandResult { + let anchors = match read_anchors(workspace) { Ok(anchors) => anchors, - Err(e) => return CommandResult::error(e), + Err(error) => return CommandResult::error(error), }; - if anchors.is_empty() { return CommandResult::message( "No anchors set. Use /anchor to pin a fact that survives compaction.", ); } - let mut output = format!("Pinned anchors ({} total):\n", anchors.len()); - for (i, anchor) in anchors.iter().enumerate() { - output.push_str(&format!("\n {}. {}", i + 1, anchor)); + for (index, anchor) in anchors.iter().enumerate() { + output.push_str(&format!("\n {}. {}", index + 1, anchor)); } output.push_str("\n\nUse /anchor remove to remove an anchor."); - CommandResult::message(output) } -fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult { +fn remove_anchor(workspace: &mut dyn CommandWorkspaceContext, index_str: &str) -> CommandResult { let index: usize = match index_str.parse() { - Ok(n) if n >= 1 => n, + Ok(index) if index >= 1 => index, _ => { return CommandResult::error( "Invalid index. Use /anchor list to see anchor numbers, then /anchor remove .", ); } }; - - let mut anchors = match read_anchors(app) { + let mut anchors = match read_anchors(workspace) { Ok(anchors) => anchors, - Err(e) => return CommandResult::error(e), + Err(error) => return CommandResult::error(error), }; - if index > anchors.len() { return CommandResult::error(format!( "Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.", anchors.len() )); } - let removed = anchors.remove(index - 1); - if let Err(e) = write_anchors(app, &anchors) { - return CommandResult::error(e); + if let Err(error) = workspace.replace_anchor_document(anchors.join("\n---\n").as_bytes()) { + return CommandResult::error(format!("Failed to write anchors file: {error}")); } - CommandResult::message(format!("Removed anchor #{index}: {removed}")) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use tempfile::TempDir; - - fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { - let options = TuiOptions { - skills_dir: tmpdir.path().join("skills"), - memory_path: tmpdir.path().join("memory.md"), - notes_path: tmpdir.path().join("notes.txt"), - mcp_config_path: tmpdir.path().join("mcp.json"), - ..crate::tui::test_support::test_tui_options(tmpdir.path()) - }; - App::new(options, &Config::default()) - } - - #[cfg(unix)] - #[test] - fn confined_anchors_refuse_linked_files_and_directories() { - use std::os::unix::fs::symlink; - - for directory in [".codewhale", ".deepseek"] { - for linked_directory in [false, true] { - let workspace = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&workspace); - let original = "first anchor\n---\nsecond anchor"; - let target = outside.path().join("anchors.md"); - std::fs::write(&target, original).unwrap(); - let parent = workspace.path().join(directory); - if linked_directory { - symlink(outside.path(), &parent).unwrap(); - } else { - std::fs::create_dir_all(&parent).unwrap(); - symlink(&target, parent.join("anchors.md")).unwrap(); - } - for command in ["list", "next anchor", "remove 1"] { - assert!(anchor(&mut app, Some(command)).is_error); - assert_eq!(std::fs::read_to_string(&target).unwrap(), original); - } - // Exercise the truncate path independently of the read refusal. - assert!(write_anchors(&app, &[]).is_err()); - assert_eq!(std::fs::read_to_string(&target).unwrap(), original); - } - } - } - - #[cfg(unix)] - #[test] - fn confined_anchors_refuse_dangling_links() { - for directory in [".codewhale", ".deepseek"] { - let workspace = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&workspace); - let target = outside.path().join("missing.md"); - let parent = workspace.path().join(directory); - std::fs::create_dir_all(&parent).unwrap(); - std::os::unix::fs::symlink(&target, parent.join("anchors.md")).unwrap(); - assert!(anchor(&mut app, Some("list")).is_error); - assert!(anchor(&mut app, Some("next anchor")).is_error); - assert!(write_anchors(&app, &[]).is_err()); - assert!(!target.exists()); - } - } - - #[test] - fn test_anchor_without_content_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, None); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Usage:")); - } - - #[test] - fn test_anchor_with_empty_content_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some(" ")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Usage:")); - } - - #[test] - fn test_anchor_add() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some("API status field is unreliable")); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("Anchor pinned")); - - let path = tmpdir.path().join(".deepseek").join("anchors.md"); - assert!(path.exists()); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("API status field is unreliable")); - } - - #[test] - fn test_anchor_list_empty() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some("list")); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("No anchors set")); - } - - #[test] - fn test_anchor_list_with_items() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - anchor(&mut app, Some("First anchor")); - anchor(&mut app, Some("Second anchor")); - - let result = anchor(&mut app, Some("list")); - let msg = result.message.unwrap(); - assert!(msg.contains("2 total")); - assert!(msg.contains("1. First anchor")); - assert!(msg.contains("2. Second anchor")); - } - - #[test] - fn test_anchor_remove() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - anchor(&mut app, Some("First anchor")); - anchor(&mut app, Some("Second anchor")); - - let result = anchor(&mut app, Some("remove 1")); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("Removed anchor #1")); - - let result = anchor(&mut app, Some("list")); - let msg = result.message.unwrap(); - assert!(msg.contains("1 total")); - assert!(msg.contains("Second anchor")); - assert!(!msg.contains("First anchor")); - } - - #[test] - fn test_anchor_remove_invalid_index() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - anchor(&mut app, Some("Only anchor")); - - let result = anchor(&mut app, Some("remove 5")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("does not exist")); - } - - #[test] - fn test_anchor_remove_non_numeric() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some("remove abc")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Invalid index")); - } -} diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index 8dea87e974..4c41017e1f 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -1,152 +1,16 @@ -//! Core commands: help, clear, exit, model +//! Remaining legacy core commands: clear, exit, model and related interactions. -use std::fmt::Write; -use std::path::PathBuf; - -use crate::config::{ - DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL, ProviderKind, - normalize_custom_model_id, normalize_model_name_for_provider, -}; +use crate::config::{ProviderKind, normalize_custom_model_id, normalize_model_name_for_provider}; #[cfg(test)] use crate::reasoning_preference::ReasoningEffort; -use crate::tui::app::{App, AppAction, AppModeUi}; -use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; -use codewhale_config::AppMode; -use codewhale_localization::{Locale, MessageId, tr}; +use crate::tui::app::{App, AppAction}; +use crate::tui::views::{ModalKind, SubAgentsView, subagent_view_agents}; +#[cfg(test)] +use codewhale_localization::Locale; +use codewhale_localization::{MessageId, tr}; use super::CommandResult; -/// Show help information -pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { - if let Some(topic) = topic { - let user_commands = - crate::commands::user_registry::with_registry_for_app(app, Clone::clone); - if let Some(command) = user_commands.get(topic) { - return CommandResult::message(user_command_help(app.ui_locale, command)); - } - - // Show help for specific command - if let Some(cmd) = crate::commands::get_command_info(topic) { - let mut help = format!( - "{}\n\n {}\n\n {} {}", - cmd.name, - cmd.description_for(app.ui_locale), - tr(app.ui_locale, MessageId::HelpUsageLabel), - cmd.usage - ); - let visible_aliases = cmd - .aliases - .iter() - .filter(|alias| user_commands.get(alias).is_none()) - .copied() - .collect::>(); - if !visible_aliases.is_empty() { - let _ = write!( - help, - "\n {} {}", - tr(app.ui_locale, MessageId::HelpAliasesLabel), - visible_aliases.join(", ") - ); - } - if cmd.name == "config" { - help.push_str( - "\n\n Provider context window: set `context_window = 262144` under the active `[providers.]` table to cap a 1M model to 256K. Use `/config context_window` to inspect the configured and effective values.", - ); - help.push('\n'); - help.push_str(&tr(app.ui_locale, MessageId::ConfigHelpDiscoverable)); - } - return CommandResult::message(help); - } - - // Skills are user-invocable but were never a `/help` topic (#3912): - // they execute and autocomplete, yet the surface that teaches the - // product claimed they did not exist. - if let Some(help) = skill_help(app, topic) { - return CommandResult::message(help); - } - - return CommandResult::error( - tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic), - ); - } - - // Show help overlay - if app.view_stack.top_kind() != Some(ModalKind::Help) { - let help = HelpView::new_for_app(app, false).with_groups_expanded(app.help_expand_groups); - app.view_stack.push(help); - } - CommandResult::ok() -} - -/// `/help ` for a discovered skill (#3912). -/// -/// Matches the cached skill list case-insensitively, accepting the bare name -/// as well as either invocation shape the dispatcher supports, so `/help -/// $review` and `/help /skill review` resolve the same as `/help review`. -fn skill_help(app: &App, topic: &str) -> Option { - let needle = topic - .trim() - .trim_start_matches('$') - .trim_start_matches('/') - .trim_start_matches("skill ") - .trim(); - if needle.is_empty() { - return None; - } - - let (name, description) = app - .cached_skills - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case(needle))?; - - let mut help = format!("${name}"); - if !description.trim().is_empty() { - let _ = write!(help, "\n\n {}", description.trim()); - } - // Both shapes are advertised because both dispatch (`commands::execute`). - let _ = write!( - help, - "\n\n {} ${name}\n {} /skill {name}", - tr(app.ui_locale, MessageId::HelpUsageLabel), - tr(app.ui_locale, MessageId::HelpUsageLabel), - ); - Some(help) -} - -fn user_command_help( - locale: Locale, - command: &crate::commands::user_registry::UserCommandMetadata, -) -> String { - let mut help = command.name.clone(); - if let Some(description) = command - .description - .as_deref() - .filter(|description| !description.trim().is_empty()) - { - let _ = write!(help, "\n\n {description}"); - } - - let usage = command - .display_usage() - .map(str::to_owned) - .unwrap_or_else(|| format!("/{}", command.name)); - let _ = write!( - help, - "\n\n {} {}", - tr(locale, MessageId::HelpUsageLabel), - usage - ); - if !command.aliases.is_empty() { - let _ = write!( - help, - "\n {} {}", - tr(locale, MessageId::HelpAliasesLabel), - command.aliases.join(", ") - ); - } - help -} - /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { if app.session_transition_blocked() { @@ -476,296 +340,6 @@ pub fn profile_switch(app: &mut App, arg: Option<&str>) -> CommandResult { ) } -pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { - let locale = app.ui_locale; - let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { - return CommandResult::message( - tr(locale, MessageId::WorkspaceCurrent) - .replace("{path}", &app.workspace.display().to_string()), - ); - }; - - let expanded = match expand_workspace_path(locale, raw_path) { - Ok(path) => path, - Err(message) => return CommandResult::error(message), - }; - let candidate = if expanded.is_absolute() { - expanded - } else { - app.workspace.join(expanded) - }; - - if !candidate.exists() { - return CommandResult::error( - tr(locale, MessageId::WorkspaceNotFound) - .replace("{path}", &candidate.display().to_string()), - ); - } - if !candidate.is_dir() { - return CommandResult::error( - tr(locale, MessageId::WorkspaceNotDirectory) - .replace("{path}", &candidate.display().to_string()), - ); - } - - let workspace = candidate.canonicalize().unwrap_or(candidate); - CommandResult::with_message_and_action( - tr(locale, MessageId::WorkspaceSwitching) - .replace("{path}", &workspace.display().to_string()), - AppAction::SwitchWorkspace { workspace }, - ) -} - -fn expand_workspace_path(locale: Locale, path: &str) -> Result { - let unresolved = || tr(locale, MessageId::WorkspaceHomeUnresolved).into_owned(); - if path == "~" { - return crate::config::effective_home_dir().ok_or_else(unresolved); - } - if let Some(rest) = path.strip_prefix("~/") { - let home = crate::config::effective_home_dir().ok_or_else(unresolved)?; - return Ok(home.join(rest)); - } - Ok(PathBuf::from(path)) -} - -fn public_site_locale_segment(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans | Locale::ZhHant => "zh", - Locale::Ja => "ja", - Locale::Vi => "vi", - Locale::Ko => "ko", - Locale::Es419 => "es", - Locale::PtBr => "pt-BR", - Locale::Ru => "ru", - Locale::Uk => "uk", - // Not shipped on the website yet — English pages are the fallback. - Locale::En | Locale::Ca | Locale::De | Locale::Fr | Locale::Id | Locale::Hi => "en", - } -} - -/// Show Codewhale documentation, community, managed-app, and provider links. -pub fn codewhale_links(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - let active_provider = app.api_provider.as_str(); - let site_locale = public_site_locale_segment(locale); - let mut message = format!( - "{}\n─────────────────────────────\n", - tr(locale, MessageId::LinksProjectTitle) - ); - - let _ = writeln!( - message, - "{} `https://codewhale.net/{site_locale}/docs`", - tr(locale, MessageId::LinksDocumentation) - ); - let _ = writeln!( - message, - "{} `https://codewhale.net/{site_locale}/community`", - tr(locale, MessageId::LinksCommunity) - ); - let _ = writeln!( - message, - "{} `https://github.com/codewhale-hq/CodeWhale`", - tr(locale, MessageId::LinksGitHub) - ); - let _ = writeln!( - message, - "{} `https://app.codewhale.net`", - tr(locale, MessageId::LinksManagedApp) - ); - let _ = writeln!(message, "{}", tr(locale, MessageId::LinksManagedAppNote)); - - let _ = write!( - message, - "\n{}\n─────────────────────────────\n", - tr(locale, MessageId::LinksTitle) - ); - - for provider in codewhale_config::provider::providers_sorted_for_display() { - let links = provider.credential_help(); - let active_marker = if provider.id() == active_provider { - " <- current" - } else { - "" - }; - let _ = writeln!( - message, - "\n{} ({}){}", - provider.display_name(), - provider.id(), - active_marker - ); - if let Some(key_url) = links.credential_url { - let _ = writeln!( - message, - "{} `{}`", - tr(locale, MessageId::LinksDashboard), - key_url - ); - } else { - let _ = writeln!( - message, - "{} {}", - tr(locale, MessageId::LinksDashboard), - links.guidance - ); - } - if let Some(docs_url) = links.docs_url { - let _ = writeln!( - message, - "{} `{}`", - tr(locale, MessageId::LinksDocs), - docs_url - ); - } - if provider.kind() == codewhale_config::ProviderKind::Moonshot { - let _ = writeln!( - message, - "{}", - tr(locale, MessageId::LinksKimiCodeRouteNote) - .replace("{route}", DEFAULT_KIMI_CODE_BASE_URL) - .replace("{console}", KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL) - ); - } - let env_vars = provider.env_vars(); - if env_vars.is_empty() { - let _ = writeln!(message, "Env: none"); - } else { - let _ = writeln!(message, "Env: {}", env_vars.join(", ")); - } - } - - let _ = writeln!(message, "\n{}", tr(locale, MessageId::LinksTip)); - CommandResult::message(message) -} - -/// Show home dashboard with stats and quick actions -pub fn home_dashboard(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - let mut stats = String::new(); - - // Basic info - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeDashboardTitle)); - let _ = writeln!(stats, "============================================"); - - // Model & mode - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeModel), - app.model - ); - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeMode), - app.mode.display_name_localized(locale) - ); - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeWorkspace), - app.workspace.display() - ); - - // Session stats - let history_count = app.history.len(); - let total_tokens = app.session.displayed_total_conversation_tokens(); - let queued_messages = app.queued_messages.len(); - let _ = writeln!( - stats, - "{} {} messages", - tr(locale, MessageId::HomeHistory), - history_count - ); - let _ = writeln!( - stats, - "{} {} (session)", - tr(locale, MessageId::HomeTokens), - total_tokens - ); - if queued_messages > 0 { - let _ = writeln!( - stats, - "{} {} messages", - tr(locale, MessageId::HomeQueued), - queued_messages - ); - } - - // Fleet role workers - let subagent_count = app.subagent_cache.len(); - if subagent_count > 0 { - let _ = writeln!( - stats, - "{} {} active", - tr(locale, MessageId::HomeSubagents), - subagent_count - ); - } - - // Active skill - if let Some(skill) = &app.active_skill { - let _ = writeln!( - stats, - "{} {} (active)", - tr(locale, MessageId::HomeSkill), - skill - ); - } - - // Quick actions section - let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeQuickActions)); - let _ = writeln!(stats, "--------------------------------------------"); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickWorkspace)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickRestore)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTokens)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickLinks)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSkills)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickConfig)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSettings)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickModel)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSubagents)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTaskList)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickHelp)); - - // Mode-specific tips - let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeModeTips)); - let _ = writeln!(stats, "--------------------------------------------"); - match app.mode { - AppMode::Agent => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeReviewTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeYoloTip)); - } - AppMode::Operate => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeOperateModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeOperateModeFleetTip)); - } - AppMode::Plan => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip)); - } - } - - CommandResult::message(stats) -} - -/// Toggle output translation to the current system language on/off. -/// -/// When enabled, the model is instructed to respond in the current locale and an -/// interception layer translates any remaining English output before it -/// reaches the user. -pub fn translate(app: &mut App) -> CommandResult { - app.translation_enabled = !app.translation_enabled; - let locale = app.ui_locale; - if app.translation_enabled { - CommandResult::message(tr(locale, MessageId::CmdTranslateOn)) - } else { - CommandResult::message(tr(locale, MessageId::CmdTranslateOff)) - } -} - #[cfg(test)] mod tests { use super::*; @@ -773,56 +347,12 @@ mod tests { use crate::config::Config; use crate::tui::app::{App, TuiOptions, TurnCacheRecord}; use crate::tui::history::HistoryCell; - use codewhale_config::AppMode; use codewhale_models::Message; use codewhale_models::Role; use std::ffi::OsString; use std::path::PathBuf; use std::time::Instant; - use tempfile::{TempDir, tempdir}; - - #[test] - fn help_topic_resolves_a_discovered_skill_and_both_shapes() { - // #3912: `/help ` said "Unknown command" for a skill that - // executes and autocompletes. - let mut app = create_test_app(); - app.cached_skills = vec![( - "codereview".to_string(), - "Review a diff for defects".to_string(), - )]; - - for topic in [ - "codereview", - "CodeReview", - "$codereview", - "/skill codereview", - ] { - let result = help(&mut app, Some(topic)); - let message = result - .message - .as_deref() - .unwrap_or_else(|| panic!("{topic} should resolve to skill help")); - assert!(message.contains("Review a diff for defects"), "{message}"); - assert!(message.contains("$codereview"), "{message}"); - assert!(message.contains("/skill codereview"), "{message}"); - } - } - - #[test] - fn help_topic_still_errors_for_an_unknown_name() { - let mut app = create_test_app(); - app.cached_skills = vec![("codereview".to_string(), "Review a diff".to_string())]; - let result = help(&mut app, Some("definitely-not-a-thing")); - assert!(result.is_error, "unknown topics must still be an error"); - assert!( - !result - .message - .as_deref() - .unwrap_or_default() - .contains("Review a diff"), - "the skill fallback must not match an unrelated topic" - ); - } + use tempfile::TempDir; struct SettingsPathGuard { _tmp: TempDir, @@ -922,83 +452,6 @@ mod tests { } } - #[test] - fn test_help_unknown_command() { - let mut app = create_test_app(); - let result = help(&mut app, Some("nonexistent")); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Unknown command")); - assert!(result.action.is_none()); - } - - #[test] - fn test_help_known_command() { - let mut app = create_test_app(); - let result = help(&mut app, Some("clear")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("clear")); - assert!(msg.contains("Clear conversation history")); - assert!(msg.contains("Usage: /clear")); - } - - #[test] - fn test_help_config_topic_uses_interactive_editor_text() { - let mut app = create_test_app(); - let result = help(&mut app, Some("config")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("config")); - assert!(msg.contains("Inspect and change settings")); - assert!(msg.contains("Usage: /config")); - assert!(msg.contains("context_window = 262144")); - assert!(msg.contains("/config context_window")); - assert!(msg.contains("/config search.provider")); - assert!(msg.contains("/config prompt_suggestion")); - assert!(msg.contains("/config notifications")); - } - - #[test] - fn test_help_links_topic_shows_aliases() { - let mut app = create_test_app(); - let result = help(&mut app, Some("links")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("links")); - assert!(msg.contains("Show Codewhale, community, and provider links")); - assert!(msg.contains("Usage: /links")); - assert!(msg.contains("Aliases: dashboard, api")); - } - - #[test] - fn test_help_memory_topic_shows_usage_and_description() { - let mut app = create_test_app(); - let result = help(&mut app, Some("memory")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("memory")); - assert!(msg.contains("persistent structured user memory")); - assert!(msg.contains( - "Usage: /memory [status|path|search|get|remember|import|export|reindex|clear|help]" - )); - } - - #[test] - fn test_help_pushes_overlay() { - let mut app = create_test_app(); - assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help)); - let result = help(&mut app, None); - assert_eq!(result.message, None); - assert_eq!(result.action, None); - assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); - } - - #[test] - fn test_help_does_not_duplicate_overlay() { - let mut app = create_test_app(); - help(&mut app, None); - let initial_kind = app.view_stack.top_kind(); - help(&mut app, None); - assert_eq!(app.view_stack.top_kind(), initial_kind); - } - #[test] fn test_clear_resets_all_state() { let mut app = create_test_app(); @@ -1179,62 +632,6 @@ mod tests { assert!(matches!(result.action, Some(AppAction::Quit))); } - #[test] - fn workspace_without_arg_shows_current_workspace() { - let mut app = create_test_app(); - let result = workspace_switch(&mut app, None); - let msg = result.message.expect("workspace should be shown"); - assert!(msg.contains("Current workspace:")); - assert!(msg.contains("/tmp/test-workspace")); - assert!(result.action.is_none()); - } - - #[test] - fn workspace_existing_absolute_dir_returns_switch_action() { - let mut app = create_test_app(); - let dir = tempdir().expect("temp dir"); - let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() - )); - } - - #[test] - fn workspace_relative_dir_resolves_from_current_workspace() { - let root = tempdir().expect("temp dir"); - let child = root.path().join("child"); - std::fs::create_dir(&child).expect("child dir"); - let mut app = create_test_app(); - app.workspace = root.path().to_path_buf(); - - let result = workspace_switch(&mut app, Some("child")); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() - )); - } - - #[test] - fn workspace_rejects_missing_path() { - let mut app = create_test_app(); - let result = workspace_switch(&mut app, Some("definitely-missing")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("does not exist")); - } - - #[test] - fn workspace_rejects_file_path() { - let root = tempdir().expect("temp dir"); - let file = root.path().join("file.txt"); - std::fs::write(&file, "not a directory").expect("test file"); - let mut app = create_test_app(); - - let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); - assert!(result.is_error); - assert!(result.message.unwrap().contains("not a directory")); - } - #[test] fn test_model_change_updates_state() { let _settings = SettingsPathGuard::new(); @@ -1704,73 +1101,6 @@ mod tests { ); } - #[test] - fn test_codewhale_links() { - let mut app = create_test_app(); - let result = codewhale_links(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Codewhale & community")); - assert!(msg.contains("https://codewhale.net/en/docs")); - assert!(msg.contains("https://codewhale.net/en/community")); - assert!(msg.contains("https://github.com/codewhale-hq/CodeWhale")); - assert!(msg.contains("https://app.codewhale.net")); - assert!(msg.contains("separate sign-in")); - assert!(msg.contains("not connected to the current local session")); - assert!(msg.contains("Provider Links")); - assert!(msg.contains("DeepSeek (deepseek) <- current")); - assert!(msg.contains("https://platform.deepseek.com/api_keys")); - assert!(msg.contains("Xiaomi MiMo (xiaomi-mimo)")); - assert!(msg.contains("https://platform.xiaomimimo.com/token-plan")); - assert!(msg.contains("Moonshot/Kimi (moonshot)")); - assert!(msg.contains("https://platform.kimi.ai/console/api-keys")); - assert!(msg.contains("https://platform.kimi.ai/docs/overview")); - assert!(msg.contains("https://api.kimi.com/coding/v1")); - assert!(msg.contains("https://www.kimi.com/code/console")); - assert!(msg.contains("never imports Kimi CLI credentials")); - assert!(msg.contains("https://console.openmodel.ai/")); - assert!(msg.contains("https://docs.openmodel.ai/en/docs/getting-started/authentication")); - assert!(msg.contains("https://console.sakana.ai/api-keys")); - assert!(msg.contains("https://console.sakana.ai/get-started")); - assert!(msg.contains("Baidu Qianfan (qianfan)")); - assert!(msg.contains("https://cloud.baidu.com/doc/qianfan/index.html")); - assert!(msg.contains("Local Ollama is keyless by default")); - assert!(msg.contains("codewhale auth chatgpt")); - assert!( - !msg.contains("codex login"), - "official sign-in must not direct users to external CLI credentials" - ); - assert!(msg.contains("no canonical vendor credential page exists")); - assert!(msg.contains("OPENAI_API_KEY")); - assert!(msg.contains("XIAOMI_MIMO_TOKEN_PLAN_API_KEY")); - assert!(!msg.contains("https://codewhale.dev/docs/providers")); - assert!(result.action.is_none()); - } - - #[test] - fn provider_links_emit_urls_as_inline_code_for_narrow_transcripts() { - let mut app = create_test_app(); - let result = codewhale_links(&mut app); - let msg = result.message.expect("links should return a message"); - - assert!(msg.contains("`https://platform.openai.com/api-keys`")); - assert!( - msg.contains( - "`https://platform.minimax.io/user-center/basic-information/interface-key`" - ) - ); - - for line in msg.lines().filter(|line| line.contains("http")) { - let Some(url_start) = line.find("http") else { - continue; - }; - assert!( - line[..url_start].ends_with('`') && line[url_start..].contains('`'), - "provider URL should be inline-code wrapped so narrow TUI renders do not emit oversized OSC8 link payloads: {line}" - ); - } - } - #[test] fn provider_link_metadata_marks_custom_routes_as_configuration_owned() { let links = @@ -1784,105 +1114,4 @@ mod tests { assert_eq!(links.docs_url, None); assert_eq!(links.credential_url, None); } - - #[test] - fn project_links_follow_the_available_public_site_locale() { - let mut app = create_test_app(); - app.ui_locale = Locale::ZhHans; - - let msg = codewhale_links(&mut app) - .message - .expect("links should return a message"); - - assert!(msg.contains("`https://codewhale.net/zh/docs`")); - assert!(msg.contains("`https://codewhale.net/zh/community`")); - assert!(msg.contains("`https://app.codewhale.net`")); - } - - #[test] - fn test_home_dashboard_includes_all_sections() { - let mut app = create_test_app(); - app.session.total_conversation_tokens = 1234; - let result = home_dashboard(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("codewhale")); - assert!(!msg.contains("codewhale Home Dashboard")); - assert!(msg.contains("Model:")); - assert!(msg.contains("Mode:")); - assert!(msg.contains("Workspace:")); - assert!(msg.contains("History:")); - assert!(msg.contains("Tokens:")); - assert!(msg.contains("Quick Actions")); - assert!(msg.contains("Mode Tips")); - assert!(result.action.is_none()); - } - - #[test] - fn test_home_dashboard_shows_queued_when_present() { - let mut app = create_test_app(); - app.queued_messages - .push_back(crate::tui::app::QueuedMessage::new( - "test".to_string(), - None, - )); - let result = home_dashboard(&mut app); - let msg = result.message.unwrap(); - assert!(msg.contains("Queued:")); - } - - #[test] - fn test_home_dashboard_mode_tips_for_each_mode() { - let modes = [AppMode::Agent, AppMode::Plan, AppMode::Operate]; - for mode in modes { - let mut app = create_test_app(); - app.mode = mode; - let result = home_dashboard(&mut app); - let msg = result.message.unwrap(); - assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); - } - } - - #[test] - fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { - let mut app = create_test_app(); - let result = home_dashboard(&mut app); - let msg = result - .message - .expect("home dashboard should return message"); - assert!(msg.contains("/workspace - Switch folders or worktrees")); - assert!(msg.contains("/restore - Roll files back to a turn snapshot")); - assert!(msg.contains("/tokens - Show session spend and context")); - assert!(msg.contains("/links - Codewhale, community & provider links")); - assert!(msg.contains("/config - Inspect and change settings")); - assert!( - !msg.lines() - .any(|line| line.trim_start().starts_with("/set ")) - ); - assert!(!msg.contains("/codewhale")); - } - - #[test] - fn home_dashboard_localizes_in_zh_hans() { - use codewhale_localization::Locale; - let mut app = create_test_app(); - app.ui_locale = Locale::ZhHans; - let result = home_dashboard(&mut app); - let msg = result - .message - .expect("home dashboard should return message"); - assert!( - msg.contains("codewhale"), - "missing canonical product title:\n{msg}" - ); - assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}"); - assert!( - msg.contains("快捷操作"), - "missing zh-Hans quick actions:\n{msg}" - ); - assert!( - msg.contains("模式提示"), - "missing zh-Hans mode tips:\n{msg}" - ); - } } diff --git a/crates/tui/src/commands/groups/core/help.rs b/crates/tui/src/commands/groups/core/help.rs index e3c1e979a1..8d35d7f6df 100644 --- a/crates/tui/src/commands/groups/core/help.rs +++ b/crates/tui/src/commands/groups/core/help.rs @@ -1,26 +1,164 @@ //! `/help` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::facets::CommandPresentationContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::{ + CommandNavigationContext, NavigationSkill, NavigationUserHelp, +}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; +use std::fmt::Write; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "help", aliases: &["?", "bangzhu", "帮助"], usage: "/help [command]", - description_id: MessageId::CmdHelpDescription, + description_key: "cmd_help_description", }; -pub(in crate::commands) struct HelpCmd; +pub struct HelpCmd; -impl RegisterCommand for HelpCmd { +impl RegisterCommand for HelpCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: help, + } + } +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::core::help(app, arg) +fn help(contexts: CommandContexts<'_>, topic: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + render_help(navigation, presentation, topic).unwrap_or_else(CommandResult::error) +} + +fn render_help( + navigation: &mut dyn CommandNavigationContext, + presentation: &dyn CommandPresentationContext, + topic: Option<&str>, +) -> Result { + if let Some(topic) = topic { + let facts = navigation.help_topic(topic); + if let Some(command) = facts.user { + return Ok(CommandResult::message(user_command_help( + presentation, + &command, + )?)); + } + if let Some(command) = facts.builtin { + let mut help = format!( + "{}\n\n {}\n\n {} {}", + command.name, + command.description, + presentation.translate("help_usage_label", &[])?, + command.usage, + ); + let visible_aliases = command + .aliases + .iter() + .filter(|alias| !facts.shadowed_names.contains(alias)) + .map(String::as_str) + .collect::>(); + if !visible_aliases.is_empty() { + let _ = write!( + help, + "\n {} {}", + presentation.translate("help_aliases_label", &[])?, + visible_aliases.join(", ") + ); + } + if command.name == "config" { + help.push_str("\n\n Provider context window: set `context_window = 262144` under the active `[providers.]` table to cap a 1M model to 256K. Use `/config context_window` to inspect the configured and effective values."); + help.push('\n'); + help.push_str(&presentation.translate("config_help_discoverable", &[])?); + } + return Ok(CommandResult::message(help)); + } + if let Some(help) = skill_help(presentation, &navigation.cached_skills(), topic)? { + return Ok(CommandResult::message(help)); + } + return Ok(CommandResult::error( + presentation.translate("help_unknown_command", &[("topic", topic)])?, + )); + } + if !navigation.help_is_open() { + navigation.open_help(); + } + Ok(CommandResult::ok()) +} + +fn skill_help( + presentation: &dyn CommandPresentationContext, + skills: &[NavigationSkill], + topic: &str, +) -> Result, String> { + let needle = topic + .trim() + .trim_start_matches('$') + .trim_start_matches('/') + .trim_start_matches("skill ") + .trim(); + if needle.is_empty() { + return Ok(None); + } + let Some(skill) = skills + .iter() + .find(|skill| skill.name.eq_ignore_ascii_case(needle)) + else { + return Ok(None); + }; + let name = &skill.name; + let mut help = format!("${name}"); + if !skill.description.trim().is_empty() { + let _ = write!(help, "\n\n {}", skill.description.trim()); + } + let _ = write!( + help, + "\n\n {} ${name}\n {} /skill {name}", + presentation.translate("help_usage_label", &[])?, + presentation.translate("help_usage_label", &[])?, + ); + Ok(Some(help)) +} + +fn user_command_help( + presentation: &dyn CommandPresentationContext, + command: &NavigationUserHelp, +) -> Result { + let mut help = command.name.clone(); + if let Some(description) = command + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + { + let _ = write!(help, "\n\n {description}"); + } + let usage = command + .usage + .clone() + .unwrap_or_else(|| format!("/{}", command.name)); + let _ = write!( + help, + "\n\n {} {}", + presentation.translate("help_usage_label", &[])?, + usage + ); + if !command.aliases.is_empty() { + let _ = write!( + help, + "\n {} {}", + presentation.translate("help_aliases_label", &[])?, + command.aliases.join(", ") + ); } + Ok(help) } diff --git a/crates/tui/src/commands/groups/core/home.rs b/crates/tui/src/commands/groups/core/home.rs index 2b69ab940e..e0e0f4fd54 100644 --- a/crates/tui/src/commands/groups/core/home.rs +++ b/crates/tui/src/commands/groups/core/home.rs @@ -1,143 +1,160 @@ //! `/home` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; +use codewhale_command_contract::facets::CommandPresentationContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::NavigationOverview; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; +use codewhale_command_contract::types::CommandMode; +use std::fmt::Write; -use super::CommandResult; - -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "home", aliases: &["zhuye", "shouye"], usage: "/home", - description_id: MessageId::CmdHomeDescription, + description_key: "cmd_home_description", }; -pub(in crate::commands) struct HomeCmd; +pub struct HomeCmd; +pub struct OverviewCmd; -impl RegisterCommand for HomeCmd { +impl RegisterCommand for HomeCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - if app.session_transition_blocked() { - return CommandResult::error(app.tr(MessageId::HomeNavigationBusy).into_owned()); + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: home, } - app.launch.workspace = app.workspace.clone(); - app.launch.restore_card(); - app.launch.visible = true; - app.launch.return_to_session = true; - app.needs_redraw = true; - CommandResult::ok() } } -/// Preserve the old read-only statistics view under its descriptive aliases. -pub(in crate::commands) struct OverviewCmd; - -impl RegisterCommand for OverviewCmd { +impl RegisterCommand for OverviewCmd { fn info() -> &'static CommandInfo { &CommandInfo { name: "overview", aliases: &["stats"], usage: "/overview", - description_id: MessageId::CmdOverviewDescription, + description_key: "cmd_overview_description", + } + } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: overview, } } +} - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::core::home_dashboard(app) +fn home(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + if navigation.home_is_blocked() { + return match presentation.translate("home_navigation_busy", &[]) { + Ok(message) => CommandResult::error(message), + Err(error) => CommandResult::error(error), + }; } + navigation.restore_home(); + CommandResult::ok() } -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +fn overview(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + render_overview(presentation, navigation.overview()) + .map(CommandResult::message) + .unwrap_or_else(CommandResult::error) +} - #[test] - fn home_preserves_session_draft_and_reveal_and_escape_returns() { - let mut app = crate::tui::test_support::test_app_with_options( - crate::tui::test_support::test_tui_options("."), - ); - app.launch.dismiss(); - app.current_session_id = Some("retained-session".into()); - app.api_messages_mut().push(codewhale_models::Message { - role: codewhale_models::Role::User, - content: vec![codewhale_models::ContentBlock::Text { - text: "Retain this conversation".into(), - cache_control: None, - }], - }); - app.add_message(crate::tui::history::HistoryCell::System { - content: "Retain this transcript".into(), - }); - app.input = "unsent draft".into(); - app.cursor_position = app.input.chars().count(); - app.launch.mark_reveal_started_at = Some(std::time::Instant::now()); - let reveal = app.launch.mark_reveal_started_at; - let messages = app.api_messages.clone(); - let history_len = app.history.len(); - let result = crate::commands::execute("/home", &mut app); - assert!(!result.is_error, "{:?}", result.message); - assert!( - result.action.is_none(), - "navigation must not sync/reset the Engine" - ); - assert!(crate::tui::widgets::should_render_empty_state(&app)); - assert!(app.launch.return_to_session); - assert!(!crate::tui::underwater::launch_motion_active( - &app, false, true - )); - crate::tui::underwater::refresh_launch_row_hitboxes( - &mut app, - ratatui::layout::Rect::new(0, 0, 80, 20), +fn render_overview( + presentation: &dyn CommandPresentationContext, + facts: NavigationOverview, +) -> Result { + let label = |key| presentation.translate(key, &[]); + let mut stats = String::new(); + let _ = writeln!(stats, "{}", label("home_dashboard_title")?); + let _ = writeln!(stats, "============================================"); + let _ = writeln!(stats, "{} {}", label("home_model")?, facts.model); + let _ = writeln!(stats, "{} {}", label("home_mode")?, facts.mode_label); + let _ = writeln!( + stats, + "{} {}", + label("home_workspace")?, + facts.workspace.display() + ); + let _ = writeln!( + stats, + "{} {} messages", + label("home_history")?, + facts.history_count + ); + let _ = writeln!( + stats, + "{} {} (session)", + label("home_tokens")?, + facts.total_tokens + ); + if facts.queued_count > 0 { + let _ = writeln!( + stats, + "{} {} messages", + label("home_queued")?, + facts.queued_count ); - let rows = crate::tui::underwater::launch_rows_for_app(&app); - assert_eq!( - crate::tui::underwater::launch_row_click_action(&rows[0].id), - crate::tui::underwater::LaunchAction::ReturnToSession, - ); - app.launch.menu_selected = Some(0); - assert_eq!( - crate::tui::underwater::handle_launch_composer_key( - &mut app, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ), - crate::tui::underwater::LaunchComposerKey::MenuRun, - ); - assert_eq!(app.input, "unsent draft"); - crate::tui::underwater::handle_launch_composer_key( - &mut app, - KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), - ); - assert!(!app.launch.visible); - assert!(!app.launch.return_to_session); - assert_eq!(app.current_session_id.as_deref(), Some("retained-session")); - assert_eq!(app.api_messages, messages); - assert_eq!(app.history.len(), history_len); - assert_eq!(app.input, "unsent draft"); - assert_eq!(app.launch.mark_reveal_started_at, reveal); - assert!(!crate::tui::widgets::should_render_empty_state(&app)); - - for command in ["/overview", "/stats"] { - let result = crate::commands::execute(command, &mut app); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("Quick Actions")); - assert!(!app.launch.visible); - } } - - #[test] - fn home_refuses_to_cover_active_work() { - let mut app = crate::tui::test_support::test_app_with_options( - crate::tui::test_support::test_tui_options("."), + if facts.subagent_count > 0 { + let _ = writeln!( + stats, + "{} {} active", + label("home_subagents")?, + facts.subagent_count ); - app.launch.dismiss(); - app.is_loading = true; - let result = HomeCmd::execute(&mut app, None); - assert!(result.is_error); - assert!(!app.launch.visible); } + if let Some(skill) = &facts.active_skill { + let _ = writeln!(stats, "{} {} (active)", label("home_skill")?, skill); + } + let _ = writeln!(stats, "\n{}", label("home_quick_actions")?); + let _ = writeln!(stats, "--------------------------------------------"); + for key in [ + "home_quick_workspace", + "home_quick_restore", + "home_quick_tokens", + "home_quick_links", + "home_quick_skills", + "home_quick_config", + "home_quick_settings", + "home_quick_model", + "home_quick_subagents", + "home_quick_task_list", + "home_quick_help", + ] { + let _ = writeln!(stats, "{}", label(key)?); + } + let _ = writeln!(stats, "\n{}", label("home_mode_tips")?); + let _ = writeln!(stats, "--------------------------------------------"); + let tips: &[&str] = match facts.mode { + CommandMode::Agent => &[ + "home_agent_mode_tip", + "home_agent_mode_review_tip", + "home_agent_mode_yolo_tip", + ], + CommandMode::Operate => &["home_operate_mode_tip", "home_operate_mode_fleet_tip"], + CommandMode::Plan => &["home_plan_mode_tip", "home_plan_mode_checklist_tip"], + }; + for key in tips { + let _ = writeln!(stats, "{}", label(key)?); + } + Ok(stats) } diff --git a/crates/tui/src/commands/groups/core/links.rs b/crates/tui/src/commands/groups/core/links.rs index 0367bfbd67..01f9b051a5 100644 --- a/crates/tui/src/commands/groups/core/links.rs +++ b/crates/tui/src/commands/groups/core/links.rs @@ -1,26 +1,139 @@ //! `/links` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; +use codewhale_command_contract::facets::CommandPresentationContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::{NavigationLinks, NavigationProviderKind}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; +use std::fmt::Write; -use super::CommandResult; - -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "links", aliases: &["dashboard", "api", "lianjie"], usage: "/links", - description_id: MessageId::CmdLinksDescription, + description_key: "cmd_links_description", }; -pub(in crate::commands) struct LinksCmd; +pub struct LinksCmd; -impl RegisterCommand for LinksCmd { +impl RegisterCommand for LinksCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: links, + } + } +} + +fn links(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + render_links(presentation, navigation.links()) + .map(CommandResult::message) + .unwrap_or_else(CommandResult::error) +} + +fn public_site_locale_segment(tag: &str) -> &'static str { + match tag { + "zh-Hans" | "zh-Hant" => "zh", + "ja" => "ja", + "vi" => "vi", + "ko" => "ko", + "es-419" => "es", + "pt-BR" => "pt-BR", + "ru" => "ru", + "uk" => "uk", + _ => "en", + } +} - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::core::codewhale_links(app) +fn render_links( + presentation: &dyn CommandPresentationContext, + facts: NavigationLinks, +) -> Result { + let label = |key| presentation.translate(key, &[]); + let site_locale = public_site_locale_segment(&facts.locale_tag); + let mut message = format!( + "{}\n─────────────────────────────\n", + label("links_project_title")? + ); + let _ = writeln!( + message, + "{} `https://codewhale.net/{site_locale}/docs`", + label("links_documentation")? + ); + let _ = writeln!( + message, + "{} `https://codewhale.net/{site_locale}/community`", + label("links_community")? + ); + let _ = writeln!( + message, + "{} `https://github.com/codewhale-hq/CodeWhale`", + label("links_git_hub")? + ); + let _ = writeln!( + message, + "{} `https://app.codewhale.net`", + label("links_managed_app")? + ); + let _ = writeln!(message, "{}", label("links_managed_app_note")?); + let _ = write!( + message, + "\n{}\n─────────────────────────────\n", + label("links_title")? + ); + for provider in facts.providers { + let active_marker = if provider.id == facts.active_provider { + " <- current" + } else { + "" + }; + let _ = writeln!( + message, + "\n{} ({}){}", + provider.name, provider.id, active_marker + ); + if let Some(key_url) = provider.credential_url { + let _ = writeln!(message, "{} `{}`", label("links_dashboard")?, key_url); + } else { + let _ = writeln!( + message, + "{} {}", + label("links_dashboard")?, + provider.guidance + ); + } + if let Some(docs_url) = provider.docs_url { + let _ = writeln!(message, "{} `{}`", label("links_docs")?, docs_url); + } + if provider.kind == NavigationProviderKind::Moonshot { + let _ = writeln!( + message, + "{}", + presentation.translate( + "links_kimi_code_route_note", + &[ + ("route", &facts.kimi_route), + ("console", &facts.kimi_console), + ] + )? + ); + } + if provider.env_names.is_empty() { + let _ = writeln!(message, "Env: none"); + } else { + let _ = writeln!(message, "Env: {}", provider.env_names.join(", ")); + } } + let _ = writeln!(message, "\n{}", label("links_tip")?); + Ok(message) } diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs index 5043198571..6844d5b0a2 100644 --- a/crates/tui/src/commands/groups/core/mod.rs +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -5,7 +5,6 @@ mod acceptance; mod advisor; mod agent; -mod anchor; mod clear; mod constitution; mod copy; @@ -18,12 +17,9 @@ mod effort; mod exit; mod feedback; pub(in crate::commands) mod fleet; -mod help; mod hf; -mod home; mod hooks; mod lane; -mod links; mod model; mod modeldb; mod models; @@ -37,11 +33,9 @@ pub(in crate::commands) mod router; mod setup; mod stash; mod subagents; -mod translate; pub mod util; pub mod voice; mod workflow; -mod workspace; pub(in crate::commands) use self::core::reset_conversation_state; pub(crate) use constitution::open_manager as open_constitution_manager; @@ -54,18 +48,12 @@ pub struct CoreCommands; impl CommandGroup for CoreCommands { fn commands(&self) -> &'static [Box] { cached_command_list!(vec![ - Box::new(FunctionCommand::new( - anchor::AnchorCmd::info(), - anchor::AnchorCmd::execute, - )), + crate::commands::core_navigation_host::registration::<0>(), Box::new(FunctionCommand::new( advisor::AdvisorCmd::info(), advisor::AdvisorCmd::execute, )), - Box::new(FunctionCommand::new( - help::HelpCmd::info(), - help::HelpCmd::execute, - )), + crate::commands::core_navigation_host::registration::<1>(), Box::new(FunctionCommand::new( clear::ClearCmd::info(), clear::ClearCmd::execute, @@ -134,7 +122,7 @@ impl CommandGroup for CoreCommands { workflow::AutoCmd::info(), workflow::AutoCmd::execute, )), - crate::commands::core_navigation_host::registration::<0>(), + crate::commands::core_navigation_host::registration::<2>(), Box::new(FunctionCommand::new( setup::SetupCmd::info(), setup::SetupCmd::execute, @@ -147,29 +135,17 @@ impl CommandGroup for CoreCommands { agent::AgentCmd::info(), agent::AgentCmd::execute, )), - Box::new(FunctionCommand::new( - links::LinksCmd::info(), - links::LinksCmd::execute, - )), - crate::commands::core_navigation_host::registration::<1>(), - crate::commands::core_navigation_host::registration::<2>(), + crate::commands::core_navigation_host::registration::<3>(), + crate::commands::core_navigation_host::registration::<4>(), + crate::commands::core_navigation_host::registration::<5>(), Box::new(FunctionCommand::new( feedback::FeedbackCmd::info(), feedback::FeedbackCmd::execute, )), Box::new(FunctionCommand::new(hf::HfCmd::info(), hf::HfCmd::execute)), - Box::new(FunctionCommand::new( - home::HomeCmd::info(), - home::HomeCmd::execute, - )), - Box::new(FunctionCommand::new( - home::OverviewCmd::info(), - home::OverviewCmd::execute, - )), - Box::new(FunctionCommand::new( - workspace::WorkspaceCmd::info(), - workspace::WorkspaceCmd::execute, - )), + crate::commands::core_navigation_host::registration::<6>(), + crate::commands::core_navigation_host::registration::<7>(), + crate::commands::core_navigation_host::registration::<8>(), Box::new(FunctionCommand::new( profile::ProfileCmd::info(), profile::ProfileCmd::execute, @@ -178,10 +154,7 @@ impl CommandGroup for CoreCommands { rlm::RlmCmd::info(), rlm::RlmCmd::execute, )), - Box::new(FunctionCommand::new( - translate::TranslateCmd::info(), - translate::TranslateCmd::execute, - )), + crate::commands::core_navigation_host::registration::<9>(), Box::new(FunctionCommand::new( voice::VoiceCmd::info(), voice::VoiceCmd::execute, diff --git a/crates/tui/src/commands/groups/core/navigation.rs b/crates/tui/src/commands/groups/core/navigation.rs index 4f083f73a1..a037e9b2d3 100644 --- a/crates/tui/src/commands/groups/core/navigation.rs +++ b/crates/tui/src/commands/groups/core/navigation.rs @@ -1,9 +1,21 @@ +#[path = "anchor.rs"] +pub mod anchor; +#[path = "help.rs"] +pub mod help; +#[path = "home.rs"] +pub mod home; #[path = "hotbar.rs"] pub mod hotbar; +#[path = "links.rs"] +pub mod links; #[path = "transcript.rs"] pub mod transcript; +#[path = "translate.rs"] +pub mod translate; #[path = "turn.rs"] pub mod turn; +#[path = "workspace.rs"] +pub mod workspace; use codewhale_command_contract::handler::CommandHandler; use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; @@ -12,13 +24,26 @@ use codewhale_command_contract::outcome::CoreNavigationCommandResult; pub fn portable_handlers() -> [( &'static CommandInfo, CommandHandler, -); 3] { +); 10] { [ + (anchor::AnchorCmd::info(), anchor::AnchorCmd::handler()), + (help::HelpCmd::info(), help::HelpCmd::handler()), (hotbar::HotbarCmd::info(), hotbar::HotbarCmd::handler()), + (links::LinksCmd::info(), links::LinksCmd::handler()), ( transcript::TranscriptCmd::info(), transcript::TranscriptCmd::handler(), ), (turn::TurnCmd::info(), turn::TurnCmd::handler()), + (home::HomeCmd::info(), home::HomeCmd::handler()), + (home::OverviewCmd::info(), home::OverviewCmd::handler()), + ( + workspace::WorkspaceCmd::info(), + workspace::WorkspaceCmd::handler(), + ), + ( + translate::TranslateCmd::info(), + translate::TranslateCmd::handler(), + ), ] } diff --git a/crates/tui/src/commands/groups/core/translate.rs b/crates/tui/src/commands/groups/core/translate.rs index ea3195519e..78e0af41b2 100644 --- a/crates/tui/src/commands/groups/core/translate.rs +++ b/crates/tui/src/commands/groups/core/translate.rs @@ -1,26 +1,48 @@ //! `/translate` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; -use super::CommandResult; - -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "translate", aliases: &["translation", "transale"], usage: "/translate", - description_id: MessageId::CmdTranslateDescription, + description_key: "cmd_translate_description", }; -pub(in crate::commands) struct TranslateCmd; +pub struct TranslateCmd; -impl RegisterCommand for TranslateCmd { +impl RegisterCommand for TranslateCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::core::translate(app) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::CONFIG.union(CommandCapabilities::PRESENTATION), + handler: translate, + } } } + +fn translate(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(config) = parts.config else { + return CommandResult::error("Command capability unavailable: config"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !config.translation_enabled(); + let key = if enabled { + "cmd_translate_on" + } else { + "cmd_translate_off" + }; + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), + }; + config.set_translation_enabled(enabled); + CommandResult::message(message) +} diff --git a/crates/tui/src/commands/groups/core/workspace.rs b/crates/tui/src/commands/groups/core/workspace.rs index 433178da8d..182ced1859 100644 --- a/crates/tui/src/commands/groups/core/workspace.rs +++ b/crates/tui/src/commands/groups/core/workspace.rs @@ -1,31 +1,108 @@ //! `/workspace` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::facets::{CommandPresentationContext, CommandWorkspaceContext}; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::WorkspaceTarget; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; +use std::path::PathBuf; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "workspace", aliases: &["cwd"], usage: "/workspace [path|worktrees]", - description_id: MessageId::CmdWorkspaceDescription, + description_key: "cmd_workspace_description", }; -pub(in crate::commands) struct WorkspaceCmd; +pub struct WorkspaceCmd; -impl RegisterCommand for WorkspaceCmd { +impl RegisterCommand for WorkspaceCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::WORKSPACE.union(CommandCapabilities::PRESENTATION), + handler: workspace, + } + } +} + +fn workspace(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(workspace) = parts.workspace else { + return CommandResult::error("Command capability unavailable: workspace"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + match arg.map(str::trim).filter(|arg| !arg.is_empty()) { + Some("worktrees" | "worktree" | "wt") => CommandResult::action(Action::OpenWorktreeManager), + other => { + workspace_switch(workspace, presentation, other).unwrap_or_else(CommandResult::error) + } + } +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.map(str::trim).filter(|a| !a.is_empty()) { - Some("worktrees" | "worktree" | "wt") => { - CommandResult::action(AppAction::OpenWorktreeManager) - } - other => super::core::workspace_switch(app, other), +fn workspace_switch( + workspace: &dyn CommandWorkspaceContext, + presentation: &dyn CommandPresentationContext, + arg: Option<&str>, +) -> Result { + let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { + return Ok(CommandResult::message(presentation.translate( + "workspace_current", + &[("path", &workspace.workspace().display().to_string())], + )?)); + }; + let expanded = expand_workspace_path(workspace, presentation, raw_path)?; + let candidate = if expanded.is_absolute() { + expanded + } else { + workspace.workspace().join(expanded) + }; + let target = match workspace.inspect_workspace_target(&candidate) { + WorkspaceTarget::Missing => { + return Ok(CommandResult::error(presentation.translate( + "workspace_not_found", + &[("path", &candidate.display().to_string())], + )?)); } + WorkspaceTarget::NotDirectory => { + return Ok(CommandResult::error(presentation.translate( + "workspace_not_directory", + &[("path", &candidate.display().to_string())], + )?)); + } + WorkspaceTarget::Directory { + canonical_or_candidate, + } => canonical_or_candidate, + }; + let message = presentation.translate( + "workspace_switching", + &[("path", &target.display().to_string())], + )?; + Ok(CommandResult::with_message_and_action( + message, + Action::SwitchWorkspace { workspace: target }, + )) +} + +fn expand_workspace_path( + workspace: &dyn CommandWorkspaceContext, + presentation: &dyn CommandPresentationContext, + path: &str, +) -> Result { + if path == "~" || path.starts_with("~/") { + let Some(home) = workspace.effective_home_dir() else { + return Err(presentation.translate("workspace_home_unresolved", &[])?); + }; + return Ok(match path.strip_prefix("~/") { + Some(rest) => home.join(rest), + None => home, + }); } + Ok(PathBuf::from(path)) } diff --git a/crates/tui/src/commands/groups/memory/note.rs b/crates/tui/src/commands/groups/memory/note.rs index 8f2fc30b4e..4f531c8c8d 100644 --- a/crates/tui/src/commands/groups/memory/note.rs +++ b/crates/tui/src/commands/groups/memory/note.rs @@ -345,6 +345,28 @@ mod tests { } impl CommandWorkspaceContext for FakeWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by note") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("navigation is not used by note") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by note") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result + { + unreachable!("anchors are not used by note") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by note") + } fn workspace(&self) -> PathBuf { self.path.clone() } diff --git a/crates/tui/src/commands/groups/utility/task.rs b/crates/tui/src/commands/groups/utility/task.rs index fa4bb7a955..e49bc4c938 100644 --- a/crates/tui/src/commands/groups/utility/task.rs +++ b/crates/tui/src/commands/groups/utility/task.rs @@ -86,6 +86,28 @@ mod tests { struct FakeWorkspace; impl codewhale_command_contract::facets::CommandWorkspaceContext for FakeWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by task") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("navigation is not used by task") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by task") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result + { + unreachable!("anchors are not used by task") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by task") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } @@ -99,6 +121,28 @@ mod tests { struct FailingWorkspace; impl codewhale_command_contract::facets::CommandWorkspaceContext for FailingWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by task") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("navigation is not used by task") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by task") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result + { + unreachable!("anchors are not used by task") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by task") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 25844c485b..9bb85d4c9f 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -14,6 +14,8 @@ mod config_main_import_host; mod config_policy_host; mod contract; mod core_navigation_host; +#[cfg(test)] +mod core_navigation_host_tests; pub mod discovery; pub(crate) mod groups; @@ -1349,6 +1351,144 @@ mod tests { #[test] fn command_info_resolves_canonical_names_and_aliases() { + use codewhale_command_contract::handler::{ + CommandCapabilities as Caps, CommandContexts, CommandHandler, ContextParts, + }; + struct MissingTemplate; + impl codewhale_command_contract::facets::CommandPresentationContext for MissingTemplate { + fn translate(&self, _: &str, _: &[(&str, &str)]) -> Result { + Err("template unavailable".into()) + } + } + for (name, service, needs_presentation) in [ + ("help", Caps::NAVIGATION, true), + ("home", Caps::NAVIGATION, true), + ("overview", Caps::NAVIGATION, true), + ("links", Caps::NAVIGATION, true), + ("anchor", Caps::WORKSPACE, false), + ("workspace", Caps::WORKSPACE, true), + ("translate", Caps::CONFIG, true), + ] { + let expected = if needs_presentation { + service | Caps::PRESENTATION + } else { + service + }; + let CommandHandler::Contextual { + capabilities, + handler, + } = registry().get(name).unwrap().contextual_handler().unwrap() + else { + panic!("/{name} must declare scoped authority"); + }; + assert_eq!(capabilities, expected); + let missing = handler(CommandContexts::empty(), Some("missing-facet-input")); + assert!(missing.is_error && missing.action.is_none()); + let mut app = create_test_app(); + app.launch.dismiss(); + let before_view = format!("{:?}", app.view_stack.top_kind()); + let before_translation = app.translation_enabled; + if needs_presentation { + let missing_presentation = { + let mut bundle = app.command_contexts(); + handler(bundle.contexts(service), None) + }; + assert!(missing_presentation.is_error && missing_presentation.action.is_none()); + assert!( + missing_presentation + .message + .as_deref() + .is_some_and(|text| text.contains("presentation")) + ); + } + if name == "translate" { + let mut failing = MissingTemplate; + let failure = { + let mut bundle = app.command_contexts(); + handler( + bundle.contexts(service).with_presentation(&mut failing), + None, + ) + }; + assert!(failure.is_error && failure.action.is_none()); + } + assert!(!app.launch.visible); + assert_eq!(format!("{:?}", app.view_stack.top_kind()), before_view); + assert_eq!(app.translation_enabled, before_translation); + let mut bundle = app.command_contexts(); + let ContextParts { + session, + model, + cost, + mode_policy, + system_prompt, + skills, + workspace, + presentation, + media, + memory, + project, + skill_group, + plugin, + lifecycle, + control, + export, + structcopy, + debug_receipts, + debug_change, + debug_history, + debug_diff, + debug_undo, + debug_diagnostics, + permissions, + config_status, + config, + pet, + trust, + login, + work_surface, + navigation, + } = bundle.contexts(capabilities).into_parts(); + for (capability, exposed) in [ + (Caps::SESSION, session.is_some()), + (Caps::MODEL, model.is_some()), + (Caps::COST, cost.is_some()), + (Caps::MODE_POLICY, mode_policy.is_some()), + (Caps::SYSTEM_PROMPT, system_prompt.is_some()), + (Caps::SKILLS, skills.is_some()), + (Caps::WORKSPACE, workspace.is_some()), + (Caps::PRESENTATION, presentation.is_some()), + (Caps::MEDIA, media.is_some()), + (Caps::MEMORY, memory.is_some()), + (Caps::PROJECT, project.is_some()), + (Caps::SKILL_GROUP, skill_group.is_some()), + (Caps::PLUGIN, plugin.is_some()), + (Caps::SESSION_LIFECYCLE, lifecycle.is_some()), + (Caps::SESSION_CONTROL, control.is_some()), + (Caps::SESSION_EXPORT, export.is_some()), + (Caps::SESSION_STRUCTCOPY, structcopy.is_some()), + (Caps::DEBUG_RECEIPTS, debug_receipts.is_some()), + (Caps::DEBUG_CHANGE, debug_change.is_some()), + (Caps::DEBUG_HISTORY, debug_history.is_some()), + (Caps::DEBUG_DIFF, debug_diff.is_some()), + (Caps::DEBUG_UNDO, debug_undo.is_some()), + (Caps::DEBUG_DIAGNOSTICS, debug_diagnostics.is_some()), + (Caps::PERMISSIONS, permissions.is_some()), + (Caps::CONFIG_STATUS, config_status.is_some()), + (Caps::CONFIG, config.is_some()), + (Caps::PET, pet.is_some()), + (Caps::TRUST, trust.is_some()), + (Caps::LOGIN, login.is_some()), + (Caps::WORK_SURFACE, work_surface.is_some()), + (Caps::NAVIGATION, navigation.is_some()), + ] { + assert_eq!( + exposed, + expected.contains(capability), + "/{name} facet {capability:?}" + ); + } + } for (name, expected) in [ ("hotbar", AppAction::OpenHotbarSetup), ("transcript", AppAction::OpenLiveTranscript), @@ -2186,6 +2326,13 @@ mod tests { // register bounded portable groups/slices; every entry outside the // explicit list must still use the original legacy dispatcher. const MIGRATED_GROUPS: &[&str] = &[ + "translate", + "anchor", + "workspace", + "help", + "home", + "overview", + "links", "hotbar", "transcript", "turn", diff --git a/crates/tui/src/commands/session_export_test_support.rs b/crates/tui/src/commands/session_export_test_support.rs index 2e2128eb11..954ef0d919 100644 --- a/crates/tui/src/commands/session_export_test_support.rs +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -126,6 +126,7 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { trust, login, work_surface, + navigation, } = parts; assert!( permissions.is_none() @@ -135,6 +136,7 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() ); assert!(export.is_some(), "the export facet must be exposed"); diff --git a/crates/tui/src/commands/session_structcopy_host_tests.rs b/crates/tui/src/commands/session_structcopy_host_tests.rs index 9cda9d70f4..2b71465d8b 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -373,6 +373,7 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross trust, login, work_surface, + navigation, } = bundle.contexts(capabilities).into_parts(); assert!( permissions.is_none() @@ -382,6 +383,7 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() ); assert!(presentation.is_some()); assert!( From 2c23e89597e0149886d3c36d80d62523b45c9e57 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sat, 17 Oct 2026 04:34:07 +0200 Subject: [PATCH 4/9] refactor(commands): complete portable core navigation voice boundary FEAT-031 P4 replaces the remaining voice App handlers with VOICE_INPUT bit31 Shapes, preserving u32 identities and exact authority. Relocate the unchanged capture runtime outside the movable group and retain builtin shortcut semantics. The real complete13-entry root compiles independently as an unpublished normal library; core remains pending for other slices. Verified:79 contract and1289 native command/help/voice tests;1125 observations/15 locales and40 metadata entries byte-identical;9 actual-source portable tests and7 rejection controls. Normal graph/build, full Clippy/fmt,30 boundary/63 migration controls, locales, developer routing and publication-order validation pass. No physical move or release. Refs #5316. --- .github/workflows/ci.yml | 6 + Cargo.lock | 7 + Cargo.toml | 1 + crates/command-contract/src/handler.rs | 16 + crates/command-contract/src/navigation.rs | 10 + crates/command-contract/src/tests.rs | 3 + crates/tui/src/commands/contract.rs | 12 +- .../src/commands/contract/core_navigation.rs | 35 ++ .../tui/src/commands/core_navigation_host.rs | 18 +- .../commands/core_navigation_voice_host.rs | 220 ++++++++++ .../debug_diagnostics_surface_tests.rs | 2 + .../src/commands/debug_mutation_host_tests.rs | 2 + crates/tui/src/commands/groups/core/mod.rs | 16 +- .../src/commands/groups/core/navigation.rs | 10 +- crates/tui/src/commands/groups/core/voice.rs | 388 +++++------------- crates/tui/src/commands/mod.rs | 134 +++++- .../commands/session_export_test_support.rs | 2 + .../commands/session_structcopy_host_tests.rs | 2 + .../check-command-core-navigation-proof.py | 148 +++++++ scripts/check-portable-core-navigation.sh | 10 + scripts/dev-test.sh | 8 + ...est_check_command_core_navigation_proof.py | 103 +++++ tests/portable-core-navigation/Cargo.toml | 14 + tests/portable-core-navigation/README.md | 33 ++ tests/portable-core-navigation/src/lib.rs | 2 + 25 files changed, 888 insertions(+), 314 deletions(-) create mode 100644 crates/tui/src/commands/core_navigation_voice_host.rs create mode 100644 scripts/check-command-core-navigation-proof.py create mode 100644 scripts/check-portable-core-navigation.sh create mode 100644 scripts/test_check_command_core_navigation_proof.py create mode 100644 tests/portable-core-navigation/Cargo.toml create mode 100644 tests/portable-core-navigation/README.md create mode 100644 tests/portable-core-navigation/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79aa4a7871..7422be2e7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -912,6 +912,12 @@ jobs: run: | python3 -B scripts/test_check_command_config_proof.py sh scripts/check-portable-config.sh + - name: Check portable core navigation boundary + if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') + shell: bash + run: | + python3 -B scripts/test_check_command_core_navigation_proof.py + sh scripts/check-portable-core-navigation.sh - uses: actions/setup-node@v7 # The extension-host integration tests spawn the real bundle under # Node >= 22.19; CODEWHALE_EXT_HOST_TESTS below makes a missing Node a diff --git a/Cargo.lock b/Cargo.lock index 5f7dac4870..716d81a1c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1117,6 +1117,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "codewhale-portable-core-navigation" +version = "0.10.2" +dependencies = [ + "codewhale-command-contract", +] + [[package]] name = "codewhale-portable-session" version = "0.10.2" diff --git a/Cargo.toml b/Cargo.toml index 34f05eb443..0a7c82ec22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/workflow-js", "tests/portable-session", "tests/portable-config", + "tests/portable-core-navigation", ] exclude = ["vendor/codewhale-ratatui"] default-members = ["crates/cli"] diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index 7d7c8acc3d..7e377f4482 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -102,6 +102,7 @@ impl CommandCapabilities { /// Workbar placement/panel state for `/workbar` (FEAT-028 D3). pub const WORK_SURFACE: Self = Self(1 << 29); pub const NAVIGATION: Self = Self(1 << 30); + pub const VOICE_INPUT: Self = Self(1 << 31); /// Raw bit pattern, for tests that pin the capability-space capacity. /// @@ -176,6 +177,7 @@ pub struct CommandContexts<'a> { login: Option<&'a mut dyn crate::config::CommandLoginContext>, work_surface: Option<&'a mut dyn crate::config::CommandWorkSurfaceContext>, navigation: Option<&'a mut dyn crate::navigation::CommandNavigationContext>, + voice_input: Option<&'a mut dyn crate::navigation::CommandVoiceInputContext>, } /// Consumed envelope used when one handler needs several independent facets. @@ -211,6 +213,7 @@ pub struct ContextParts<'a> { pub login: Option<&'a mut dyn crate::config::CommandLoginContext>, pub work_surface: Option<&'a mut dyn crate::config::CommandWorkSurfaceContext>, pub navigation: Option<&'a mut dyn crate::navigation::CommandNavigationContext>, + pub voice_input: Option<&'a mut dyn crate::navigation::CommandVoiceInputContext>, } impl<'a> CommandContexts<'a> { @@ -247,6 +250,7 @@ impl<'a> CommandContexts<'a> { login: None, work_surface: None, navigation: None, + voice_input: None, } } @@ -283,6 +287,7 @@ impl<'a> CommandContexts<'a> { login: self.login, work_surface: self.work_surface, navigation: self.navigation, + voice_input: self.voice_input, } } @@ -337,6 +342,17 @@ impl<'a> CommandContexts<'a> { self } + pub fn with_voice_input( + mut self, + value: &'a mut dyn crate::navigation::CommandVoiceInputContext, + ) -> Self { + assert!( + self.voice_input.replace(value).is_none(), + "voice-input facet already set" + ); + self + } + pub fn with_permissions(mut self, value: &'a mut dyn CommandPermissionsContext) -> Self { assert!( self.permissions.replace(value).is_none(), diff --git a/crates/command-contract/src/navigation.rs b/crates/command-contract/src/navigation.rs index 9f087c630f..b254ed02d1 100644 --- a/crates/command-contract/src/navigation.rs +++ b/crates/command-contract/src/navigation.rs @@ -93,3 +93,13 @@ pub trait CommandNavigationContext { fn overview(&self) -> NavigationOverview; fn links(&self) -> NavigationLinks; } + +pub trait CommandVoiceInputContext { + fn recorder_available(&self) -> bool; + fn voice_enabled(&self) -> bool; + fn set_voice_enabled(&mut self, enabled: bool); + fn send_enabled(&self) -> bool; + fn set_send_enabled(&mut self, enabled: bool); + fn control_enabled(&self) -> bool; + fn set_control_enabled(&mut self, enabled: bool); +} diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 7d5129f166..4ba9a0497f 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -3593,6 +3593,7 @@ fn structcopy_capability_preserves_all_published_identities() { CommandCapabilities::LOGIN, CommandCapabilities::WORK_SURFACE, CommandCapabilities::NAVIGATION, + CommandCapabilities::VOICE_INPUT, ]; let exact = CommandCapabilities::SESSION_STRUCTCOPY | CommandCapabilities::PRESENTATION; for (index, capability) in capabilities.into_iter().enumerate() { @@ -3658,6 +3659,7 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { login, work_surface, navigation, + voice_input, } = CommandContexts::empty() .with_structcopy(&mut fixture) .with_presentation(&mut presentation) @@ -3708,6 +3710,7 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { login.is_some(), work_surface.is_some(), navigation.is_some(), + voice_input.is_some(), ] { assert!(!present); } diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index dcb1544d86..2a541741aa 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -33,7 +33,7 @@ use std::rc::Rc; mod config_main; mod core_navigation; -use core_navigation::NavigationAdapter; +use core_navigation::{NavigationAdapter, VoiceInputAdapter}; pub(in crate::commands) mod config_policy; #[cfg(test)] mod config_policy_baseline; @@ -4668,6 +4668,7 @@ pub(crate) struct CommandContextBundle<'a> { login: LoginAdapter<'a>, work_surface: WorkSurfaceAdapter<'a>, navigation: NavigationAdapter<'a>, + voice_input: VoiceInputAdapter<'a>, } impl<'a> CommandContextBundle<'a> { @@ -4767,6 +4768,9 @@ impl<'a> CommandContextBundle<'a> { if capabilities.contains(CommandCapabilities::NAVIGATION) { contexts = contexts.with_navigation(&mut self.navigation); } + if capabilities.contains(CommandCapabilities::VOICE_INPUT) { + contexts = contexts.with_voice_input(&mut self.voice_input); + } contexts } @@ -4803,7 +4807,8 @@ impl<'a> CommandContextBundle<'a> { .union(CommandCapabilities::TRUST) .union(CommandCapabilities::LOGIN) .union(CommandCapabilities::WORK_SURFACE) - .union(CommandCapabilities::NAVIGATION); + .union(CommandCapabilities::NAVIGATION) + .union(CommandCapabilities::VOICE_INPUT); self.contexts(all_test_capabilities).into_parts() } } @@ -4855,7 +4860,8 @@ impl App { trust: TrustAdapter { host: host.clone() }, login: LoginAdapter { host: host.clone() }, work_surface: WorkSurfaceAdapter { host: host.clone() }, - navigation: NavigationAdapter { host }, + navigation: NavigationAdapter { host: host.clone() }, + voice_input: VoiceInputAdapter { host }, } } } diff --git a/crates/tui/src/commands/contract/core_navigation.rs b/crates/tui/src/commands/contract/core_navigation.rs index 6011887eef..b3651e743b 100644 --- a/crates/tui/src/commands/contract/core_navigation.rs +++ b/crates/tui/src/commands/contract/core_navigation.rs @@ -133,6 +133,34 @@ impl CommandNavigationContext for NavigationAdapter<'_> { } } +pub(super) struct VoiceInputAdapter<'a> { + pub(super) host: SharedCommandHost<'a>, +} + +impl codewhale_command_contract::navigation::CommandVoiceInputContext for VoiceInputAdapter<'_> { + fn recorder_available(&self) -> bool { + crate::voice::is_available() + } + fn voice_enabled(&self) -> bool { + self.host.app.borrow().voice_enabled + } + fn set_voice_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().voice_enabled = enabled; + } + fn send_enabled(&self) -> bool { + self.host.app.borrow().voice_send_enabled + } + fn set_send_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().voice_send_enabled = enabled; + } + fn control_enabled(&self) -> bool { + self.host.app.borrow().voice_control_enabled + } + fn set_control_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().voice_control_enabled = enabled; + } +} + pub(super) fn anchors_path(workspace: &std::path::Path) -> std::path::PathBuf { let primary = workspace.join(".codewhale").join("anchors.md"); if primary.symlink_metadata().is_ok() || workspace.join(".codewhale").is_symlink() { @@ -175,6 +203,13 @@ pub(super) fn replace_anchor_document( pub(super) fn resolve_message(key: &str) -> Option { Some(match key { + "voice_disabled" => MessageId::VoiceDisabled, + "voice_enabled" => MessageId::VoiceEnabled, + "voice_err_no_recorder" => MessageId::VoiceErrNoRecorder, + "voice_send_enabled" => MessageId::VoiceSendEnabled, + "voice_send_disabled" => MessageId::VoiceSendDisabled, + "voice_control_enabled" => MessageId::VoiceControlEnabled, + "voice_control_disabled" => MessageId::VoiceControlDisabled, "cmd_translate_on" => MessageId::CmdTranslateOn, "cmd_translate_off" => MessageId::CmdTranslateOff, "workspace_current" => MessageId::WorkspaceCurrent, diff --git a/crates/tui/src/commands/core_navigation_host.rs b/crates/tui/src/commands/core_navigation_host.rs index 9dc4362930..10a5cda8bf 100644 --- a/crates/tui/src/commands/core_navigation_host.rs +++ b/crates/tui/src/commands/core_navigation_host.rs @@ -1,7 +1,7 @@ use super::CommandResult; use super::groups::core::navigation::portable_handlers; use super::traits::{Command, ContextualCommand}; -use crate::tui::app::AppAction; +use crate::tui::app::{App, AppAction}; use codewhale_command_contract::handler::CommandHandler; use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; use codewhale_command_contract::outcome::{CoreNavigationAction, CoreNavigationCommandResult}; @@ -53,6 +53,22 @@ fn host_result(result: CoreNavigationCommandResult) -> CommandResult { } } +pub(in crate::commands) fn dispatch( + app: &mut App, + arg: Option<&str>, +) -> CommandResult { + match Registration::::handler() { + CommandHandler::Pure(run) => run(arg), + CommandHandler::Contextual { + capabilities, + handler, + } => { + let mut bundle = app.command_contexts_with_config(None); + handler(bundle.contexts(capabilities), arg) + } + } +} + pub(in crate::commands) fn registration() -> Box { Box::new( ContextualCommand::from_contract::>().expect("navigation registration"), diff --git a/crates/tui/src/commands/core_navigation_voice_host.rs b/crates/tui/src/commands/core_navigation_voice_host.rs new file mode 100644 index 0000000000..f62c0d68b7 --- /dev/null +++ b/crates/tui/src/commands/core_navigation_voice_host.rs @@ -0,0 +1,220 @@ +//! Host-owned voice capture and builtin shortcut transport. + +use crate::commands::CommandResult; +use crate::config::Config; +use crate::tui::app::App; +use crate::voice::{ + is_available, record_audio, resolve_asr_choice, split_send_suffix, transcribe_selected, +}; +use codewhale_localization::{MessageId, tr}; +use std::time::Duration; + +pub fn voice(app: &mut App) -> CommandResult { + super::core_navigation_host::dispatch::<10>(app, None) +} + +// --- Capture orchestration (UI event loop) --------------------------------- + +/// What the UI should do with a finished capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VoiceCaptureOutcome { + /// Insert the transcribed text into the composer at the cursor. + Insert(String), + /// Submit this text as a message (auto-send). + Send(String), +} + +/// Status line while recording: the localized recording label, the latest +/// interim transcript once one exists, and how to stop. The capture is awaited +/// on the UI loop, so no key can end it — `record_audio` stops after a second +/// of silence (or `MAX_RECORD_SECS`), and the cue says exactly that. +fn recording_status(locale: codewhale_localization::Locale, interim: Option<&str>) -> String { + let label = tr(locale, MessageId::VoiceRecording); + let stop = tr(locale, MessageId::VoiceRecordingStopHint); + match interim.map(str::trim).filter(|text| !text.is_empty()) { + Some(text) => format!("{label} \u{2014} \u{201c}{text}\u{201d} \u{00b7} {stop}"), + None => format!("{label} \u{00b7} {stop}"), + } +} + +/// Perform a complete record + transcribe cycle with live interim display. +/// +/// Runs in the UI event loop (see [`crate::tui::app::AppAction::VoiceCapture`]) so provider +/// credentials come from the live [`Config`] rather than state cached on +/// [`App`]. Recording happens on a blocking thread; transcription uses the +/// shared async HTTP client. Every failure path returns a localized message +/// so callers can surface it as a status line. +pub async fn capture_and_transcribe( + app: &mut App, + config: &Config, +) -> Result { + let locale = app.ui_locale; + + if !is_available() { + return Err(tr(locale, MessageId::VoiceErrNoRecorder).to_string()); + } + // C01-10: the credential check follows the ASR backend that will run, + // not the chat provider. Local Whisper and Groq need no provider key, so + // resolve it lazily — the same contract as `crate::voice::dictate_once` + // — and only refuse before recording when provider ASR is the backend. + let provider_key = || { + config + .active_route_api_key() + .map_err(|_| tr(locale, MessageId::VoiceErrNoAuth).to_string()) + }; + let (asr_kind, _asr_model) = resolve_asr_choice(config); + if asr_kind == "provider" { + provider_key()?; + } + // Show the localized recording status plus the live interim in the composer. + let original_input = app.composer.input.clone(); + let original_cursor = app.composer.cursor_position; + app.status_message = Some(recording_status(locale, None)); + + // Streaming interim: poll every 700ms and show partial transcript like Grok Build's + // VoiceEvent::Interim → VoiceState::Recording{interim}. We re-transcribe the + // growing buffer (local-whisper is cheap; Groq is ~300ms; provider falls back). + let interim_enabled = true; // always show partials — feels alive like Spark + + // Spawn recorder on blocking thread with a shared buffer for interim polling. + let shared_buf: std::sync::Arc>> = + std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())); + let shared_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let shared_buf_clone = std::sync::Arc::clone(&shared_buf); + let shared_done_clone = std::sync::Arc::clone(&shared_done); + let recorder_handle = tokio::task::spawn_blocking(move || { + // Bridge to existing record_audio but copy into shared buffer incrementally. + // For now we reuse the blocking recorder and then publish; interim will + // poll the final buffer. A true streaming recorder (cpal/pw-record) is + // the next step — see grokbuild's xai-grok-voice::audio for the subprocess + // isolation pattern we should mirror. + let result = record_audio(); + if let Some((samples, dur)) = result { + *shared_buf_clone.lock() = samples.clone(); + shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); + Some((samples, dur)) + } else { + shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); + None + } + }); + + // Interim polling loop — updates composer with "original + interim ▍" so text + // appears as you talk, just like Spark's live transcript. + let mut last_interim = String::new(); + let mut ticks: u32 = 0; + loop { + tokio::time::sleep(Duration::from_millis(700)).await; + ticks += 1; + if shared_done.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + if !interim_enabled || ticks < 2 { + continue; // let a little audio accumulate before first interim + } + let snapshot = { shared_buf.lock().clone() }; + if snapshot.len() < 8000 { + // <0.5s of audio — not enough for meaningful ASR + continue; + } + // Try cheapest free ASR for interim; don't fail the whole capture on interim error. + let interim = transcribe_selected(config, &asr_kind, &snapshot, None) + .await + .unwrap_or_default(); + let trimmed = interim.trim(); + if !trimmed.is_empty() && trimmed != last_interim { + last_interim = trimmed.to_string(); + // Show interim inline — preserve cursor at original position, append interim with a block cursor + let display = if original_input.trim().is_empty() { + format!("{trimmed} ▍") + } else { + format!("{} {} ▍", original_input.trim_end(), trimmed) + }; + app.composer.input = display; + app.composer.cursor_position = original_cursor; + app.status_message = Some(recording_status(locale, Some(trimmed))); + } + if ticks > 40 { + break; // safety: ~28s max interim polling + } + } + + let (samples, _duration) = recorder_handle + .await + .ok() + .flatten() + .ok_or_else(|| tr(locale, MessageId::VoiceErrTooShort).to_string())?; + + // Restore composer to original before final insert (interim was preview only) + app.composer.input = original_input.clone(); + app.composer.cursor_position = original_cursor; + app.status_message = Some(tr(locale, MessageId::VoiceProcessing).to_string()); + + let text = transcribe_selected( + config, + &asr_kind, + &samples, + app.voice_control_enabled.then_some(original_input.as_str()), + ) + .await + .map_err(|error| format!("{}: {error}", tr(locale, MessageId::VoiceErrNetwork)))?; + + let clean = text.trim(); + if app.voice_send_enabled { + let (remainder, wants_send) = split_send_suffix(clean); + if wants_send { + // A bare "send it" submits whatever is already in the composer. + let outgoing = if remainder.is_empty() { + let existing = app.composer.input.trim().to_string(); + if !existing.is_empty() { + app.clear_input(); + } + existing + } else { + remainder.to_string() + }; + if outgoing.is_empty() { + return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); + } + return Ok(VoiceCaptureOutcome::Send(outgoing)); + } + } + if clean.is_empty() { + return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); + } + Ok(VoiceCaptureOutcome::Insert(clean.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recording_status_is_localized_and_keeps_the_interim() { + use codewhale_localization::Locale; + + for locale in [Locale::En, Locale::De, Locale::Ja] { + let label = tr(locale, MessageId::VoiceRecording).to_string(); + let stop = tr(locale, MessageId::VoiceRecordingStopHint).to_string(); + let idle = format!("{label} \u{00b7} {stop}"); + assert_eq!(recording_status(locale, None), idle); + assert_eq!(recording_status(locale, Some(" ")), idle); + + let with_interim = recording_status(locale, Some(" hello there ")); + assert!(with_interim.starts_with(&label), "{with_interim}"); + assert!(with_interim.contains("\u{201c}hello there\u{201d}")); + assert!( + with_interim.ends_with(&stop), + "the stop cue survives the interim: {with_interim}" + ); + assert!(!with_interim.contains("\u{2325}V"), "no hardcoded key hint"); + if locale != Locale::En { + assert!(!with_interim.contains("to finish"), "no English hint"); + } + } + assert_ne!( + recording_status(Locale::En, None), + recording_status(Locale::De, None) + ); + } +} diff --git a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs index 9ae5c80085..ef6e091914 100644 --- a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs @@ -259,6 +259,7 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { login, work_surface, navigation, + voice_input, } = parts; assert!( permissions.is_none() @@ -269,6 +270,7 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { && login.is_none() && work_surface.is_none() && navigation.is_none() + && voice_input.is_none() ); assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics"); assert_eq!( diff --git a/crates/tui/src/commands/debug_mutation_host_tests.rs b/crates/tui/src/commands/debug_mutation_host_tests.rs index 24015bb047..d6b3fabe41 100644 --- a/crates/tui/src/commands/debug_mutation_host_tests.rs +++ b/crates/tui/src/commands/debug_mutation_host_tests.rs @@ -1612,6 +1612,7 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { login, work_surface, navigation, + voice_input, } = bundle.contexts(capabilities).into_parts(); assert!( permissions.is_none() @@ -1622,6 +1623,7 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { && login.is_none() && work_surface.is_none() && navigation.is_none() + && voice_input.is_none() ); for (name, present, capability) in [ ("session", session.is_some(), Caps::SESSION), diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs index 6844d5b0a2..4c38546ef8 100644 --- a/crates/tui/src/commands/groups/core/mod.rs +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -34,7 +34,6 @@ mod setup; mod stash; mod subagents; pub mod util; -pub mod voice; mod workflow; pub(in crate::commands) use self::core::reset_conversation_state; @@ -155,19 +154,10 @@ impl CommandGroup for CoreCommands { rlm::RlmCmd::execute, )), crate::commands::core_navigation_host::registration::<9>(), - Box::new(FunctionCommand::new( - voice::VoiceCmd::info(), - voice::VoiceCmd::execute, - )), + crate::commands::core_navigation_host::registration::<10>(), Box::new(FunctionCommand::new(&effort::EFFORT_INFO, effort::effort,)), - Box::new(FunctionCommand::new( - voice::VoiceSendCmd::info(), - voice::VoiceSendCmd::execute, - )), - Box::new(FunctionCommand::new( - voice::VoiceControlCmd::info(), - voice::VoiceControlCmd::execute, - )), + crate::commands::core_navigation_host::registration::<11>(), + crate::commands::core_navigation_host::registration::<12>(), Box::new(FunctionCommand::new( pin::PinCmd::info(), pin::PinCmd::execute diff --git a/crates/tui/src/commands/groups/core/navigation.rs b/crates/tui/src/commands/groups/core/navigation.rs index a037e9b2d3..d29bec321b 100644 --- a/crates/tui/src/commands/groups/core/navigation.rs +++ b/crates/tui/src/commands/groups/core/navigation.rs @@ -14,6 +14,8 @@ pub mod transcript; pub mod translate; #[path = "turn.rs"] pub mod turn; +#[path = "voice.rs"] +pub mod voice; #[path = "workspace.rs"] pub mod workspace; @@ -24,7 +26,7 @@ use codewhale_command_contract::outcome::CoreNavigationCommandResult; pub fn portable_handlers() -> [( &'static CommandInfo, CommandHandler, -); 10] { +); 13] { [ (anchor::AnchorCmd::info(), anchor::AnchorCmd::handler()), (help::HelpCmd::info(), help::HelpCmd::handler()), @@ -45,5 +47,11 @@ pub fn portable_handlers() -> [( translate::TranslateCmd::info(), translate::TranslateCmd::handler(), ), + (voice::VoiceCmd::info(), voice::VoiceCmd::handler()), + (voice::VoiceSendCmd::info(), voice::VoiceSendCmd::handler()), + ( + voice::VoiceControlCmd::info(), + voice::VoiceControlCmd::handler(), + ), ] } diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index 6b98b0a7c2..f22fa77069 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -1,340 +1,140 @@ //! Voice input commands — `/voice`, `/voice-send`, `/voice-control`. -//! -//! Records audio from the default microphone, sends it to the configured -//! provider's API for transcription, and inserts the transcribed text into -//! the composer. The interaction model mirrors MiMo Code's voice UX: -//! -//! `/voice` — toggle voice input on/off (records when toggled on) -//! `/voice-send` — toggle auto-send when the transcript ends with -//! "send it" / "发送" -//! `/voice-control` — toggle AI-assisted dictation that sees the current -//! composer text -//! -//! The slash commands only flip state and emit [`AppAction::VoiceCapture`]; -//! the actual capture runs in the UI event loop where the live [`Config`] -//! supplies provider credentials. That keeps the handlers side-effect free -//! (the registry smoke tests execute every command) and avoids caching -//! auth material on [`App`]. -//! -//! Recording, transcription and the headless dictation cycle live in -//! `crate::voice`; this module keeps the slash commands and the capture loop -//! that updates the composer while the user speaks. -use std::time::Duration; - -use crate::commands::CommandResult; -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::config::Config; -use crate::tui::app::{App, AppAction}; -use crate::voice::{ - is_available, record_audio, resolve_asr_choice, split_send_suffix, transcribe_selected, +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, }; -use codewhale_localization::{MessageId, tr}; -pub(in crate::commands) const VOICE_INFO: CommandInfo = CommandInfo { +pub const VOICE_INFO: CommandInfo = CommandInfo { name: "voice", aliases: &["yuyin", "语音"], usage: "/voice", - description_id: MessageId::CmdVoiceDescription, + description_key: "cmd_voice_description", }; - -pub(in crate::commands) const VOICE_SEND_INFO: CommandInfo = CommandInfo { +pub const VOICE_SEND_INFO: CommandInfo = CommandInfo { name: "voicesend", aliases: &["voice-send", "yuyinsend", "语音发送"], usage: "/voicesend", - description_id: MessageId::CmdVoiceSendDescription, + description_key: "cmd_voice_send_description", }; - -pub(in crate::commands) const VOICE_CONTROL_INFO: CommandInfo = CommandInfo { +pub const VOICE_CONTROL_INFO: CommandInfo = CommandInfo { name: "voicecontrol", aliases: &["voice-control", "yuyincontrol", "语音控制"], usage: "/voicecontrol", - description_id: MessageId::CmdVoiceControlDescription, + description_key: "cmd_voice_control_description", }; -pub(in crate::commands) struct VoiceCmd; -pub(in crate::commands) struct VoiceSendCmd; -pub(in crate::commands) struct VoiceControlCmd; +pub struct VoiceCmd; +pub struct VoiceSendCmd; +pub struct VoiceControlCmd; -impl RegisterCommand for VoiceCmd { +impl RegisterCommand for VoiceCmd { fn info() -> &'static CommandInfo { &VOICE_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - voice(app) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION), + handler: voice, + } } } - -impl RegisterCommand for VoiceSendCmd { +impl RegisterCommand for VoiceSendCmd { fn info() -> &'static CommandInfo { &VOICE_SEND_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - voice_send(app) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION), + handler: voice_send, + } } } - -impl RegisterCommand for VoiceControlCmd { +impl RegisterCommand for VoiceControlCmd { fn info() -> &'static CommandInfo { &VOICE_CONTROL_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - voice_control(app) - } -} - -// --- Capture orchestration (UI event loop) --------------------------------- - -/// What the UI should do with a finished capture. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VoiceCaptureOutcome { - /// Insert the transcribed text into the composer at the cursor. - Insert(String), - /// Submit this text as a message (auto-send). - Send(String), -} - -/// Status line while recording: the localized recording label, the latest -/// interim transcript once one exists, and how to stop. The capture is awaited -/// on the UI loop, so no key can end it — `record_audio` stops after a second -/// of silence (or `MAX_RECORD_SECS`), and the cue says exactly that. -fn recording_status(locale: codewhale_localization::Locale, interim: Option<&str>) -> String { - let label = tr(locale, MessageId::VoiceRecording); - let stop = tr(locale, MessageId::VoiceRecordingStopHint); - match interim.map(str::trim).filter(|text| !text.is_empty()) { - Some(text) => format!("{label} \u{2014} \u{201c}{text}\u{201d} \u{00b7} {stop}"), - None => format!("{label} \u{00b7} {stop}"), - } -} - -/// Perform a complete record + transcribe cycle with live interim display. -/// -/// Runs in the UI event loop (see [`AppAction::VoiceCapture`]) so provider -/// credentials come from the live [`Config`] rather than state cached on -/// [`App`]. Recording happens on a blocking thread; transcription uses the -/// shared async HTTP client. Every failure path returns a localized message -/// so callers can surface it as a status line. -pub async fn capture_and_transcribe( - app: &mut App, - config: &Config, -) -> Result { - let locale = app.ui_locale; - - if !is_available() { - return Err(tr(locale, MessageId::VoiceErrNoRecorder).to_string()); - } - // C01-10: the credential check follows the ASR backend that will run, - // not the chat provider. Local Whisper and Groq need no provider key, so - // resolve it lazily — the same contract as `crate::voice::dictate_once` - // — and only refuse before recording when provider ASR is the backend. - let provider_key = || { - config - .active_route_api_key() - .map_err(|_| tr(locale, MessageId::VoiceErrNoAuth).to_string()) - }; - let (asr_kind, _asr_model) = resolve_asr_choice(config); - if asr_kind == "provider" { - provider_key()?; - } - // Show the localized recording status plus the live interim in the composer. - let original_input = app.composer.input.clone(); - let original_cursor = app.composer.cursor_position; - app.status_message = Some(recording_status(locale, None)); - - // Streaming interim: poll every 700ms and show partial transcript like Grok Build's - // VoiceEvent::Interim → VoiceState::Recording{interim}. We re-transcribe the - // growing buffer (local-whisper is cheap; Groq is ~300ms; provider falls back). - let interim_enabled = true; // always show partials — feels alive like Spark - - // Spawn recorder on blocking thread with a shared buffer for interim polling. - let shared_buf: std::sync::Arc>> = - std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())); - let shared_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let shared_buf_clone = std::sync::Arc::clone(&shared_buf); - let shared_done_clone = std::sync::Arc::clone(&shared_done); - let recorder_handle = tokio::task::spawn_blocking(move || { - // Bridge to existing record_audio but copy into shared buffer incrementally. - // For now we reuse the blocking recorder and then publish; interim will - // poll the final buffer. A true streaming recorder (cpal/pw-record) is - // the next step — see grokbuild's xai-grok-voice::audio for the subprocess - // isolation pattern we should mirror. - let result = record_audio(); - if let Some((samples, dur)) = result { - *shared_buf_clone.lock() = samples.clone(); - shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); - Some((samples, dur)) - } else { - shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); - None - } - }); - - // Interim polling loop — updates composer with "original + interim ▍" so text - // appears as you talk, just like Spark's live transcript. - let mut last_interim = String::new(); - let mut ticks: u32 = 0; - loop { - tokio::time::sleep(Duration::from_millis(700)).await; - ticks += 1; - if shared_done.load(std::sync::atomic::Ordering::SeqCst) { - break; - } - if !interim_enabled || ticks < 2 { - continue; // let a little audio accumulate before first interim - } - let snapshot = { shared_buf.lock().clone() }; - if snapshot.len() < 8000 { - // <0.5s of audio — not enough for meaningful ASR - continue; - } - // Try cheapest free ASR for interim; don't fail the whole capture on interim error. - let interim = transcribe_selected(config, &asr_kind, &snapshot, None) - .await - .unwrap_or_default(); - let trimmed = interim.trim(); - if !trimmed.is_empty() && trimmed != last_interim { - last_interim = trimmed.to_string(); - // Show interim inline — preserve cursor at original position, append interim with a block cursor - let display = if original_input.trim().is_empty() { - format!("{trimmed} ▍") - } else { - format!("{} {} ▍", original_input.trim_end(), trimmed) - }; - app.composer.input = display; - app.composer.cursor_position = original_cursor; - app.status_message = Some(recording_status(locale, Some(trimmed))); - } - if ticks > 40 { - break; // safety: ~28s max interim polling + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION), + handler: voice_control, } } - - let (samples, _duration) = recorder_handle - .await - .ok() - .flatten() - .ok_or_else(|| tr(locale, MessageId::VoiceErrTooShort).to_string())?; - - // Restore composer to original before final insert (interim was preview only) - app.composer.input = original_input.clone(); - app.composer.cursor_position = original_cursor; - app.status_message = Some(tr(locale, MessageId::VoiceProcessing).to_string()); - - let text = transcribe_selected( - config, - &asr_kind, - &samples, - app.voice_control_enabled.then_some(original_input.as_str()), - ) - .await - .map_err(|error| format!("{}: {error}", tr(locale, MessageId::VoiceErrNetwork)))?; - - let clean = text.trim(); - if app.voice_send_enabled { - let (remainder, wants_send) = split_send_suffix(clean); - if wants_send { - // A bare "send it" submits whatever is already in the composer. - let outgoing = if remainder.is_empty() { - let existing = app.composer.input.trim().to_string(); - if !existing.is_empty() { - app.clear_input(); - } - existing - } else { - remainder.to_string() - }; - if outgoing.is_empty() { - return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); - } - return Ok(VoiceCaptureOutcome::Send(outgoing)); - } - } - if clean.is_empty() { - return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); - } - Ok(VoiceCaptureOutcome::Insert(clean.to_string())) } -// --- Command handlers ------------------------------------------------------ - -/// Handle the `/voice` command: toggle voice input. Toggling on requests a -/// one-shot recording + transcription via [`AppAction::VoiceCapture`]. -pub fn voice(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - - if app.voice_enabled { - app.voice_enabled = false; - return CommandResult::message(tr(locale, MessageId::VoiceDisabled)); +fn voice(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(voice) = parts.voice_input else { + return CommandResult::error("Command capability unavailable: voice_input"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !voice.voice_enabled(); + if enabled && !voice.recorder_available() { + let message = presentation + .translate("voice_err_no_recorder", &[]) + .unwrap_or_else(std::convert::identity); + return CommandResult::error(message); } - if !is_available() { - return CommandResult::error(tr(locale, MessageId::VoiceErrNoRecorder)); + let key = if enabled { + "voice_enabled" + } else { + "voice_disabled" + }; + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), + }; + voice.set_voice_enabled(enabled); + if enabled { + CommandResult::with_message_and_action(message, Action::VoiceCapture) + } else { + CommandResult::message(message) } - app.voice_enabled = true; - CommandResult::with_message_and_action( - tr(locale, MessageId::VoiceEnabled), - AppAction::VoiceCapture, - ) } -/// Handle the `/voice-send` command: toggle auto-send after transcription. -pub fn voice_send(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - app.voice_send_enabled = !app.voice_send_enabled; - - let msg = if app.voice_send_enabled { - tr(locale, MessageId::VoiceSendEnabled) +fn voice_send(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(voice) = parts.voice_input else { + return CommandResult::error("Command capability unavailable: voice_input"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !voice.send_enabled(); + let key = if enabled { + "voice_send_enabled" } else { - tr(locale, MessageId::VoiceSendDisabled) + "voice_send_disabled" + }; + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), }; - CommandResult::message(msg) + voice.set_send_enabled(enabled); + CommandResult::message(message) } -/// Handle the `/voice-control` command: toggle AI-assisted dictation. -pub fn voice_control(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - app.voice_control_enabled = !app.voice_control_enabled; - - let msg = if app.voice_control_enabled { - tr(locale, MessageId::VoiceControlEnabled) +fn voice_control(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(voice) = parts.voice_input else { + return CommandResult::error("Command capability unavailable: voice_input"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !voice.control_enabled(); + let key = if enabled { + "voice_control_enabled" } else { - tr(locale, MessageId::VoiceControlDisabled) + "voice_control_disabled" }; - CommandResult::message(msg) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn recording_status_is_localized_and_keeps_the_interim() { - use codewhale_localization::Locale; - - for locale in [Locale::En, Locale::De, Locale::Ja] { - let label = tr(locale, MessageId::VoiceRecording).to_string(); - let stop = tr(locale, MessageId::VoiceRecordingStopHint).to_string(); - let idle = format!("{label} \u{00b7} {stop}"); - assert_eq!(recording_status(locale, None), idle); - assert_eq!(recording_status(locale, Some(" ")), idle); - - let with_interim = recording_status(locale, Some(" hello there ")); - assert!(with_interim.starts_with(&label), "{with_interim}"); - assert!(with_interim.contains("\u{201c}hello there\u{201d}")); - assert!( - with_interim.ends_with(&stop), - "the stop cue survives the interim: {with_interim}" - ); - assert!(!with_interim.contains("\u{2325}V"), "no hardcoded key hint"); - if locale != Locale::En { - assert!(!with_interim.contains("to finish"), "no English hint"); - } - } - assert_ne!( - recording_status(Locale::En, None), - recording_status(Locale::De, None) - ); - } + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), + }; + voice.set_control_enabled(enabled); + CommandResult::message(message) } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 9bb85d4c9f..174dc9ca97 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -88,7 +88,8 @@ pub(crate) use groups::memory::{notes_path, read_notes}; pub use groups::project::share; // Voice capture plumbing shared with the hotbar and the UI event loop. -pub use groups::core::voice; +#[path = "core_navigation_voice_host.rs"] +pub mod voice; #[cfg(test)] mod debug_diagnostics_baseline_tests; @@ -654,6 +655,18 @@ mod tests { Some(AppAction::SendMessage(message)) => assert_eq!(message, "user help now"), other => panic!("expected user command SendMessage action, got {other:?}"), } + std::fs::write(commands_dir.join("voice.md"), "user voice").unwrap(); + super::user_registry::reload(Some(temp.path())); + assert!( + matches!(execute("/voice", &mut app).action, Some(AppAction::SendMessage(message)) if message == "user voice") + ); + app.voice_enabled = true; + let shortcut = crate::commands::voice::voice(&mut app); + assert!(!shortcut.is_error && shortcut.action.is_none()); + assert!( + !app.voice_enabled, + "builtin voice shortcut must not dispatch a shadowing user command" + ); } #[test] @@ -1368,6 +1381,9 @@ mod tests { ("anchor", Caps::WORKSPACE, false), ("workspace", Caps::WORKSPACE, true), ("translate", Caps::CONFIG, true), + ("voice", Caps::VOICE_INPUT, true), + ("voicesend", Caps::VOICE_INPUT, true), + ("voicecontrol", Caps::VOICE_INPUT, true), ] { let expected = if needs_presentation { service | Caps::PRESENTATION @@ -1388,6 +1404,12 @@ mod tests { app.launch.dismiss(); let before_view = format!("{:?}", app.view_stack.top_kind()); let before_translation = app.translation_enabled; + app.voice_enabled = name == "voice"; + let before_voice = ( + app.voice_enabled, + app.voice_send_enabled, + app.voice_control_enabled, + ); if needs_presentation { let missing_presentation = { let mut bundle = app.command_contexts(); @@ -1401,7 +1423,7 @@ mod tests { .is_some_and(|text| text.contains("presentation")) ); } - if name == "translate" { + if name == "translate" || service == Caps::VOICE_INPUT { let mut failing = MissingTemplate; let failure = { let mut bundle = app.command_contexts(); @@ -1415,6 +1437,14 @@ mod tests { assert!(!app.launch.visible); assert_eq!(format!("{:?}", app.view_stack.top_kind()), before_view); assert_eq!(app.translation_enabled, before_translation); + assert_eq!( + ( + app.voice_enabled, + app.voice_send_enabled, + app.voice_control_enabled + ), + before_voice + ); let mut bundle = app.command_contexts(); let ContextParts { session, @@ -1448,6 +1478,7 @@ mod tests { login, work_surface, navigation, + voice_input, } = bundle.contexts(capabilities).into_parts(); for (capability, exposed) in [ (Caps::SESSION, session.is_some()), @@ -1481,6 +1512,7 @@ mod tests { (Caps::LOGIN, login.is_some()), (Caps::WORK_SURFACE, work_surface.is_some()), (Caps::NAVIGATION, navigation.is_some()), + (Caps::VOICE_INPUT, voice_input.is_some()), ] { assert_eq!( exposed, @@ -1640,6 +1672,101 @@ mod tests { /// On hosts without a recorder it must fail gracefully instead. #[test] fn voice_command_toggles_on_and_off_or_fails_gracefully() { + use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; + struct VoiceState { + enabled: bool, + available: bool, + probes: std::cell::Cell, + } + impl codewhale_command_contract::navigation::CommandVoiceInputContext for VoiceState { + fn recorder_available(&self) -> bool { + self.probes.set(self.probes.get() + 1); + self.available + } + fn voice_enabled(&self) -> bool { + self.enabled + } + fn set_voice_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + fn send_enabled(&self) -> bool { + unreachable!("voice must not read send state") + } + fn set_send_enabled(&mut self, _: bool) { + unreachable!("voice must not set send state") + } + fn control_enabled(&self) -> bool { + unreachable!("voice must not read control state") + } + fn set_control_enabled(&mut self, _: bool) { + unreachable!("voice must not set control state") + } + } + struct Labels { + fail: bool, + } + impl codewhale_command_contract::facets::CommandPresentationContext for Labels { + fn translate(&self, key: &str, _: &[(&str, &str)]) -> Result { + if self.fail { + return Err("missing fixture template".into()); + } + match key { + "voice_enabled" => Ok("Voice enabled".into()), + "voice_disabled" => Ok("Voice disabled".into()), + "voice_err_no_recorder" => Ok("Recorder unavailable".into()), + _ => Err("unexpected voice label".into()), + } + } + } + let CommandHandler::Contextual { handler, .. } = registry() + .get("voice") + .unwrap() + .contextual_handler() + .unwrap() + else { + panic!("voice must be contextual"); + }; + for available in [false, true] { + let mut voice = VoiceState { + enabled: false, + available, + probes: std::cell::Cell::new(0), + }; + let mut labels = Labels { fail: false }; + let on = handler( + CommandContexts::empty() + .with_voice_input(&mut voice) + .with_presentation(&mut labels), + None, + ); + assert_eq!(on.is_error, !available); + assert_eq!(voice.enabled, available); + assert_eq!(on.action, available.then_some(AppAction::VoiceCapture)); + assert_eq!(voice.probes.get(), 1); + voice.enabled = true; + voice.available = false; + let off = handler( + CommandContexts::empty() + .with_voice_input(&mut voice) + .with_presentation(&mut labels), + None, + ); + assert!(!off.is_error && off.action.is_none() && !voice.enabled); + assert_eq!( + voice.probes.get(), + 1, + "voice-off must not probe recorder availability" + ); + voice.available = true; + labels.fail = true; + let failed = handler( + CommandContexts::empty() + .with_voice_input(&mut voice) + .with_presentation(&mut labels), + None, + ); + assert!(failed.is_error && failed.action.is_none() && !voice.enabled); + } let mut app = create_test_app(); let result = execute("/voice", &mut app); if app.voice_enabled { @@ -2326,6 +2453,9 @@ mod tests { // register bounded portable groups/slices; every entry outside the // explicit list must still use the original legacy dispatcher. const MIGRATED_GROUPS: &[&str] = &[ + "voice", + "voicesend", + "voicecontrol", "translate", "anchor", "workspace", diff --git a/crates/tui/src/commands/session_export_test_support.rs b/crates/tui/src/commands/session_export_test_support.rs index 954ef0d919..e669c39b1c 100644 --- a/crates/tui/src/commands/session_export_test_support.rs +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -127,6 +127,7 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { login, work_surface, navigation, + voice_input, } = parts; assert!( permissions.is_none() @@ -137,6 +138,7 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { && login.is_none() && work_surface.is_none() && navigation.is_none() + && voice_input.is_none() ); assert!(export.is_some(), "the export facet must be exposed"); diff --git a/crates/tui/src/commands/session_structcopy_host_tests.rs b/crates/tui/src/commands/session_structcopy_host_tests.rs index 2b71465d8b..2822640882 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -374,6 +374,7 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross login, work_surface, navigation, + voice_input, } = bundle.contexts(capabilities).into_parts(); assert!( permissions.is_none() @@ -384,6 +385,7 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross && login.is_none() && work_surface.is_none() && navigation.is_none() + && voice_input.is_none() ); assert!(presentation.is_some()); assert!( diff --git a/scripts/check-command-core-navigation-proof.py b/scripts/check-command-core-navigation-proof.py new file mode 100644 index 0000000000..59bf42e469 --- /dev/null +++ b/scripts/check-command-core-navigation-proof.py @@ -0,0 +1,148 @@ +from pathlib import Path +import argparse +import re +import tomllib + +ROOT = Path(__file__).resolve().parent.parent +GROUP = "crates/tui/src/commands/groups/core" +ROOTS = (f"{GROUP}/navigation.rs",) +PROOF = "tests/portable-core-navigation/src/lib.rs" +MANIFEST = "tests/portable-core-navigation/Cargo.toml" +HOST = f"{GROUP}/mod.rs" +PAIRS = ( + ("anchor", "AnchorCmd"), ("help", "HelpCmd"), ("hotbar", "HotbarCmd"), + ("links", "LinksCmd"), ("transcript", "TranscriptCmd"), ("turn", "TurnCmd"), + ("home", "HomeCmd"), ("home", "OverviewCmd"), ("workspace", "WorkspaceCmd"), + ("translate", "TranslateCmd"), ("voice", "VoiceCmd"), + ("voice", "VoiceSendCmd"), ("voice", "VoiceControlCmd"), +) +NAMES = { + "anchor": ("anchor",), "help": ("help",), "hotbar": ("hotbar",), + "links": ("links",), "transcript": ("transcript",), "turn": ("turn",), + "home": ("home", "overview"), "workspace": ("workspace",), + "translate": ("translate",), "voice": ("voice", "voicesend", "voicecontrol"), +} +POSITIONS = (0, 2, 20, 24, 25, 26, 29, 30, 31, 34, 35, 37, 38) +REQUIRED = {ROOTS[0]} | {f"{GROUP}/{name}.rs" for name in NAMES} +ALLOWED_NORMAL = { + "codewhale-portable-core-navigation", "codewhale-command-contract", "codewhale-protocol", + "chrono", "iana-time-zone", "android_system_properties", "libc", "core-foundation-sys", + "iana-time-zone-haiku", "js-sys", "cfg-if", "futures-util", "futures-core", "futures-task", + "pin-project-lite", "slab", "wasm-bindgen", "once_cell", "wasm-bindgen-macro", "quote", + "proc-macro2", "unicode-ident", "wasm-bindgen-macro-support", "bumpalo", "syn", + "wasm-bindgen-shared", "log", "windows-core", "windows-implement", "windows-interface", + "windows-link", "windows-result", "windows-strings", "num-traits", "serde", "serde_core", + "serde_derive", "serde_json", "indexmap", "equivalent", "hashbrown", "itoa", "memchr", + "zmij", "uuid", "getrandom", "r-efi", +} +HOST_IMPORT = re.compile( + r"\b(?:App|AppAction|codewhale_(?:tui|core|runtime|config|execpolicy|secrets|state|localization))\b" + r"|\bcrate::(?:tui|core|config|commands)\b|\bsuper::core\b|\bBox\s*<" + r"|\bstd::(?:fs|net|process|env)\b|\binclude(?:_str|_bytes)?\s*!" +) +MODULE = re.compile(r'(?:#\[path\s*=\s*"([^"]+)"\]\s*)?(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;') +PAIR = re.compile(r'\(\s*(\w+)::(\w+)::info\(\),\s*\1::\2::handler\(\),?\s*\)') + + +def source_text(path): + return "\n".join(line.split("//")[0] for line in path.read_text().splitlines()) + + +def conditional_source(text): + without_test_module = re.sub(r'#\[cfg\(test\)\]\s*mod\s+tests\s*\{', 'mod tests {', text) + return bool(re.search(r'#\s*\[\s*cfg(?:_attr)?\b', without_test_module)) + + +def graph_violations(graph): + names = {line.split()[0] for line in graph.splitlines() if line.strip()} + errors = [f"unapproved normal dependency: {name}" for name in sorted(names - ALLOWED_NORMAL)] + for required in ("codewhale-portable-core-navigation", "codewhale-command-contract", "codewhale-protocol"): + if required not in names: + errors.append(f"missing actual normal graph package: {required}") + return errors + + +def source_closure(root): + pending = [root / name for name in ROOTS] + seen, errors = set(), [] + while pending: + path = pending.pop().resolve() + if path in seen: + continue + if not path.is_relative_to(root) or not path.is_file(): + errors.append(f"missing or out-of-tree navigation source: {path}") + continue + seen.add(path) + text = source_text(path) + if HOST_IMPORT.search(text): + errors.append(f"host/service in portable navigation source: {path.relative_to(root)}") + if conditional_source(text): + errors.append(f"conditional production source is not a normal proof: {path.relative_to(root)}") + for explicit, name in MODULE.findall(text): + if explicit: + child = path.parent / explicit + else: + base = path.parent if path.name in {"mod.rs", "lib.rs"} else path.with_suffix("") + child = base / f"{name}.rs" + if not child.is_file(): + child = base / name / "mod.rs" + pending.append(child) + return {str(path.relative_to(root)) for path in seen}, errors + + +def violations(root): + root = root.resolve() + errors = [] + wrapper = root / PROOF + mount = re.fullmatch(r'\s*#\[path\s*=\s*"([^"]+)"\]\s*pub mod navigation;\s*', wrapper.read_text()) + if not mount or (wrapper.parent / mount.group(1)).resolve() != (root / ROOTS[0]).resolve(): + errors.append("proof must mount only the actual complete production navigation root normally") + manifest = tomllib.loads((root / MANIFEST).read_text()) + if manifest.get("package", {}).get("publish") is not False: + errors.append("proof must remain unpublished") + deps = manifest.get("dependencies", {}) + contract = deps.get("codewhale-command-contract", {}) + if set(deps) != {"codewhale-command-contract"} or not isinstance(contract, dict) or (root / MANIFEST).parent.joinpath(contract.get("path", "")).resolve() != (root / "crates/command-contract").resolve(): + errors.append("actual shared contract must be the sole normal direct dependency") + if manifest.get("dev-dependencies") or manifest.get("build-dependencies") or manifest.get("target"): + errors.append("proof cannot hide a host or substitute behind test/build/target dependencies") + closure, source_errors = source_closure(root) + errors.extend(source_errors) + for name in sorted(REQUIRED - closure): + errors.append(f"incomplete navigation source closure: {name}") + for name in sorted(REQUIRED): + path = root / name + if path.is_file(): + text = source_text(path) + if HOST_IMPORT.search(text): + errors.append(f"host/service in declared movable source: {name}") + if conditional_source(text): + errors.append(f"conditional declared movable source: {name}") + inventory = source_text(root / ROOTS[0]) + if tuple(PAIR.findall(inventory)) != PAIRS or not re.search(r';\s*13\s*\]', inventory): + errors.append("production inventory must contain all13 real matching registrations in original filtered order") + for module, expected in NAMES.items(): + path = root / GROUP / f"{module}.rs" + if path.is_file() and tuple(re.findall(r'\bname:\s*"([^"]+)"', source_text(path))) != expected: + errors.append(f"canonical metadata mismatch: {module}") + entries = list(re.finditer(r'core_navigation_host::registration::<(\d+)>\(\)|Box::new\(FunctionCommand::new\(', source_text(root / HOST))) + actual = [(position, int(entry.group(1))) for position, entry in enumerate(entries) if entry.group(1) is not None] + if len(entries) != 40 or actual != list(zip(POSITIONS, range(13))): + errors.append("real host core registry must interleave all13 inventory entries at their original positions") + return errors + + +def main(): + parser = argparse.ArgumentParser(description="Verify the real complete core-navigation slice and its all-target normal graph") + parser.add_argument("--graph", type=Path, required=True) + args = parser.parse_args() + errors = violations(ROOT) + graph_violations(args.graph.read_text()) + for error in errors: + print(f"[core-navigation-proof] FAIL: {error}") + if not errors: + print("[core-navigation-proof] PASS: all13 actual registrations, complete source closure, host parity and approved normal graph; core remains pending") + return bool(errors) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-portable-core-navigation.sh b/scripts/check-portable-core-navigation.sh new file mode 100644 index 0000000000..7968a8136d --- /dev/null +++ b/scripts/check-portable-core-navigation.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +graph=$(mktemp) +trap 'rm -f "$graph"' EXIT HUP INT TERM +cargo tree --locked -p codewhale-portable-core-navigation --edges normal --all-features --target all --prefix none > "$graph" +cat "$graph" +python3 -B scripts/check-command-core-navigation-proof.py --graph "$graph" +env CARGO_INCREMENTAL=0 RUSTFLAGS=-Dwarnings cargo check --locked -p codewhale-portable-core-navigation --lib --no-default-features +env CARGO_TERM_COLOR=always CARGO_INCREMENTAL=0 RUSTFLAGS=-Dwarnings RUST_MIN_STACK=16777216 CODEWHALE_EXT_HOST_TESTS=1 CODEWHALE_EXT_HOST_BUN_TESTS=1 sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-portable-core-navigation --lib --all-features --locked --profile ci --no-tests=fail diff --git a/scripts/dev-test.sh b/scripts/dev-test.sh index c0d105724d..c2200e6c22 100755 --- a/scripts/dev-test.sh +++ b/scripts/dev-test.sh @@ -53,6 +53,7 @@ lane cargo test -p codewhale-lane --lib --locked mcp cargo test -p codewhale-mcp --lib --locked paths cargo test -p codewhale-paths --lib --locked portable-config cargo test -p codewhale-portable-config --lib --locked +portable-core-navigation cargo test -p codewhale-portable-core-navigation --lib --locked protocol cargo test -p codewhale-protocol --lib --locked release cargo test -p codewhale-release --lib --locked runtime cargo test -p codewhale-runtime --lib --locked @@ -114,6 +115,12 @@ if [ -e "$area" ] || printf '%s' "$area" | grep -q /; then tests/portable-config/*|tests/portable-config) area=portable-config ;; + tests/portable-core-navigation/*|tests/portable-core-navigation|crates/tui/src/commands/groups/core/navigation.rs|crates/tui/src/commands/groups/core/anchor.rs|crates/tui/src/commands/groups/core/help.rs|crates/tui/src/commands/groups/core/home.rs|crates/tui/src/commands/groups/core/hotbar.rs|crates/tui/src/commands/groups/core/links.rs|crates/tui/src/commands/groups/core/transcript.rs|crates/tui/src/commands/groups/core/translate.rs|crates/tui/src/commands/groups/core/turn.rs|crates/tui/src/commands/groups/core/voice.rs|crates/tui/src/commands/groups/core/workspace.rs) + area=portable-core-navigation + ;; + crates/command-contract/src/navigation.rs) + area=command-contract + ;; crates/tui/tests/integration/*) area=tui-integration extra=$(basename "$rel" .rs) @@ -188,6 +195,7 @@ case $area in mcp) pkg=codewhale-mcp ;; paths) pkg=codewhale-paths ;; portable-config) pkg=codewhale-portable-config ;; + portable-core-navigation) pkg=codewhale-portable-core-navigation ;; protocol) pkg=codewhale-protocol ;; release) pkg=codewhale-release ;; runtime) pkg=codewhale-runtime ;; diff --git a/scripts/test_check_command_core_navigation_proof.py b/scripts/test_check_command_core_navigation_proof.py new file mode 100644 index 0000000000..49ce53019f --- /dev/null +++ b/scripts/test_check_command_core_navigation_proof.py @@ -0,0 +1,103 @@ +import importlib.util +from pathlib import Path +import re +import tempfile +import unittest + +path = Path(__file__).with_name("check-command-core-navigation-proof.py") +spec = importlib.util.spec_from_file_location("navigation_proof", path) +proof = importlib.util.module_from_spec(spec) +spec.loader.exec_module(proof) + + +class NavigationProofTests(unittest.TestCase): + def fixture(self, root): + for relative in proof.REQUIRED | {proof.PROOF, proof.MANIFEST, proof.HOST}: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text((proof.ROOT / relative).read_text()) + + def test_actual_complete_group_is_included(self): + self.assertEqual(proof.violations(proof.ROOT), []) + + def test_selected_leaf_stub_and_test_only_root_are_rejected(self): + actual = (proof.ROOT / proof.PROOF).read_text() + for source in [ + "pub mod navigation {}", + actual.replace("navigation.rs", "hotbar.rs"), + "#[cfg(test)]\n" + actual, + actual + "struct App;\n", + ]: + with self.subTest(source=source), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + (root / proof.PROOF).write_text(source) + self.assertTrue(proof.violations(root)) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + source = root / proof.ROOTS[0] + source.write_text("#[cfg(test)]\n" + source.read_text()) + self.assertTrue(any("conditional" in error for error in proof.violations(root))) + + def test_omitted_root_or_helper_is_rejected(self): + for case in ["anchor", "voice", "inventory", "host_order", "metadata", "include"]: + with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + source = root / proof.ROOTS[0] + if case in {"anchor", "voice"}: + source.write_text(re.sub(r'#\[path = "' + case + r'\.rs"\]\s*pub mod ' + case + r';', '', source.read_text())) + elif case == "inventory": + source.write_text(source.read_text().replace("(anchor::AnchorCmd::info(), anchor::AnchorCmd::handler()),", "")) + elif case == "host_order": + host = root / proof.HOST + host.write_text(host.read_text().replace("registration::<0>()", "registration::<99>()")) + elif case == "include": + source.write_text(source.read_text() + '\ninclude!("untracked-helper.rs");\n') + else: + leaf = root / proof.GROUP / "anchor.rs" + leaf.write_text(leaf.read_text().replace('name: "anchor"', 'name: "wrong"')) + self.assertTrue(proof.violations(root)) + + def test_host_import_in_any_mounted_file_is_rejected(self): + for name in sorted(proof.REQUIRED): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + leaf = root / name + leaf.write_text(leaf.read_text() + "\nuse crate::tui::app::App;\n") + self.assertTrue(proof.violations(root)) + + def test_host_import_in_unmounted_group_file_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + source = root / proof.ROOTS[0] + source.write_text(re.sub(r'#\[path = "voice\.rs"\]\s*pub mod voice;', '', source.read_text())) + leaf = root / proof.GROUP / "voice.rs" + leaf.write_text(leaf.read_text() + "\nuse codewhale_runtime::Runtime;\n") + errors = proof.violations(root) + self.assertTrue(any("host/service in declared movable source" in error for error in errors)) + + def test_shapes_cannot_move_to_dev_dependencies(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + manifest = root / proof.MANIFEST + manifest.write_text(manifest.read_text().replace("[dependencies]", "[dev-dependencies]")) + self.assertTrue(proof.violations(root)) + + def test_direct_and_transitive_runtime_or_storage_edges_are_rejected(self): + allowed = "codewhale-portable-core-navigation v0.10.2\ncodewhale-command-contract v0.10.2\ncodewhale-protocol v0.10.2\nserde v1.0.0\n" + self.assertEqual(proof.graph_violations(allowed), []) + for package in ["codewhale-tui", "codewhale-core", "codewhale-runtime", "codewhale-config", + "codewhale-secrets", "codewhale-state", "codewhale-localization", "tokio", + "reqwest", "rusqlite", "keyring", "ratatui", "crossterm", "unreviewed-helper"]: + with self.subTest(package=package): + self.assertEqual(len(proof.graph_violations(allowed + f"{package} v1.0.0\n")), 1) + self.assertTrue(proof.graph_violations("serde v1.0.0\n")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/portable-core-navigation/Cargo.toml b/tests/portable-core-navigation/Cargo.toml new file mode 100644 index 0000000000..63f2130472 --- /dev/null +++ b/tests/portable-core-navigation/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "codewhale-portable-core-navigation" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +codewhale-command-contract = { path = "../../crates/command-contract", version = "0.10.2" } + +[lints] +workspace = true diff --git a/tests/portable-core-navigation/README.md b/tests/portable-core-navigation/README.md new file mode 100644 index 0000000000..46100387e5 --- /dev/null +++ b/tests/portable-core-navigation/README.md @@ -0,0 +1,33 @@ +# Core navigation normal-library proof + +This unpublished workspace package mounts the **actual production navigation +root**, all ten leaf modules and all13 registrations. It contains no substitute +App, copied implementation, test-only root or host adapter. Shared metadata, +results/actions, facets and nested payloads are normal dependencies. + +Run sequentially from the repository root: + +```sh +python3 -B scripts/test_check_command_core_navigation_proof.py +sh scripts/check-portable-core-navigation.sh +``` + +The proof checks the declared complete source closure and real host inventory +positions, an all-feature/all-target **normal** dependency graph, a no-default +normal-library build, and nonzero tests from the actual portable leaves. +Rejection controls cover omitted/reordered registrations, metadata drift, +selected-leaf/stub/test-only wrappers, conditional production modules and +unreviewed include macros, host imports (even in an omitted owned leaf), test-only +dependency substitution and unapproved transitive packages. + +The approved graph is deliberately explicit: command-contract/protocol data; +serde/JSON/indexed containers and their derive/parser support; chrono/UUID and +existing platform/timezone/random primitives, including their wasm/Windows +support. These are inherited shared data dependencies, not permission to add +TUI, application runtime, configuration, HTTP, terminal or credential-storage +services. New normal packages require source review, not automatic approval. + +Host registration, concrete filesystem/view/config effects, real-App tests and +voice recording/transcription remain outside the movable slice. The rest of core +is not claimed portable by this package: the top-level `core` frontier remains +pending for its other27 registrations. Physical movement is a later feature. diff --git a/tests/portable-core-navigation/src/lib.rs b/tests/portable-core-navigation/src/lib.rs new file mode 100644 index 0000000000..99b97d5274 --- /dev/null +++ b/tests/portable-core-navigation/src/lib.rs @@ -0,0 +1,2 @@ +#[path = "../../../crates/tui/src/commands/groups/core/navigation.rs"] +pub mod navigation; From a0a9f2541f4aca586a8f3c9500729d81e07a6ca3 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sat, 17 Oct 2026 05:42:56 +0200 Subject: [PATCH 5/9] test(commands): strengthen navigation preservation assertions FEAT-031 P5 extends existing host tests for user help precedence/alias shadowing, one-dismissal help behavior, home-directory routes and exact anchor read/open/partial-write/replacement failures. Production code and all original test identities remain unchanged. Verified:1289 commands/help/voice tests pass after correcting the fixture's comma-separated alias syntax;58 native/PTY acceptance tests pass against rebuilt current CLI. Contract79, navigation9+7 controls, config35+7, session65+5, boundary30/migration63 and locale/format checks pass. Numeric final coverage remains pending. Refs #5316. --- .../commands/core_navigation_host_tests.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/crates/tui/src/commands/core_navigation_host_tests.rs b/crates/tui/src/commands/core_navigation_host_tests.rs index 2609bcf6e0..33b6d12c67 100644 --- a/crates/tui/src/commands/core_navigation_host_tests.rs +++ b/crates/tui/src/commands/core_navigation_host_tests.rs @@ -123,6 +123,36 @@ fn test_help_links_topic_shows_aliases() { assert!(msg.contains("Show Codewhale, community, and provider links")); assert!(msg.contains("Usage: /links")); assert!(msg.contains("Aliases: dashboard, api")); + + let root = tempdir().unwrap(); + crate::test_support::trust_workspace(root.path()); + let directory = root.path().join(".codewhale/commands"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("custom-links.md"), "---\ndescription: User link helper\nusage: /custom-links \naliases: api\n---\nuser link $ARGUMENTS").unwrap(); + let mut app = crate::tui::test_support::test_app_with_options( + crate::tui::test_support::test_tui_options(root.path()), + ); + app.ui_locale = Locale::En; + app.cached_skills = vec![ + ("custom-links".into(), "Wrong skill fallback".into()), + ("links".into(), "Wrong builtin fallback".into()), + ]; + crate::commands::user_registry::reload(Some(root.path())); + for topic in ["custom-links", "api"] { + let result = help(&mut app, Some(topic)); + assert!(!result.is_error); + let text = result.message.unwrap(); + assert!(text.starts_with("custom-links\n\n")); + assert!(text.contains("User link helper")); + assert!(text.contains("Usage: /custom-links ")); + assert!(text.contains("Aliases: api"), "{text}"); + assert!(!text.contains("Wrong skill fallback")); + } + let text = help(&mut app, Some("links")).message.unwrap(); + assert!(text.starts_with("links\n\n")); + assert!(text.contains("Aliases: dashboard, lianjie")); + assert!(!text.contains("Aliases: dashboard, api")); + assert!(!text.contains("Wrong builtin fallback")); } #[test] @@ -150,10 +180,16 @@ fn test_help_pushes_overlay() { #[test] fn test_help_does_not_duplicate_overlay() { let mut app = create_test_app(); + assert!(app.view_stack.is_empty()); help(&mut app, None); let initial_kind = app.view_stack.top_kind(); help(&mut app, None); assert_eq!(app.view_stack.top_kind(), initial_kind); + assert!(app.view_stack.pop().is_some()); + assert!( + app.view_stack.is_empty(), + "one dismissal must remove the only help modal" + ); } #[test] @@ -427,6 +463,17 @@ fn workspace_without_arg_shows_current_workspace() { assert!(msg.contains("Current workspace:")); assert!(msg.contains("/tmp/test-workspace")); assert!(result.action.is_none()); + let home = crate::config::effective_home_dir().expect("hermetic home"); + let result = workspace_switch(&mut app, Some("~")); + assert!( + matches!(result.action, Some(AppAction::SwitchWorkspace { workspace }) if workspace == home.canonicalize().unwrap_or(home.clone())) + ); + let child = tempfile::tempdir_in(&home).unwrap(); + let input = format!("~/{}", child.path().file_name().unwrap().to_str().unwrap()); + let result = workspace_switch(&mut app, Some(&input)); + assert!( + matches!(result.action, Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.path().canonicalize().unwrap()) + ); } #[test] @@ -489,6 +536,73 @@ mod anchor { crate::commands::execute(&input, app) } + enum AnchorFault { + Read, + Open, + Write, + Replace, + } + + impl codewhale_command_contract::facets::CommandWorkspaceContext for AnchorFault { + fn workspace(&self) -> std::path::PathBuf { + unreachable!("unexpected workspace observation") + } + fn work_state_snapshot(&self) -> Result, String> { + unreachable!("unexpected work observation") + } + fn operation_digest(&mut self) -> Result { + unreachable!("unexpected work observation") + } + fn effective_home_dir(&self) -> Option { + unreachable!("unexpected home observation") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("unexpected path inspection") + } + fn read_anchor_document(&self) -> Result, String> { + match self { + Self::Read => Err("read denied".into()), + Self::Replace => Ok(Some("first\n---\nsecond".into())), + _ => unreachable!("append must not read the document"), + } + } + fn append_anchor_bytes( + &mut self, + parts: &[&[u8]], + ) -> Result + { + use codewhale_command_contract::navigation::AnchorWriteError; + let expected: &[&[u8]] = &[b"\n---\n", b"entry", b"\n"]; + assert_eq!(parts, expected); + match self { + Self::Open => Err(AnchorWriteError::Open("open denied".into())), + Self::Write => Err(AnchorWriteError::Write("partial write".into())), + _ => unreachable!("unexpected append"), + } + } + fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String> { + assert!(matches!(self, Self::Replace)); + assert_eq!(bytes, b"second"); + Err("replace denied".into()) + } + } + + fn fault_result(fault: &mut AnchorFault, arg: &str) -> CommandResult { + use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; + let CommandHandler::Contextual { handler, .. } = crate::commands::registry() + .get("anchor") + .unwrap() + .contextual_handler() + .unwrap() + else { + panic!("anchor must be contextual"); + }; + handler(CommandContexts::empty().with_workspace(fault), Some(arg)) + } + fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { let options = TuiOptions { skills_dir: tmpdir.path().join("skills"), @@ -605,6 +719,20 @@ mod anchor { assert!(path.exists()); let content = std::fs::read_to_string(&path).unwrap(); assert!(content.contains("API status field is unreliable")); + for (mut fault, expected) in [ + ( + AnchorFault::Open, + "Error: Failed to open anchors file: open denied", + ), + ( + AnchorFault::Write, + "Error: Failed to write anchor: partial write", + ), + ] { + let result = fault_result(&mut fault, "entry"); + assert!(result.is_error && result.action.is_none()); + assert_eq!(result.message.as_deref(), Some(expected)); + } } #[test] @@ -614,6 +742,12 @@ mod anchor { let result = anchor(&mut app, Some("list")); assert!(!result.is_error); assert!(result.message.unwrap().contains("No anchors set")); + let result = fault_result(&mut AnchorFault::Read, "list"); + assert!(result.is_error && result.action.is_none()); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to read anchors file: read denied") + ); } #[test] @@ -646,6 +780,12 @@ mod anchor { assert!(msg.contains("1 total")); assert!(msg.contains("Second anchor")); assert!(!msg.contains("First anchor")); + let result = fault_result(&mut AnchorFault::Replace, "remove 1"); + assert!(result.is_error && result.action.is_none()); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to write anchors file: replace denied") + ); } #[test] From d22dfbba91863bedd85e4489b7f3369e4e3f9a7b Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sat, 17 Oct 2026 07:04:38 +0200 Subject: [PATCH 6/9] docs(web): refresh generated agent docs from current main --- web/data/agent-docs.generated.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/data/agent-docs.generated.json b/web/data/agent-docs.generated.json index 0604ab49ac..8795f5fc66 100644 --- a/web/data/agent-docs.generated.json +++ b/web/data/agent-docs.generated.json @@ -32,8 +32,8 @@ "sources": [ { "path": "docs/CONFIGURATION.md", - "sha256": "72303118597855d7866b8c9c998c4d48106850868d17883c1ea7f58dcde4adf3", - "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" + "sha256": "f61542d1de3cd954591e3e4d686a1639d1327016c9e26cf9b92cefedcc5501a3", + "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nA custom table sends Chat Completions requests by default. Add\n`wire = \"responses\"` to send OpenAI Responses requests to\n`/responses` instead, or `wire = \"anthropic\"` for Anthropic Messages:\n\n```toml\n[providers.my-gateway]\nkind = \"openai-compatible\"\nbase_url = \"https://gateway.example/v1\"\napi_key_env = \"MY_GATEWAY_API_KEY\"\nmodel = \"gpt-5.6\"\nwire = \"responses\"\n```\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" }, { "path": "docs/CODEWHALE_AGENT.md", @@ -48,8 +48,8 @@ "sources": [ { "path": "docs/PROVIDERS.md", - "sha256": "b19f7548ef10d21beddd4a5fcbc861c125d2ed3701b917a653536326383b23bf", - "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nStandard Compute offers flat monthly plans with a compute budget. By default,\nrequests return HTTP 402 when it is used; eligible plans can enable Pay as you go\nto continue with additional prepaid charges. See its\n[budget policy](https://docs.standardcompute.com/budgets-and-limits).\nThe `standardcompute` model id routes each request across models and providers;\nvendor-prefixed ids such as `deepseek/deepseek-v4-pro` pin one model. The\nauthority is `GET https://api.stdcmpt.com/v1/models`.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.
].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `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`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `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. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/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. |\n| `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. |\n| `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. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `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. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `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. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `arcee` | `trinity-large-thinking`, `trinity-large-preview`; provider-hinted custom model IDs pass through | yes | yes for `trinity-large-thinking`; no for `trinity-large-preview` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `zai` | `GLM-5.3`, `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nThis is an experimental 0.10.2 candidate flow; live sign-in, inference and\nprovider authorization are not release-qualified.\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale inference-client identity. The login\nregistration is a separate fact: this candidate uses Claude Code’s OAuth\nclient ID, not a Codewhale-issued registration. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nReference refreshed on 2026-10-13: upstream Pi is now `earendil-works/pi`.\nIts [v1.2.0 OAuth adapter](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/auth/oauth/anthropic.ts)\nsupports loopback and copy-code flows and uses `platform.claude.com` token and\ncopy-code endpoints; this candidate still uses `console.anthropic.com`.\nPi also uses [Claude Code inference identity](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/api/anthropic-messages.ts),\nwhich Codewhale does not adopt. Pi's implementation is a compatibility\nreference, not proof of account eligibility, billing behavior or permission\nto offer the same flow. See Anthropic's current\n[authentication guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)\nand [Agent SDK plan guidance](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan).\n" + "sha256": "aef170b600dcdc4b461fe7214072a1b3caa313b714f894461ba894e3b0124d99", + "content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Offerings come from live `GET /v1/models`\nplus the Codewhale catalog rather than a compiled roster (#5350, #6289).\n\nKnown-good hosts. These ship as bundled descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model ids stay live from `GET /v1/models`\nand the Codewhale catalog; the example model is only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n\nAICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,\nMiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is\n`GET https://aicraftapi.com/v1/models` with your key — pick a model from that\nlist, not from this table.\nTsubasa implements only `GET /v1/models` and Chat Completions. Its two public\nmodel ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes\nfor an unknown model, so set it on the route after saving:\n`codewhale config set providers.tsubasa.context_window 32768`.\n\nCheaper Inference is an OpenAI-compatible gateway with one key for models from\nseveral labs. Model ids are bare, such as `gpt-5.4-mini` or `claude-sonnet-5`.\nPricing varies by model and route; consult the provider’s current catalog.\nThe authority is `GET https://api.cheaperinference.com/v1/models` with your key.\nDocs: .\n\nStandard Compute offers flat monthly plans with a compute budget. By default,\nrequests return HTTP 402 when it is used; eligible plans can enable Pay as you go\nto continue with additional prepaid charges. See its\n[budget policy](https://docs.standardcompute.com/budgets-and-limits).\nThe `standardcompute` model id routes each request across models and providers;\nvendor-prefixed ids such as `deepseek/deepseek-v4-pro` pin one model. The\nauthority is `GET https://api.stdcmpt.com/v1/models`.\nDocs: .\n\nOpenCode Zen and OpenCode Go are first-class provider routes, configured like\nany other provider below; they are not part of this table. In `/provider`,\ntype to filter the list (letters not bound to a row action); `Ctrl+T` probes the\nselected row's `/models` and records reachability only (a 2xx is not\nmodel-ready).\n\nSources to keep in sync:\n\n- `crates/config/assets/provider_descriptors.json` - built-in and compatible-host\n labels, defaults, aliases, key-env lists, config/secret slots, and credential\n guidance. `crates/config/build.rs` generates immutable typed views and the\n existing constant projections from this one data owner.\n- `crates/config/src/provider_kind.rs` and `src/lib.rs` - legacy identity serde,\n config schema, environment precedence and Rust route/auth behavior.\n- `crates/tui/src/config.rs` - captures and verifies exact configured provider\n identities and keeps provider-specific credential and route policy in Rust.\n- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,\n scoped selector aliases, completion references and pure transport metadata.\n The existing seed renderer embeds this reviewed supplement in Models.dev.\n- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current\n model facts for both CLI model commands, the picker and Runtime API. Public\n catalog facts, provider/account rosters and explicit configuration retain\n their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector\n compatibility projection; it does not decide the CLI's current route.\n- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config\n examples and environment variable reference.\n- `scripts/check-provider-registry.py` - drift check for canonical provider\n IDs, live TUI provider IDs, TOML table names, static registry rows, and\n documented defaults.\n\n## Captured Provider Identity\n\nPresentation names, labels, aliases, and historical wire tags come from\n`provider_descriptors.json`. `ProviderKind` remains the Rust-owned intrinsic\ncredential, protocol, and region distinction. A configured route captures both\nits kind and exact table key; a custom table named `openai` stays custom and\ndoes not acquire OpenAI credentials or protocol rules from its name. Case is\nsignificant for custom keys.\n\nDeepSeek China has three preserved representations: `deepseek_c_n` in released\nTUI serde wrappers, `deepseek-cn` as its captured route ID, and `deepseek_cn` as\nits config leaf. Its intrinsic kind is DeepSeek, while its table and endpoint\nstay distinct.\n\nAn old custom route without an additive provider ID can resume only with a\nverified root `base_url` migration into the active `providers.custom` table.\nThat private receipt is bound to the parsed table generation. A table-only,\nprofile-only, conflicting, or later replaced table cannot establish the\nmissing identity; an explicit empty ID is refused. Writes reuse the existing\nlocked config mutation and undo comparison, obtain the fresh migration receipt\nunder that lock, and verify the captured table before changing a leaf.\n\nIn-flight requests retain their captured identity, endpoint, and credential\ngeneration. Health lookup cannot treat an opaque credential reference as ready\nmerely because an earlier request used the same authentication class.\n\n## Provider Selection\n\nWith no saved model, no `default_text_model`, and no `CODEWHALE_MODEL` or\nprovider-specific model variable, a fresh install runs `deepseek-flash` on the\nDeepSeek provider. Precedence is the active provider's configured default, then\nits catalog default — so an explicit `default_text_model` is not silently\noverridden by whichever model the shipped catalog lists first.\n\nRefresh model catalogs without installing a new Codewhale release:\n\n```sh\ncodewhale models --update\ncodewhale models --update --provider openai\ncodewhale models --provider openai --json\n```\n\n`models --update` (also `--refresh`) updates the shared Models.dev metadata\nand calls the existing `/models` endpoint for each configured provider with\nits own credentials. `--provider ID` restricts the refresh to that exact\nprovider, including named custom endpoints. It makes no inference requests\nand never changes the saved provider or model. A command-line API key is\nconfined to the active provider; other routes are reported as skipped for\nthat invocation.\n\nPlain `models` lists the active provider's saved catalog without provider requests.\nFor ChatGPT, it validates the selected registration locally before reading\nthat account's saved roster. Successful refreshes are saved under Codewhale's\ncatalog directory and used by the model/provider pickers. Cache files are\nscoped to provider identity and endpoint; a failed refresh preserves prior\nrows. Text output reports source, last successful fetch time (Unix seconds),\nand freshness. `--update --json` adds per-source receipts and aggregate counts;\npartial failures return a nonzero exit code after writing those receipts.\nOrdinary `models --json` keeps its model-array format. Bundled/configured\nfallbacks are not proof that an account can use every listed model.\n\n### Sign in with ChatGPT\n\nThe `openai-codex` provider ID now uses OpenAI's official open-source\n[Sign in with ChatGPT flow](https://developers.openai.com/siwc/token-sharing-open-source).\nSign in and refresh the selected account's catalog:\n\n```sh\ncodewhale auth chatgpt\ncodewhale models --update --provider openai-codex\ncodewhale --provider openai-codex\n```\n\nSign-in opens your system browser, uses a loopback callback and PKCE, validates\nthe returned identity, and stores the issued registration and renewable tokens\nin Codewhale's protected credential storage. Permission to use your ChatGPT\nplan is separate from identity sign-in. A declined or missing plan grant stops\ninference; choose another provider explicitly if you want another billing path.\n`codewhale auth chatgpt-revoke` signs out of the Codewhale-owned session.\nThis experimental implementation retains one active registration. A picker\nfor multiple saved ChatGPT accounts is not implemented yet.\n\nModel discovery uses your granted bearer token at\n`GET https://api.openai.com/v1/models`; inference uses the public\n`POST https://api.openai.com/v1/responses` endpoint. The model picker keeps\nOpenAI's display labels and order and lists only entries with `visibility: list`.\nThe catalog contains no credentials and is bound to the verified issuer,\nissued client ID, and account subject. Another account or workspace never\ninherits those model choices. Missing, invalid, or older-than-one-day rosters\nare reported explicitly and provide no account entitlement evidence. Run the\nrefresh command after signing in. Neither model discovery nor ordinary listing\nstarts an inference request. Imported Codex CLI tokens and legacy process-token\nvariables cannot authorize this official plan route.\n\nEligible requests consume your ChatGPT plan or credits. ChatGPT Plus's\nfive-hour allowance is shared across apps; each app receives no separate\nallowance. The documented five-hour limit does not apply to Pro. App-specific\nlimits can also apply. Review limits and access in\n[ChatGPT usage settings](https://chatgpt.com/settings/usage). Codewhale does\nnot silently switch to an API key or another provider when a limit is reached.\n\nThis is an OpenAI preview for open-source/local apps. It does not grant access\nto ChatGPT conversation history. Requests stream with `store: false` and\n`stream: true`, while Codewhale retains its own session history and tools.\nOpenAI-hosted image generation, file search, Code Interpreter, native computer\nuse, hosted MCP/connectors, and Responses `tool_search` are unavailable on this\nroute; Codewhale's own tools use supported function/custom tool calls.\nThe ChatGPT preview requests one function call at a time.\nSee [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).\nPaid or remotely hosted applications require the\n[commercial partner interest process](https://openai.com/form/sign-in-with-chatgpt-interest/);\nthis local integration is not commercial approval.\n\nThe canonical provider IDs are the entries of `ProviderKind::ALL`\n(`crates/config/src/provider_kind.rs`), in that order:\n\n`deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`,\n`siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`,\n`together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`,\n`stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`,\n`opencode-zen`, `meta`, `xai`, `mistral`, `telecomjs`, `modelstudio-token-plan`, `modelscope`,\n`google`, `edenai`, `zenmux`, `csdn`, `concentrate`, `codewhale`, and `custom`.\n\n`deepseek-anthropic` is *not* on this list — it is a wire dialect of\n`deepseek`, reached with `wire = \"anthropic\"`, not a separate route to select.\n\nUse any of these surfaces to select a provider:\n\n- CLI: `codewhale --provider `\n- TUI: `/provider ` or the provider picker\n- Env: `CODEWHALE_PROVIDER=`; `DEEPSEEK_PROVIDER=` is the legacy alias\n- Config: `provider = \"\"`\n\n`deepseek-cn`, `deepseek_china`, `deepseekcn`, and `deepseek-china` are accepted\nas legacy aliases for `deepseek`. They do not select a different official host;\nDeepSeek uses the same official API host worldwide.\n\n`deepseek_anthropic`, `deepseek-claude`, and `deepseek_claude` select\n`deepseek-anthropic`, the opt-in DeepSeek route that speaks the Anthropic\nMessages API at `https://api.deepseek.com/anthropic`. It keeps the normal\nDeepSeek API key path but uses `x-api-key` plus `anthropic-version: 2023-06-01`\ninstead of Bearer auth. If the key already lives in official DeepSeek Harness\n(`dsh`) at `$DSH_HOME/.credentials.yaml`, grant read-only access with\n`codewhale auth external-consent --provider deepseek --mode read-only`.\nCodewhale never writes that file and only reads `DEEPSEEK_API_KEY`.\n\n`huggingface`, `hugging-face`, `hugging_face`, and `hf` all select the\nHugging Face Inference Providers route. This is the OpenAI-compatible router\npath for chat/inference, not Hub browsing, model-card inspection, uploads, or\nartifact export.\n\n`telecomjs`, `telecom-js`, `telecom_js`, `telecomjs-cn`, and `tokenhub` all\nselect the TelecomJS TokenHub route. Its authenticated `/models` catalog is\nkey-scoped and remains isolated from every other provider's live snapshot.\n\nFresh shared config writes to `~/.codewhale/config.toml`. Existing\n`~/.deepseek/config.toml` files are still read for compatibility.\n\n### Legacy Antigravity tombstone\n\nAntigravity is not a Codewhale provider and cannot be selected or run. Existing\nlegacy Antigravity provider state is recognized only as a non-runnable migration\ntombstone. Run `codewhale auth clear --provider antigravity` to forget only\nCodewhale-owned legacy configuration and consent metadata. This does not sign\nout of, revoke, read, or otherwise alter any official Google or Antigravity\nsession. For Gemini, select the supported `google` provider and supply\n`GEMINI_API_KEY`.\n\n### Wire Protocol Compatibility\n\nProvider selection is explicit. A model string prefix such as\n`deepseek-ai/...`, `deepseek/...`, `qwen/...`, or `arcee-ai/...` is a\nprovider-owned wire ID or catalog namespace hint under the selected provider.\nIt is not a provider switch and must not be treated as proof that the route is\nDeepSeek, OpenRouter, or any other provider.\n\nSet the route with `provider = \"\"`, `CODEWHALE_PROVIDER=`, or\n`codewhale --provider `. Set the request model with `CODEWHALE_MODEL`, a\nprovider-specific model env var, top-level `default_text_model`, or\n`[providers.
].model`. Set the endpoint with `CODEWHALE_BASE_URL`, a\nprovider-specific base URL env var, or `[providers.
].base_url`. Set auth\nwith `codewhale auth set --provider `, `[providers.
].api_key`, or\nthe listed provider env vars.\n\n| Provider ID | TOML table | Wire protocol | Auth env vars |\n| --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | Model-aware: Responses default (`deepseek-flash`); Chat Completions (`deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | Anthropic Messages | `DEEPSEEK_API_KEY` |\n| `nvidia-nim` | `[providers.nvidia_nim]` | OpenAI Chat Completions | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` |\n| `openai` | `[providers.openai]` | OpenAI Chat Completions | `OPENAI_API_KEY` |\n| `atlascloud` | `[providers.atlascloud]` | OpenAI Chat Completions | `ATLASCLOUD_API_KEY` |\n| `wanjie-ark` | `[providers.wanjie_ark]` | OpenAI Chat Completions | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` |\n| `volcengine` | `[providers.volcengine]` | OpenAI Chat Completions | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` |\n| `openrouter` | `[providers.openrouter]` | OpenAI Chat Completions | `OPENROUTER_API_KEY` |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | OpenAI Chat Completions | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` |\n| `novita` | `[providers.novita]` | OpenAI Chat Completions | `NOVITA_API_KEY` |\n| `fireworks` | `[providers.fireworks]` | OpenAI Chat Completions | `FIREWORKS_API_KEY` |\n| `siliconflow` | `[providers.siliconflow]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `arcee` | `[providers.arcee]` | OpenAI Chat Completions | `ARCEE_API_KEY` |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | OpenAI Chat Completions | `SILICONFLOW_API_KEY` |\n| `moonshot` | `[providers.moonshot]` | OpenAI Chat Completions | `MOONSHOT_API_KEY`, `KIMI_API_KEY` |\n| `sglang` | `[providers.sglang]` | OpenAI Chat Completions | `SGLANG_API_KEY` |\n| `vllm` | `[providers.vllm]` | OpenAI Chat Completions | `VLLM_API_KEY` |\n| `ollama` | `[providers.ollama]` | Local OpenAI-compatible Chat Completions | `OLLAMA_API_KEY` (optional; only for an authenticated local route) |\n| `ollama-cloud` | `[providers.ollama_cloud]` | Hosted OpenAI-compatible Chat Completions | `OLLAMA_CLOUD_API_KEY`, `OLLAMA_API_KEY` |\n| `huggingface` | `[providers.huggingface]` | OpenAI Chat Completions | `HUGGINGFACE_API_KEY`, `HF_TOKEN` |\n| `modelscope` | `[providers.modelscope]` | OpenAI Chat Completions | `MODELSCOPE_API_KEY` |\n| `together` | `[providers.together]` | OpenAI Chat Completions | `TOGETHER_API_KEY` |\n| `qianfan` | `[providers.qianfan]` | OpenAI Chat Completions | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` |\n| `openai-codex` | `[providers.openai_codex]` | OpenAI Responses | Official Sign in with ChatGPT (`codewhale auth chatgpt`) with a validated Codewhale-owned plan grant |\n| `anthropic` | `[providers.anthropic]` | Anthropic Messages | `ANTHROPIC_API_KEY` or `codewhale auth claude` |\n| `openmodel` | `[providers.openmodel]` | Anthropic Messages | `OPENMODEL_API_KEY` |\n| `zai` | `[providers.zai]` | OpenAI Chat Completions | `ZAI_API_KEY`, `Z_AI_API_KEY` |\n| `stepfun` | `[providers.stepfun]` | OpenAI Chat Completions | `STEPFUN_API_KEY`, `STEP_API_KEY` |\n| `minimax` | `[providers.minimax]` | OpenAI Chat Completions | `MINIMAX_API_KEY` |\n| `deepinfra` | `[providers.deepinfra]` | OpenAI Chat Completions | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` |\n| `sakana` | `[providers.sakana]` | OpenAI Chat Completions | `FUGU_API_KEY`, `SAKANA_API_KEY` |\n| `longcat` | `[providers.longcat]` | OpenAI Chat Completions | `LONGCAT_API_KEY` |\n| `opencode-go` | `[providers.opencode_go]` | OpenAI Chat Completions | `OPENCODE_GO_API_KEY` |\n| `opencode-zen` | `[providers.opencode_zen]` | Model-aware: OpenAI Responses, Anthropic Messages, or OpenAI Chat Completions | `OPENCODE_ZEN_API_KEY`, `OPENCODE_API_KEY` |\n| `meta` | `[providers.meta]` | OpenAI Chat Completions | `META_MODEL_API_KEY`, `MODEL_API_KEY` |\n| `telecomjs` | `[providers.telecomjs]` | OpenAI Chat Completions | `TELECOMJS_API_KEY` |\n| `xai` | `[providers.xai]` | OpenAI Chat Completions | `XAI_API_KEY` |\n| `mistral` | `[providers.mistral]` | OpenAI Chat Completions | `MISTRAL_API_KEY` |\n| `google` | `[providers.google]` | OpenAI Chat Completions (official Gemini OpenAI-compat route; captures and replays thought signatures on tool calls) | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |\n| `edenai` | `[providers.edenai]` | OpenAI Chat Completions | `EDENAI_API_KEY` |\n| `zenmux` | `[providers.zenmux]` | OpenAI Chat Completions | `ZENMUX_API_KEY` |\n| `csdn` | `[providers.csdn]` | OpenAI Chat Completions | `CSDN_API_KEY` |\n| `concentrate` | `[providers.concentrate]` | OpenAI Responses (`/v1/responses`) | `CONCENTRATE_API_KEY` |\n| `codewhale` | `[providers.codewhale]` | Model-aware: OpenAI Chat Completions (`/v1/chat/completions`) or Anthropic Messages (`/v1/messages`), chosen per model by the account catalog | `CODEWHALE_API_KEY` |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | OpenAI Chat Completions | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | Anthropic Messages | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` |\n\nDefault base URLs and models for each route are listed in the shipped provider\ntable below. The wire protocol values above are derived from\n`crates/config/src/provider.rs`: `ChatCompletions` is the default,\n`openai-codex` overrides to `Responses`; `deepseek-anthropic`, `anthropic`, and\n`openmodel` override to `AnthropicMessages`; `opencode-zen` resolves the\nprotocol from the selected model's curated offering; and `deepseek` is\nmodel-aware — the shipped default `deepseek-flash` (and legacy\n`deepseek-v4-flash`) rides the Responses endpoint while `deepseek-v4-pro`\nstays on Chat Completions.\n\n## Auth And Env Rules\n\nFor hosted providers, `codewhale auth set --provider ` saves an API key for\nthat provider. API-key environment variables are fallback inputs after saved\nconfig and keyring credentials; an explicit process-level `--api-key` still\nwins for that launch.\n\nFor base URL and model selection, prefer:\n\n- `CODEWHALE_BASE_URL` / `CODEWHALE_MODEL` for the active provider.\n- Provider-specific base URL/model env vars when listed below.\n- `DEEPSEEK_BASE_URL`, `DEEPSEEK_MODEL`, and `DEEPSEEK_DEFAULT_TEXT_MODEL` as\n legacy aliases.\n\nNon-local `http://` base URLs are rejected unless\n`DEEPSEEK_ALLOW_INSECURE_HTTP=1` is set. Loopback HTTP URLs are allowed for\nself-hosted runtimes.\n\n## Custom DeepSeek-Compatible Endpoints\n\nMost custom DeepSeek-compatible deployments can use an existing provider ID.\nDo not create `[providers.deepseek_custom]`; the provider table names are fixed.\nInstead, choose the closest shipped route and override its endpoint/model:\n\n- DeepSeek-compatible hosted API: keep `provider = \"deepseek\"` and set\n `[providers.deepseek].base_url` plus `[providers.deepseek].model`, or launch\n with `DEEPSEEK_BASE_URL` and `DEEPSEEK_MODEL`.\n- Generic OpenAI-compatible gateway: use `provider = \"openai\"` with\n `[providers.openai].base_url` plus `[providers.openai].model`, or launch with\n `OPENAI_BASE_URL` and `OPENAI_MODEL`.\n- Multiple named OpenAI-compatible gateways, or local routes you want to pin\n from an AgentProfile, can use a custom table such as\n `[providers.lm-studio] kind = \"openai-compatible\"` and select it with\n `provider = \"lm-studio\"` or a profile `provider = \"lm-studio\"`. Such a table\n speaks Chat Completions unless it sets `wire = \"responses\"` (OpenAI\n Responses at `/responses`) or `wire = \"anthropic\"`.\n- Local OpenAI-compatible runtimes: use `provider = \"vllm\"`, `\"sglang\"`, or\n `\"ollama\"` with the matching provider-specific base URL/model values.\n\nExample user config for a DeepSeek-compatible host:\n\n```toml\nprovider = \"deepseek\"\n\n[providers.deepseek]\napi_key = \"YOUR_API_KEY\"\nbase_url = \"https://your-provider.example/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n```\n\nExample user config for a generic gateway:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_GATEWAY_API_KEY\"\nbase_url = \"https://gateway.example/v1\"\nmodel = \"your-deepseek-compatible-model\"\n```\n\nAlibaba Cloud Model Studio (Bailian / DashScope) is a first-class provider as\nof v0.9.4 with two plan profiles: Token Plan (Personal / Team) and Coding Plan.\nBoth plans expose an OpenAI-compatible Chat Completions endpoint and an\nAnthropic-compatible Messages endpoint.\n\n**Token Plan** (Personal and Team share the same AP-Southeast endpoint):\n\n```toml\nprovider = \"modelstudio-token-plan\"\n\n[providers.modelstudio_token_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\nmodel = \"qwen3.8-max\" # or qwen3.8-max-preview | qwen3.7-plus | qwen3.7-max |\n # qwen3.6-flash | deepseek-v4-pro | deepseek-v4-flash-0731 |\n # glm-5.2\n```\n\n**Coding Plan** (separate international endpoint):\n\n```toml\nprovider = \"modelstudio-coding-plan\"\n\n[providers.modelstudio_coding_plan]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://coding-intl.dashscope.aliyuncs.com/v1\nmodel = \"qwen3.8-max\"\n```\n\n**Anthropic-compatible dialect** — both plans also expose a native Anthropic\nMessages path. Select it with the `-anthropic` provider suffix:\n\n```toml\nprovider = \"modelstudio-token-plan-anthropic\"\n\n[providers.modelstudio_token_plan_anthropic]\napi_key = \"YOUR_MODELSTUDIO_API_KEY\"\n# base_url defaults to https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic\nmodel = \"qwen3.8-max\"\n```\n\nCreate or copy a Model Studio API key from the\n[Bailian console](https://bailian.console.aliyun.com/). The API key is shared\nacross all four provider IDs above; only the base URL and wire protocol differ.\n\n**Thinking / reasoning.** Reasoning surfaces in the TUI's Thinking view on both\ndialects, per Model Studio's\n[deep-thinking docs](https://www.alibabacloud.com/help/en/model-studio/deep-thinking).\n\nOn the OpenAI-compatible routes the top-level controls are **route- and\nmodel-specific**, and Codewhale fails closed: they are sent only when the\nconfigured `base_url` is an official Alibaba Chat Completions host\n(`*.maas.aliyuncs.com/compatible-mode/v1`, including workspace-scoped hosts, or\n`coding-intl.dashscope.aliyuncs.com/v1`). A custom `base_url` on the same\nprovider ID gets `thinking`, `enable_thinking`, `preserve_thinking`, and\n`reasoning_effort` stripped, so an arbitrary OpenAI-compatible gateway is never\nhanded Alibaba's dialect. On a verified host:\n\n- **Hybrid models** (`qwen3.7-*`, `qwen3.6-*`, `deepseek-v4*`, `glm-*`,\n `kimi-k2.6*`) get `enable_thinking`: `false` for `off`, `true` otherwise.\n- **Thinking-only models** — `qwen3.8-max` (catalogued `thinking: always_on`),\n `qwen3.8-max-preview` (effort/budget options, no toggle), and\n `kimi-k2.7-code` — get **no** enable/disable switch at all. Sending one is at\n best ignored.\n- `preserve_thinking` is sent for the models documented to accept it\n (`qwen3.7-max`/`-plus`, `qwen3.6-max-preview`/`-plus`/`-flash`, `kimi-k2.6*`,\n `kimi-k2.7-code`), so the next turn keeps the assistant's trace.\n- `reasoning_effort` is sent only for the two families with a documented ladder\n — `deepseek-v4*` and `glm-5`/`5.1`/`5.2` — mapped to `high` or `max`.\n\nReasoning streams back as `delta.reasoning_content`. It is replayed to the\nprovider on later turns only for the `preserve_thinking` models above and the\nthinking-only models; `deepseek-v3.1`, `deepseek-v3.2`, and `glm-*` history\nstays stripped pending live confirmation that DashScope accepts\n`reasoning_content` in input messages. (`deepseek-v4*` replays regardless — the\nDeepSeek thinking-mode contract requires it on every provider.)\n\nOn the Anthropic-compatible routes, thinking uses the documented\n`{\"type\":\"enabled\",\"budget_tokens\":N}` / `{\"type\":\"disabled\"}` shapes from the\n[Anthropic-compatible Messages API](https://www.alibabacloud.com/help/en/model-studio/anthropic-api-messages),\nwith `budget_tokens` derived from the effort level.\n\nDeepSeek (`deepseek-v4-pro`, `deepseek-v4-flash-0731`) and GLM (`glm-5.2`)\nmodels served by Model Studio are provider-scoped and do not collide with the\nfirst-party DeepSeek or Zhipu/Z.ai routes. Model Studio publishes no `glm-5.3`\nentry, so Codewhale does not offer one on this route.\nPay-as-you-go workspace-id templating is not yet in the built-in provider; use\na custom provider entry for that plan until a follow-up adds it.\n\nPrivate gateways with broken or intercepted certificates should use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy\n`insecure_skip_tls_verify = true` key is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of skipping TLS\ncertificate verification.\n\nKeep `provider`, `api_key`, and `base_url` in user config or process\nenvironment. Project-local config overlays intentionally cannot set those keys,\nso a repository cannot silently redirect prompts or credentials to another\nendpoint.\n\n## Local Models (DS4, Ollama, vLLM, SGLang)\n\nSelf-hosted OpenAI-compatible runtimes are first-class routes and are keyless\nby default — set an API key only when your server requires one. Start your\nrunner, then point Codewhale at it with `--provider` / `/provider` or a config\ntable.\n\n| Runner | Default base URL | Default model | Base URL override |\n| --- | --- | --- | --- |\n| `ollama` | `http://localhost:11434/v1` | live tag from `GET /v1/models` (pre-refresh: `unknown`) | `OLLAMA_BASE_URL` |\n| `vllm` | `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `VLLM_BASE_URL` |\n| `sglang` | `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `SGLANG_BASE_URL` |\n\n### DS4 (DwarfStar)\n\n[DS4](https://github.com/antirez/ds4/tree/84cc882352757baf628a1776badf7cc54d584e28)\nserves DeepSeek V4 Flash and Pro locally\nthrough an OpenAI-compatible API. Start DS4, then open Codewhale's prefilled,\nkeyless setup form:\n\n```bash\n./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192\ncodewhale\n# In Codewhale: /setup provider ds4\n```\n\nReview the prefilled route and press Enter to save it. The preset budgets a\n100,000-token context to match that starter command and defaults to the Flash\ncompatibility alias. Check the local route explicitly with\n`codewhale doctor --probe-local`.\n\nDS4 loads the actual GGUF when the server starts. Its `deepseek-v4-flash` and\n`deepseek-v4-pro` API ids are compatibility aliases; changing `/model` does\nnot swap the resident model. To run Pro, download the supported Pro weights\nand start `ds4-server -m ...` as described by DS4. Update\n`context_window` whenever the server's `--ctx` value changes.\n\nThe equivalent config is:\n\n```toml\nprovider = \"ds4\"\n\n[providers.ds4]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:8000/v1\"\nmodel = \"deepseek-v4-flash\"\nauth_mode = \"none\"\ncontext_window = 100000\n```\n\nCodewhale reuses its existing OpenAI-compatible transport and DeepSeek\nreasoning/tool-call shaping for DS4. It does not invent an API key, confuse an\nAPI alias with the loaded GGUF, or silently switch to a hosted DeepSeek route.\nThe pinned DS4 [agent-client contract](https://github.com/antirez/ds4/blob/84cc882352757baf628a1776badf7cc54d584e28/README.md#agent-client-usage)\ndocuments Chat Completions at `/v1`, DeepSeek thinking replay, streamed usage,\n`max_tokens`, and no strict-tool mode; Codewhale follows those exact route\nfacts instead of inheriting unsupported capabilities from a generic gateway.\nThe primary sources for model-facing behavior are DeepSeek's official\n[thinking-mode](https://api-docs.deepseek.com/guides/thinking_mode),\n[tool-call](https://api-docs.deepseek.com/guides/tool_calls), and\n[Chat Completion](https://api-docs.deepseek.com/api/create-chat-completion)\ncontracts. The pinned\n[DeepSeek Harness adapter](https://github.com/deepseek-ai/deepseek-harness/blob/47f943859bef60e4160492346772ded9b24f765a/packages/llm/llm-deepseek/README.md)\nis only a secondary implementation cross-check; it is not the API contract.\n\n### Ollama\n\n```bash\nollama serve # if not already running\nollama pull # e.g. deepseek-v4-flash, or any tag you prefer\ncodewhale --provider ollama --model \n```\n\nProvider-hinted model names are sent as-is, so `--model qwen3:8b` works with\nany tag Ollama has pulled.\n\n### Ollama Cloud\n\nOllama Cloud is a separate hosted provider. It uses the authenticated\nOpenAI-compatible `/v1/chat/completions` route and defaults to `gpt-oss:120b`:\n\n```toml\nprovider = \"ollama-cloud\"\n\n[providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nCreate a key in [Ollama account settings](https://ollama.com/settings/keys),\nthen run `codewhale auth set --provider ollama-cloud`. For ambient auth,\n`OLLAMA_CLOUD_API_KEY` wins over Ollama's official `OLLAMA_API_KEY`.\n`OLLAMA_CLOUD_BASE_URL` and `OLLAMA_CLOUD_MODEL` override the Cloud defaults;\narbitrary provider-owned model IDs pass through unchanged. Local `ollama`\nremains a separate, keyless-by-default provider.\n\nCompatibility is read-only and in memory: a released config that selected\n`provider = \"ollama\"` with the exact normalized\n`[providers.ollama] base_url = \"https://ollama.com/v1\"` tuple is treated as\n`ollama-cloud` at runtime. Only that exact tuple may fall back to the legacy\n`ollama` secret slot. Codewhale does not rewrite the config, copy or delete a\nsecret, migrate neighboring paths, or make an explicit `ollama-cloud` route\nconsume the legacy slot.\n\n### vLLM\n\n```bash\nvllm serve --port 8000\n# or: python -m vllm.entrypoints.openai.api_server --model --port 8000\ncodewhale --provider vllm --model \n```\n\nvLLM's OpenAI-compatible server listens on port 8000 by default, matching\nCodewhale's `VLLM_BASE_URL`.\n\n### SGLang\n\n```bash\npython -m sglang.launch_server --model-path --port 30000\ncodewhale --provider sglang --model \n```\n\nSGLang's default port 30000 matches Codewhale's `SGLANG_BASE_URL`.\n\n### Pinning a local route in config\n\n```toml\nprovider = \"ollama\" # or \"vllm\" / \"sglang\"\n\n[providers.ollama]\nmodel = \"qwen3:8b\" # default is deepseek-v4-flash\n# base_url defaults to http://localhost:11434/v1\n```\n\nLocal models that print tool-call JSON without the wire markers: see\n[When a Local Model Prints Tool JSON](#when-a-local-model-prints-tool-json).\n\n## Credential Links\n\nProvider setup surfaces use the same typed credential metadata as onboarding,\n`/provider`, `/links`, setup receipts, and doctor output. A missing URL is\nintentional: local, OAuth-only, and user-defined routes show their supported\nconfiguration path instead of guessing a vendor page.\n\n| Provider ID | Credential or console link |\n| --- | --- |\n| `deepseek`, `deepseek-anthropic` | [DeepSeek API keys](https://platform.deepseek.com/api_keys) |\n| `nvidia-nim` | [NVIDIA NIM API keys](https://build.nvidia.com/settings/api-keys) |\n| `openai` | [OpenAI API keys](https://platform.openai.com/api-keys) |\n| `atlascloud` | [Atlas Cloud API keys](https://atlascloud.ai/docs/en/api-keys) |\n| `wanjie-ark` | [Wanjie MaaS APIKEY docs](https://docs.wanjiedata.com/maas/maas-openapi-v1.html) |\n| `volcengine` | [Volcengine Ark API keys](https://console.volcengine.com/ark/apiKey) |\n| `openrouter` | [OpenRouter keys](https://openrouter.ai/settings/keys) |\n| `xiaomi-mimo` | [Xiaomi MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) |\n| `novita` | [Novita key management](https://novita.ai/en/settings/key-management) |\n| `fireworks` | [Fireworks API keys](https://fireworks.ai/api-keys) |\n| `siliconflow` | [SiliconFlow global API keys](https://cloud.siliconflow.com/account/ak) |\n| `siliconflow-CN` | [SiliconFlow China API keys](https://cloud.siliconflow.cn/account/ak) |\n| `arcee` | [Arcee API key guide](https://docs.arcee.ai/other/create-your-first-api-key) |\n| `moonshot` | [Kimi API platform keys](https://platform.kimi.ai/console/api-keys) or [Kimi Code membership console](https://www.kimi.com/code/console) |\n| `zai` | [Z.ai model API](https://z.ai/model-api) |\n| `stepfun` | [StepFun Open Platform](https://platform.stepfun.ai/) |\n| `minimax`, `minimax-anthropic` | [MiniMax interface keys](https://platform.minimax.io/user-center/basic-information/interface-key) |\n| `huggingface` | [Hugging Face tokens](https://huggingface.co/settings/tokens) |\n| `modelscope` | [ModelScope API Keys](https://modelscope.cn/my/settings/token) |\n| `deepinfra` | [DeepInfra API keys](https://deepinfra.com/dash/api_keys) |\n| `together` | [Together API keys](https://api.together.ai/settings/api-keys) |\n| `qianfan` | [Baidu Cloud access keys](https://console.bce.baidu.com/iam/#/iam/accesslist) |\n| `anthropic` | [Anthropic API keys](https://console.anthropic.com/settings/keys) |\n| `openmodel` | [OpenModel console](https://console.openmodel.ai/) ([authentication guide](https://docs.openmodel.ai/en/docs/getting-started/authentication)) |\n| `openai-codex` | Official Sign in with ChatGPT via `codewhale auth chatgpt` (eligible ChatGPT plan or credits, Codewhale-owned tokens). The `openai` API-key route has separate billing. Legacy imported Codex credentials do not authorize this route. |\n| `sglang`, `vllm` | Local OpenAI-compatible endpoints are keyless by default; configure a key only when the server requires one. |\n| `ollama` | Local Ollama is keyless by default; configure a key only when the local server requires one. |\n| `ollama-cloud` | Create an [Ollama API key](https://ollama.com/settings/keys), save it with `codewhale auth set --provider ollama-cloud`, or set `OLLAMA_CLOUD_API_KEY` / `OLLAMA_API_KEY` in that precedence order. |\n| `sakana` | [Sakana AI API keys](https://console.sakana.ai/api-keys) ([get started](https://console.sakana.ai/get-started)) |\n| `longcat` | [Meituan LongCat platform](https://longcat.chat/platform) |\n| `opencode-go` | [OpenCode Go](https://opencode.ai/docs/go/) |\n| `opencode-zen` | [OpenCode Zen](https://opencode.ai/docs/zen/) |\n| `meta` | [Meta Model API](https://developer.meta.com/ai/) |\n| `telecomjs` | [TelecomJS TokenHub](https://aigw.telecomjs.com/) |\n| `xai` | [xAI Console](https://console.x.ai/) for an API key, Codewhale-owned device login, or explicitly consented read-only Grok CLI credentials. |\n| `mistral` | [Mistral Console (la Plateforme)](https://console.mistral.ai/api-keys) |\n| `google` | [Google AI Studio](https://aistudio.google.com/apikey) — Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files. |\n| `edenai` | [Eden AI API keys](https://app.edenai.run/settings/api-keys) |\n| `zenmux` | [ZenMux API keys](https://zenmux.ai/platform/pay-as-you-go) |\n| `csdn` | [CSDN 星图 console](https://ai.csdn.net/workbench/api-key) — choose the Coding Plan key type for the `glm_for_coding` plan route; a general key bills metered. Docs: [Coding Plan](https://ai.csdn.net/coding-plan). |\n| `concentrate` | [Concentrate dashboard](https://concentrate.ai/) → API Keys → Create API Key (a Universal API key); docs: [API introduction](https://concentrate.ai/docs/api-reference/introduction). BYOK only — the key stays in the local secret store. |\n| `modelstudio-token-plan`, `modelstudio-token-plan-anthropic`, `modelstudio-coding-plan`, `modelstudio-coding-plan-anthropic` | [Alibaba Cloud Model Studio (Bailian console)](https://bailian.console.aliyun.com/) — create or copy a Model Studio API key. |\n| `codewhale` | [Codewhale account settings](https://app.codewhale.net/settings?section=api) — create an API key with the `models:infer` scope, or run `codewhale account api-keys create --name --use`. |\n| `custom` | Set the named provider's `base_url` and `api_key_env` or `api_key`; no canonical vendor credential page exists. |\n\nFor Kimi, the official [quickstart](https://platform.kimi.ai/docs/overview)\ndirects users to sign in, open **API Keys**, create and copy a key, and keep it\nsecret. Codewhale links straight to that console and accepts the copied key.\nIt never probes or impersonates `kimi_cli`/`kimi_code_cli`; first-class Kimi\nOAuth remains blocked on a vendor-registered Codewhale identity.\n\n### Subscription sign-in accounts (ChatGPT, xAI)\n\nCodewhale keeps one sign-in of its own per subscription provider. It is\nseparate from the browser, the ChatGPT or Grok apps, and the Codex or Grok\nCLIs, so those can be signed in to a different account. A successful login\nreplaces the previous owned grant; there is no picker for multiple saved\nChatGPT accounts.\n\n- **See which account is signed in.** `codewhale auth status --provider\n openai-codex` (or `--provider xai`) and `codewhale auth list` show an account\n label when the ID token contains an email. ChatGPT plan and workspace\n details appear only when those claims are present; a workspace label uses\n the first characters of its account ID. The label is display metadata, not\n proof of plan entitlement. A valid sign-in may have no email label. Status\n reads local credential state without contacting the issuer: an expired\n access token with a stored refresh token remains structurally usable, while\n missing or invalid storage, or an expired token without a refresh token,\n is reported unusable. The provider picker shows the label of the sign-in\n the route would use (for xAI this includes a consented Grok CLI import).\n Successful login reports the account when known and whether it replaced\n an earlier sign-in, in the TUI's selected language. No token is printed.\n- **Reauthorize ChatGPT.** Run `/auth chatgpt` inside Codewhale to update the\n running session, or `codewhale auth chatgpt` from a shell and restart open\n Codewhale sessions. Ordinary login reuses the selected account/workspace's\n issued client ID and checks that the returned account is the same. The\n browser opens with `prompt=login`; `CODEWHALE_CHATGPT_OAUTH_NO_PROMPT=1`\n omits that parameter if the issuer refuses it.\n- **Replace the ChatGPT account or workspace.** From a shell, run\n `CODEWHALE_CHATGPT_NEW_ACCOUNT=1 codewhale auth chatgpt`, then refresh its\n model catalog and restart open Codewhale sessions. If the browser holds the\n wrong account, sign out there or open the printed URL in a private window.\n Only a fully validated new grant replaces the active owned sign-in.\n- **Switch xAI accounts.** Run `/auth xai-device` inside Codewhale, or\n `codewhale auth xai-device` from a shell and restart open sessions. The\n device page approves with whichever account the browser holds; sign out\n there or use a private window to approve another account. The login names\n the account it replaced, or says it is the same account as before.\n- **Usage limits.** ChatGPT plan requests can fail with\n `subscription_sharing_usage_limit_exceeded` or\n `subscription_sharing_usage_unavailable`. These errors, and subscription\n quota errors from other providers, name the account that made the request\n when its label is known and offer recovery guidance. Codewhale does not\n retry these terminal errors or silently choose another billing route. An\n xAI route set to OAuth that fell back to an API key gets no sign-in\n guidance, since no sign-in was used.\n- **ChatGPT credential authority.** The official ChatGPT plan route uses\n only its selected, verified Codewhale-owned grant.\n `OPENAI_CODEX_ACCESS_TOKEN`, `CODEX_ACCESS_TOKEN`, and consented Codex CLI\n tokens cannot override or authorize this route.\n\n### External CLI credential consent\n\nCredential files owned by another CLI are disabled by default. Without an\nexplicit grant, provider discovery, setup, routing, `auth status`, and doctor\ndo not stat, read, refresh, contact an identity provider for, or rewrite Codex,\nGrok, Kimi, or future external credential files.\n\nCodewhale currently supports exact-path, provider-scoped **read-only** grants\nfor the Codex CLI and Grok CLI. Codex grants remain available for legacy\ncredential inspection only; they cannot authorize the official ChatGPT plan\nroute. Use `codewhale auth chatgpt` for that route.\n\n```bash\n# Legacy Codex credential inspection; does not authorize ChatGPT plan requests.\ncodex login\ncodewhale auth external-consent --provider openai-codex --mode read-only\n\ngrok login\ncodewhale auth external-consent --provider xai --mode read-only\n\ncodewhale auth status --provider openai-codex\ncodewhale auth external-revoke --provider openai-codex\n```\n\nPass `--path /absolute/path/to/auth.json` when the external CLI uses a custom\nlocation. Consent persists the provider, external owner, exact absolute path,\nand consent schema version. Later environment-variable changes do not redirect\nthat authority to a different file. Read-only grants never refresh, contact an\nidentity/discovery service, or rewrite the external file. Supported routes such\nas xAI may use the external token after explicit selection. The official ChatGPT\nplan route ignores imported Codex tokens and requires its own verified grant.\nAn expired external token fails with login guidance. Doctor reports structural\nconsent/config state without\nopening credential files and is always non-mutating.\n\n`managed` is reserved for a future provider-specific preservation adapter.\nv0.9.1 rejects it before file or network I/O because no reviewed adapter can\nyet preserve every unknown external schema field safely. Codewhale-started xAI\ndevice login instead atomically activates a Codewhale-owned generation named\n`$CODEWHALE_HOME/credentials/xai-auth-.json`, stores only that\nvalidated basename in config, and revokes any Grok-file grant. Superseded\ngenerations are cleaned only after the new config pointer commits.\nKimi remains API-key-only; external consent for Kimi is rejected.\n\nThe official DeepSeek Harness (`dsh`) is a third read-only credential owner:\n`codewhale auth external-consent --provider deepseek --mode read-only` grants\nexact-path read access to `DEEPSEEK_API_KEY` in `$DSH_HOME/.credentials.yaml`\n(or `~/.dsh/.credentials.yaml`), which Codewhale never writes, refreshes, or\nloads into the process environment. This is separate from the DSH *harness*\nintegration (`codewhale integrations dsh …`, see\n[INTEGRATIONS_DSH.md](INTEGRATIONS_DSH.md)), which never touches credentials\nin either direction: it pins Codewhale's route identity into a `--patch`\noverlay and lets DSH resolve its own keys.\n\n## Shipped Providers\n\n| Provider ID | TOML table | Auth env | Base URL env and default | Default or static models | Notes |\n| --- | --- | --- | --- | --- | --- |\n| `deepseek` | `[providers.deepseek]` | `DEEPSEEK_API_KEY` | `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`; default `https://api.deepseek.com/beta` | `deepseek-flash` (shipped default; V4.1 Flash, unversioned id), `deepseek-v4-pro`, `deepseek-v4-flash`, experimental `deepseek-v4-flash-vision-exp`; vision aliases `flash-vision`, `deepseek-v4flashvisionexp`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | First-class default. The live Pro backend is labeled `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`. Beta URL enables strict tool mode, chat prefix completion, and FIM completion. The documented V4 routes can use provider-native web search through a separate bounded Responses request; compatible custom endpoints do not inherit that capability. Set `https://api.deepseek.com` or `/v1` explicitly to opt out of beta-only features. The shipped default `deepseek-flash` speaks the Responses API (DeepSeek's documented path for Codex-style integration, since the 2026-07-31 Flash production update); the Chat-only controls (the `thinking` toggle and strict-tool `/beta` routing) apply to Chat Completions routes, and `deepseek-v4-pro` stays on Chat Completions until its announced Responses rollout. Reasoning effort follows DeepSeek's documented requested-to-actual mapping: `minimal`/`low` land on `low`, `medium`/`xhigh` on `high`, and `max`/`ultra` on `max`; `off` disables thinking (`thinking: {\"type\":\"disabled\"}` on Chat, `reasoning.effort: \"none\"` on Responses). The experimental vision ID was observed in the authenticated `/models` roster on 2026-08-21 and is advertised as image-input capable on the direct Chat Completions route only. Its limits, reasoning, and tool-call flags provisionally inherit Flash; pricing remains unknown, and no funded image round trip was made during this release work. |\n| `deepseek-anthropic` | `[providers.deepseek_anthropic]` | `DEEPSEEK_API_KEY` | `DEEPSEEK_ANTHROPIC_BASE_URL`; default `https://api.deepseek.com/anthropic` | `deepseek-v4-pro`, `deepseek-v4-flash`; compatibility aliases `deepseek-chat`, `deepseek-reasoner` | Opt-in DeepSeek route for the Anthropic Messages wire protocol. Uses `/v1/messages`, `x-api-key`, and `anthropic-version: 2023-06-01`. Keep `provider = \"deepseek\"` for the default Chat Completions path. |\n| `nvidia-nim` | `[providers.nvidia_nim]` | `NVIDIA_API_KEY`, `NVIDIA_NIM_API_KEY` | `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, `NVIDIA_BASE_URL`; default `https://integrate.api.nvidia.com/v1` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | Hosted DeepSeek V4 through NVIDIA NIM. `NVIDIA_NIM_MODEL` is accepted by the TUI config path. |\n| `openai` | `[providers.openai]` | `OPENAI_API_KEY` | `OPENAI_BASE_URL`; default `https://api.openai.com/v1` | `gpt-5.6` (default), `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | Generic OpenAI-compatible route whose built-in endpoint and fallback catalog are native to OpenAI. The [GPT-5.6 family](https://developers.openai.com/api/docs/models/gpt-5.6-sol) uses OpenAI's documented 1.05M context, 128K max output, and reasoning levels. Custom gateways remain free to select an explicit gateway-owned model. `OPENAI_MODEL` is accepted. |\n| `atlascloud` | `[providers.atlascloud]` | `ATLASCLOUD_API_KEY` | `ATLASCLOUD_BASE_URL`; default `https://api.atlascloud.ai/v1` | Default `deepseek-ai/deepseek-v4-flash`; explicit `vendor/model-id` values pass through when AtlasCloud is selected | OpenAI-compatible hosted route. `ATLASCLOUD_MODEL` is accepted by the TUI config path, the static `ModelRegistry` keeps DeepSeek V4 fallback rows, and provider-hinted CLI model IDs are sent to AtlasCloud exactly as requested. Use Atlas Cloud's own catalog or Coding Plan page for the current provider-owned model list and pricing. |\n| `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. |\n| `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. |\n| `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `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`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. |\n| `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. |\n| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `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. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. |\n| `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/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. |\n| `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. |\n| `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. |\n| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = \"siliconflow-CN\"` or `CODEWHALE_PROVIDER=siliconflow-CN`. |\n| `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. |\n| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = \"https://api.moonshot.ai/v1\"` and `model = \"kimi-k3\"`; it is always-thinking and receives top-level `reasoning_effort = \"low\" | \"high\" | \"max\"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = \"kimi_oauth\"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = \"https://api.moonshot.cn/v1\"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. |\n| `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. |\n| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general APIs `https://api.z.ai/api/paas/v4` and `https://open.bigmodel.cn/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. All three first-party Chat routes (both api.z.ai products and BigModel's general platform endpoint) share one request dialect, so reasoning controls — the GLM-5.2 thinking toggle, tiered effort, and the forced-thinking GLM-5.3 rewrite that sends `off` as `enabled` + `reasoning_effort: \"low\"` — apply on BigModel too; neighboring paths such as `/preview` stay fail-closed. The two general API products expose structured provider-native web search (`search-prime` globally, `search_std` in China); Coding Plan and compatible custom endpoints do not inherit it. `GLM-5.3` is the default and a first-class picker row (`model = \"GLM-5.3\"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = \"GLM-5.3-Flash\"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |\n| `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. |\n| `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = \"token-plan\"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. |\n| `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. |\n| `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. |\n| `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. |\n| `ollama` | `[providers.ollama]` | Local optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | live tag from the local catalog; pre-refresh placeholder `unknown`; provider-hinted custom tags pass through | Local Ollama is keyless by default. `OLLAMA_MODEL` is accepted. The header must not paint a hosted id the local daemon did not list. |\n| `ollama-cloud` | `[providers.ollama_cloud]` | `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY` | `OLLAMA_CLOUD_BASE_URL`; default `https://ollama.com/v1` | `gpt-oss:120b`; arbitrary provider-owned IDs pass through | Hosted OpenAI-compatible `/v1/chat/completions` route. Save credentials under `ollama-cloud`; the exact released `ollama` + Cloud URL tuple has bounded read-only in-memory compatibility with its legacy table and secret slot. `OLLAMA_CLOUD_MODEL` is accepted. |\n| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`, `HF_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible router route. Accepted aliases: `huggingface`, `hugging-face`, `hugging_face`, `hf`. Org-prefixed model IDs pass through. `HUGGINGFACE_MODEL` and `HF_MODEL` are accepted. Hub browsing/export are separate future features. |\n| `modelscope` | `[providers.modelscope]` | `MODELSCOPE_API_KEY` | `MODELSCOPE_BASE_URL`; default `https://api-inference.modelscope.cn/v1` | `Qwen/Qwen3.5-397B-A17B` (default), `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | ModelScope OpenAI-compatible inference route. Org-prefixed model IDs pass through. `MODELSCOPE_MODEL` is accepted. |\n| `deepinfra` | `[providers.deepinfra]` | `DEEPINFRA_API_KEY`, `DEEPINFRA_TOKEN` | `DEEPINFRA_BASE_URL`; default `https://api.deepinfra.com/v1/openai` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | DeepInfra OpenAI-compatible route. Drop-in replacement for OpenAI SDK. |\n| `together` | `[providers.together]` | `TOGETHER_API_KEY` | `TOGETHER_BASE_URL`; default `https://api.together.xyz/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | Together AI OpenAI-compatible route. `TOGETHER_MODEL` is accepted. Model aliases `deepseek-v4-pro` and `deepseek-v4-flash` normalize to Together's org-prefixed IDs; `inkling` and `together-inkling` normalize to Together's published lowercase Inkling wire ID. Inkling uses the exact `none`/`minimal`/`low`/`medium`/`high`/`max` reasoning vocabulary from Thinking Machines' [official model repository](https://huggingface.co/thinkingmachines/Inkling). Together's [launch post](https://www.together.ai/blog/together-ai-brings-thinking-machines-labs-new-model-inkling-on-day-0) currently says Inkling is live with 1M context, while its [model detail page](https://www.together.ai/models/inkling) says coming soon with 256K context and publishes no price. Until Together's active `/models` endpoint and the Models.dev catalog resolve that conflict, Inkling is not seeded into Codewhale's offline picker and no route-specific context or cost is inferred. |\n| `qianfan` | `[providers.qianfan]` | `QIANFAN_API_KEY`, `BAIDU_QIANFAN_API_KEY` | `QIANFAN_BASE_URL`, `BAIDU_QIANFAN_BASE_URL`; default `https://api.baiduqianfan.ai/v1` | `ernie-4.0-turbo-8k`; provider-scoped custom Qianfan service/model IDs pass through | Baidu Qianfan OpenAI-compatible route. Requests use Bearer auth and Chat Completions payloads. `QIANFAN_MODEL` and `BAIDU_QIANFAN_MODEL` are accepted; aliases `baidu-qianfan`, `baidu_qianfan`, and `baidu` resolve to this provider. Tool/function calling is model-scoped in Qianfan docs, so Codewhale preserves the selected wire model and leaves live capability proof to follow-up route/capability work. |\n| `openai-codex` | `[providers.openai_codex]` | Official Sign in with ChatGPT (`codewhale auth chatgpt` / `/provider setup openai-codex`) | Official `https://api.openai.com/v1` | Selected account catalog; configured model IDs remain explicit selections | **Experimental.** Public Responses endpoint (`/v1/responses`) with a validated `chatgpt.tokens.use.direct` grant. Dynamic OSS registration issues a client ID for the selected account/workspace; Codewhale protects and renews its own tokens. The account catalog supplies selectable models; public model lists and legacy Codex tokens do not establish plan permission. No silent billing fallback. See [Sign in with ChatGPT](#sign-in-with-chatgpt) for setup, usage limits, and preview boundaries. |\n| `anthropic` | `[providers.anthropic]` | `ANTHROPIC_API_KEY` | `ANTHROPIC_BASE_URL`; default `https://api.anthropic.com` | `claude-opus-4-8`, `claude-sonnet-4-6` (default), `claude-haiku-4-5` | Native Anthropic Messages API route (`/v1/messages`, `x-api-key` + `anthropic-version: 2023-06-01`) — not OpenAI-compatible. Prompt caching via `cache_control` breakpoints, adaptive thinking + `output_config.effort`, signed thinking blocks replayed verbatim, cache telemetry normalized per #2961. `ANTHROPIC_MODEL` is accepted. |\n| `openmodel` | `[providers.openmodel]` | `OPENMODEL_API_KEY` | `OPENMODEL_BASE_URL`; default `https://api.openmodel.ai` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | OpenModel Anthropic-compatible Messages route. Uses `/v1/messages`, Bearer auth, and `anthropic-version: 2023-06-01`; OpenModel selects DeepSeek, DashScope, Xiaomi, Claude, and other routes by model id. `OPENMODEL_MODEL` is accepted. |\n| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |\n| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |\n| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |\n| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |\n| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |\n| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |\n| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |\n| `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. |\n| `zenmux` | `[providers.zenmux]` | `ZENMUX_API_KEY` | `ZENMUX_BASE_URL`; default `https://zenmux.ai/api/v1` | `deepseek/deepseek-v4.1-flash` (default); live `/models` catalog of `provider/model` ids (keyless-readable) | ZenMux OpenAI-compatible aggregation gateway (~190 models). Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `ZENMUX_MODEL` is accepted. Provider aliases: `zen-mux`, `zen_mux`. |\n| `csdn` | `[providers.csdn]` | `CSDN_API_KEY` | `CSDN_BASE_URL`; default `https://ai.csdn.net/api/model/v1` | `glm_for_coding` (default; the Coding Plan's dedicated model id); other marketplace model ids pass through | CSDN 星图 (Starmap) OpenAI-compatible hosted platform. Coding Plan keys and general marketplace keys share the one endpoint, so billing follows the credential product, never the URL alone: routing `glm_for_coding` — the shipped default — or setting `mode = \"coding_plan\"`/`\"plan\"`/`\"subscription\"` bills as CSDN Coding Plan quota with no dollar estimates; any other model, or an explicit `pay-as-you-go`/`metered` mode, bills metered; an unrecognized mode or an endpoint off `ai.csdn.net/api/model/v1` reports `cost: unknown`. `CSDN_MODEL` is accepted. Provider aliases: `csdn-ai`, `csdn_ai`, `csdn-coding-plan`, `csdn_coding_plan`, `starmap`. |\n| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |\n| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set `. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |\n| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) has the same 500K window, text/image input, function calls, structured output, reasoning ladder and $2.00 / $0.50 cached / $6.00 rates; xAI documents no web-search support for it yet, so Codewhale does not claim it. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |\n| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |\n| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |\n| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |\n| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |\n\nStepFun's four coding models are available through both its standard API and\n[Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api).\nChoose the billing route in `/provider`, then the model in `/model`; existing\nStep 3.7 selections remain unchanged. [Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview)\nhas a 1M context window. Step 5 and Step 3.7 expose low/medium/high reasoning;\nStep 3.5 Flash 2603 exposes low/high; base Step 3.5 uses provider-default reasoning.\n[Published API prices](https://platform.stepfun.ai/docs/en/guides/pricing/details)\napply only to verified PAYG routes. Step Plan displays subscription allowance.\nSpeech, music and image-generation models use separate interfaces and are not\npresented as coding models. Provider video capability metadata does not imply\nthat every Codewhale client can attach video.\n\n\n### OpenCode Zen protocol catalog\n\nZen Responses and Chat Completions requests authenticate with Bearer\n`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these\nroutes add ChatGPT/Codex OAuth headers.\n\nEach Zen model's wire comes from two sources, and neither is guessed from a\nmodel-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):\n\n1. **The curated snapshot** compiled into Codewhale, verified against the\n [official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`\n provider in [Models.dev](https://models.dev). It is the offline floor, and\n it wins when a catalog row names a different wire for the same id.\n2. **The Models.dev catalog.** Its `opencode` provider is Zen's published\n catalog: each model row names the AI SDK package OpenCode itself uses, which\n Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`\n to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider\n default) to Chat Completions. A Zen model released after this build routes\n once the catalog lists it, without a Codewhale release. The interactive TUI\n and `codewhale exec` both load the persisted catalog; refresh it with\n `codewhale models --update`.\n\nThe curated snapshot:\n\n- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,\n `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,\n `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,\n `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,\n `gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,\n `gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,\n `grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,\n `muse-spark-1.2`, `muse-spark-1.2-contributor`,\n `muse-spark-1.2-contributor-free`.\n- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,\n `claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,\n `claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,\n `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,\n `claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,\n `qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.\n- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,\n `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,\n `minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,\n `glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,\n `qwen3.8-max`, `big-pickle`, `space-bunny-free`,\n `longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,\n `ling-3.0-flash-fin-free`, `north-mini-code-free`,\n `nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,\n `deepseek-v4-flash-free`.\n\nThese fail closed locally, with the reason in the error, instead of reaching\nZen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale\ndoes not speak); a catalog row naming any other package; a catalog row Models.dev\nmarks `deprecated`; and a model neither source lists. A miss never falls back to\nanother Zen wire shape, including when a custom Zen base URL is configured.\n\n### Concentrate Notes\n\nConcentrate (`concentrate`) is an OpenAI **Responses**-compatible gateway:\n`POST https://api.concentrate.ai/v1/responses` with `Authorization: Bearer\n`. It is opt-in and **BYOK only**.\n\n- **Model ids pass through.** A plain catalog id (`gpt-5.6-sol`,\n `deepseek-v4-pro`) lets the gateway choose the upstream provider;\n `provider/model` (`openai/gpt-5.6-sol`) pins the upstream provider inside the\n gateway; `concentrate/auto` sends the gateway's own `auto` router. Codewhale's\n bare `--model auto` remains the resolver sentinel (provider default), which is\n why the gateway router has its own explicit spelling. Once streaming begins\n the gateway commits to one provider ([auto routing](https://concentrate.ai/docs/api-reference/endpoint/auto-routing)).\n- **Wire.** Fixed Responses dialect. The request carries only documented\n fields — `model`, `input`, `stream`, `max_output_tokens`, `tools`,\n `tool_choice`, `parallel_tool_calls`, `reasoning.effort` — and the system\n prompt travels as a leading `system` input item because `instructions` is\n not in the gateway's [parameter reference](https://concentrate.ai/docs/api-reference/endpoint/request-parameters).\n Streaming follows the typed `response.*` SSE events and ends on\n `response.completed` without a `[DONE]` sentinel ([streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming)).\n- **Errors.** `{ \"error\", \"message\", \"model\"?, \"retry_after\"? }` with 400\n (bad request / unknown model), 401 (invalid key), 402 (insufficient\n credits), 424 (upstream provider unavailable), 429 (rate limit) —\n surfaced verbatim and classified by message ([errors](https://concentrate.ai/docs/api-reference/endpoint/errors)).\n- **Catalog.** `GET /v1/models` needs no key\n ([list models](https://concentrate.ai/docs/api-reference/endpoint/list-models)); the live rows\n are provider-scoped and unclaimed (no pricing or capability claims).\n- **Commercial boundary.** Concentrate's [Terms of Service](https://concentrate.ai/legal/terms-of-service)\n forbid reselling, white-labeling, or providing the service on a\n service-bureau basis without written consent, and its\n [Acceptable Use Policy](https://concentrate.ai/legal/acceptable-use-policy)\n forbids sharing or sublicensing keys. Codewhale therefore ships **no**\n Codewhale-owned Concentrate key, no stored customer key, no default or\n managed routing, and no markup — activation of any hosted lane is gated on\n written consent, terms, and billing approval (tracked in the Codewhale ops\n evidence checklist `concentrate-gateway-20260829/CHECKLIST.md`). Public\n pricing states \"No platform markup on tokens\".\n- **Self-test without a key.** `scripts/concentrate-selftest.sh` boots a\n local stub that speaks the documented contract and drives the real\n `codewhale exec` path through it, asserting the URL, bearer header, model\n passthrough, streaming, and the completed-turn receipt. No network call\n leaves the machine and no account is required.\n\n### Hugging Face Provider vs MCP vs Hub\n\nCodewhale's `huggingface` provider ID is only the OpenAI-compatible chat\ninference route through Hugging Face Inference Providers. It is selected with\n`/provider huggingface`, `CODEWHALE_PROVIDER=huggingface`, or\n`provider = \"huggingface\"`.\n\nHugging Face MCP is a separate external-tool route. Configure it through the\nMCP config described in `docs/MCP.md`, preferably using the settings-generated\nsnippet from . In the TUI, `/hf mcp status`\nchecks whether the Hugging Face MCP server appears in the resolved MCP config,\n`/hf mcp setup` prints the settings workflow and a placeholder-only shape, and\n`/hf concepts` explains the provider/MCP/Hub distinction.\n\nHub publishing or repository management remains explicit user action through\nHub-native tooling such as `huggingface_hub` or git. The `/hf` helper does not\nupload to Hugging Face and does not perform direct Hugging Face Hub HTTP search.\n\n### Xiaomi MiMo Notes\n\n`xiaomi-mimo` defaults to `mimo-v2.5-pro` for long-context reasoning and coding\nwork. The chat picker also exposes `mimo-v2.5-pro-ultraspeed` and the latest\nOmni model `mimo-v2.5`. Xiaomi MiMo TTS is available through\n`codewhale --provider xiaomi-mimo speech \"text\" --model tts` (or the `tts`\nalias). In Act and Operate, the provider-specific `speech` / `tts` tools are\navailable through deferred discovery when the Xiaomi MiMo route is configured.\n\n`/provider xiaomi-mimo ultraspeed` and `/provider xiaomi-mimo pro-ultraspeed`\nboth select `mimo-v2.5-pro-ultraspeed`. Speech aliases such as `tts`,\n`voice-design`, and `voice-clone` are separate from normal chat defaults.\n\nToken Plan keys default to the Singapore endpoint\n`https://token-plan-sgp.xiaomimimo.com/v1`. If your MiMo account is provisioned\nfor the China region, set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"`\nexplicitly in `[providers.xiaomi_mimo]` or set `mode = \"token-plan-cn\"`. Europe\nToken Plan accounts can set\n`base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` or use\n`mode = \"token-plan-ams\"`; `mode = \"pay-as-you-go\"`\nselects the standard API endpoint and standard MiMo key family. Xiaomi Token\nPlan docs and console expose credit/quota semantics, but Codewhale does not\ncurrently have a documented balance endpoint to poll, so cost display remains\nunknown rather than reusing token-price estimates from another provider.\n\nVoice-design and voice-clone shorthands map to `mimo-v2.5-tts-voicedesign` and\n`mimo-v2.5-tts-voiceclone`. Xiaomi's current\n[image-understanding guide](https://platform.xiaomimimo.com/docs/en-US/usage-guide/multimodal-understanding/image-understanding)\nincludes `mimo-v2.5` for image input. Codewhale exposes image analysis through the\nseparate `[vision_model]` / `image_analyze` path; set that model to\n`mimo-v2.5` when using MiMo for vision.\n\n### OpenRouter-Compatible Base URLs\n\nOpenRouter-compatible gateways should usually stay on the `openrouter`\nprovider with a provider-scoped `base_url` override instead of moving through\nthe generic `openai` route. That keeps OpenRouter-style reasoning, streaming,\ncache usage, and namespaced wire model parsing attached to the selected route:\n\n```toml\nprovider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"sk-...\"\nbase_url = \"https://openrouter-compatible.example/v1\"\nmodel = \"deepseek/deepseek-v4-pro\"\n```\n\nCodewhale preserves the `deepseek/` wire-model prefix under the OpenRouter\nprovider scope; it does not infer a switch to the direct DeepSeek provider from\nthat model string. Cache fields such as `prompt_cache_hit_tokens`,\n`prompt_cache_miss_tokens`, and `prompt_tokens_details.cached_tokens` are\nparsed when the upstream gateway sends them. If a key/account type omits those\nfields, Codewhale treats them as absent for that response rather than as a\ndifferent provider route.\n\nOrcaRouter (`https://api.orcarouter.ai/v1`) is a dedicated named route\n([OrcaRouter](https://www.orcarouter.ai)) that speaks the same OpenAI\nChat Completions wire protocol and serves the same namespaced\n`vendor/model` catalog. It does not need an OpenRouter-compatible\n`base_url` override: select `provider = \"orcarouter\"` and its namespaced\nwire models (for example `deepseek/deepseek-v4-pro` or its own\n`orcarouter/auto` router) pass through verbatim, exactly as they do on the\nOpenRouter provider scope.\n\n#### OrcaRouter credentials: API key or OAuth 2.0 + PKCE\n\nOrcaRouter accepts two credential sources, and both write the same durable\n`sk-orca-…` key to the same `orcarouter` secret-store slot with\n`auth_mode = \"api_key\"`:\n\n- **API key** — `codewhale auth set --provider orcarouter`, `/provider`, or\n `ORCAROUTER_API_KEY`. Use this when you already have a key.\n- **Connect with OrcaRouter** — `codewhale auth orcarouter` (CLI) or\n `/auth orcarouter` (in-session). This runs an OAuth 2.0 authorization-code\n flow with PKCE (S256) on a loopback redirect. The browser is sent to\n `GET https://www.orcarouter.ai/auth` with `callback_url`, `code_challenge`,\n `code_challenge_method=S256`, `state`, `app_name`, and `scope`; the code is\n exchanged at `POST https://www.orcarouter.ai/api/v1/auth/keys` for a\n durable API key. There is no client secret and no pre-registered redirect\n URI; state is compared in constant time before the code is used.\n\nAuthentication and inference use different origins: the consent and exchange\nlive on `https://www.orcarouter.ai`; models and chat live on\n`https://api.orcarouter.ai/v1`. Neither is derived from the other, and\n`/v1/auth/keys` on the API origin is not the exchange route. Self-hosted\ndeployments can override each origin explicitly with `ORCA_AUTH_BASE_URL`\n(auth) and `ORCA_API_BASE_URL`/`ORCAROUTER_BASE_URL` (inference); the explicit\nvalue wins. Remote origins must be HTTPS; plain HTTP is accepted only on\nloopback.\n\nA PKCE-issued key is a durable API key, **not** a refresh token: Codewhale\nstores it, reuses it across restarts, and never sends a refresh grant. Revoke\nthe key on the OrcaRouter console (`/auth orcarouter-revoke` clears the local\ncopy) and sign in again to get a new one. A 401 from the relay marks that\ncredential generation as needing re-authentication rather than silently\nretrying.\n\nThe chat model list is discovered live from `GET https://api.orcarouter.ai/v1/models`\nwith the configured key. OrcaRouter publishes `supported_endpoint_types` on\nevery row, so the chat selector keeps only rows advertising `openai`,\n`anthropic`, `gemini`, or `openai-response`, and drops image-generation,\nvideo, and rerank rows instead of guessing from a model name. A row that\nstates `architecture.input_modalities` with `image` is image-input capable;\nrows that state no architecture are treated as unknown, not as text-only.\n\n### Recent OpenRouter Large Models\n\nOpenRouter completions and static registry rows include the April 2026 onward\nlarge models verified through OpenRouter's model metadata:\n`arcee-ai/trinity-large-thinking`, `qwen/qwen3.6-flash`,\n`qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`,\n`qwen/qwen3.6-plus`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`,\n`xiaomi/mimo-v2.5`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`,\n`z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`,\n`google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, and\n`nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`.\n`minimax/minimax-m3` was added from OpenRouter's May 31, 2026 listing as a 1M\ncontext multimodal model for coding, tool use, and long-horizon agentic work.\n`GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` /\n`z-ai/glm-5.2` remain available (explicit selections keep their own id),\n`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model,\n`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of\n`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling\nof `GLM-5.2`.\n`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are\nfirst-class picker ids on the Z.ai and OpenRouter routes (`/model` after\n`/provider zai`, or `model = \"GLM-5.3-Flash\"`).\nLimits and reasoning options for 5.3 are inherited from\n`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no\nprice. Flash ships the published $0.15/$0.50 list. A live call can still\n429 with entitlement code 1311 on accounts that are not provisioned for 5.3.\n\n## Static Model Registry\n\nThis table records legacy selector compatibility. Current `codewhale model\nresolve`, `codewhale model list`, the picker and Runtime API use the shared\nprovider lake and route resolver. `models --update` refreshes the existing\ncatalog cache; later routes see new metadata, while dispatched requests retain\ntheir admitted limits and price receipt.\n\nThe reviewed `selections` in `crates/config/assets/catalog_corrections.json`\nremain compatibility metadata for legacy callers. They do not establish account\navailability or executable route permission. Public discovery can contain\nmetadata-only models; an authenticated provider roster establishes a different\nfact. A same-named custom endpoint keeps its own declared limits and prices.\n\n`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,\ncanonical ID when declared, limits and implemented capabilities. Runtime\n`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published\nprice structure, reasoning options, configured effective limits and the price\nstatus. `account_access: unverified` means these facts have not established the\nselected account's access. Context responses distinguish an active engine\nwindow from the static route's resolution source, including any fallback.\nSee [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.\n\n| Provider | Static registry entries | Tool calls | Registry reasoning flag |\n| --- | --- | --- | --- |\n| `deepseek` | `deepseek-v4-pro`, `deepseek-v4-flash`, `deepseek-v4-flash-vision-exp` | yes | yes |\n| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro`, `deepseek-ai/deepseek-v4-flash` | yes | yes |\n| `openai` | `deepseek-v4-pro`, `deepseek-v4-flash`, `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | yes | yes |\n| `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes |\n| `wanjie-ark` | `deepseek-reasoner` | yes | yes |\n| `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes |\n| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes |\n| `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes |\n| `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models |\n| `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes |\n| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes |\n| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `arcee` | `trinity-large-thinking`, `trinity-large-preview`; provider-hinted custom model IDs pass through | yes | yes for `trinity-large-thinking`; no for `trinity-large-preview` |\n| `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes |\n| `zai` | `GLM-5.3`, `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes |\n| `stepfun` | `step-3.7-flash` (default), `step-5-preview`, `step-3.5-flash`, `step-3.5-flash-2603` | yes | yes |\n| `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes |\n| `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes |\n| `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `ollama` | live local tag; custom tags pass through when provider hint is `ollama` | yes | no |\n| `ollama-cloud` | `gpt-oss:120b`; arbitrary provider-owned model IDs pass through | yes | yes |\n| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no |\n| `modelscope` | `Qwen/Qwen3.5-397B-A17B`, `Qwen/Qwen3.5-122B-A10B`, `Qwen/Qwen3.5-27B`, `Qwen/Qwen3.5-35B-A3B`, `Qwen/Qwen3.8-27B`, `Qwen/Qwen3.8-Flash-Next`, `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Pro-0813`, `deepseek-ai/DeepSeek-V4.1-Flash`, `ZhipuAI/GLM-4.7-Flash`, `ZhipuAI/GLM-5.2` | yes | no |\n| `deepinfra` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes |\n| `together` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash`, `thinkingmachines/inkling` | yes | yes |\n| `openai-codex` | `gpt-5.5` | yes | yes |\n| `anthropic` | `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5`, `claude-haiku-4-5` | yes | yes except `claude-haiku-4-5` |\n| `openmodel` | `deepseek-v4-flash`; provider-scoped custom model IDs pass through | yes | model-dependent |\n| `sakana` | `fugu`, `fugu-ultra-20260615` | yes | yes for `fugu-ultra-20260615` |\n| `longcat` | `LongCat-2.0` | yes | yes |\n| `opencode-go` | `deepseek-v4-pro`, `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | yes | yes |\n| `meta` | `muse-spark-1.2` | yes | yes |\n| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |\n| `google` | `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |\n| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |\n| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |\n| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |\n\nAtlasCloud keeps the same default model as the config layer and adds\nprovider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs\nshould still be selected through `ATLASCLOUD_MODEL`, config, or live model\nlisting when available.\n\n## Capability Metadata\n\n`codewhale doctor --json` exposes the `capability` object. It is static\nmetadata, not a live API probe. Current fields are:\n\n`resolved_provider`, `resolved_model`, `context_window`, `max_output`,\n`thinking_supported`, `cache_telemetry_supported`, and `request_payload_mode`.\n\nWhen configuration cannot be loaded or validated, `doctor --json` exits\nnonzero and prints a bounded, secret-redacted JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"` instead of emitting\nmisleading route or capability metadata.\n\nMost shipped providers use the Chat Completions request payload mode. Native\nMessages routes, including `minimax-anthropic`, use `/v1/messages`, and\n`openai-codex` uses Responses.\n\nFor OpenAI-compatible gateways or self-hosted runtimes whose real window\ndiffers from the static table, set `[providers.] context_window = N`.\nThe configured value becomes the route-effective context window for prompts,\ncontext-pressure checks, compaction, and output-cap budgeting.\n\n`max_output` is optional and truthful: it is `null` (and omitted from the\ncapability struct on the wire) when the route publishes no output maximum we\ncan stand behind — the Kimi Code membership `kimi-for-coding` family is the\ncanonical example, since the membership catalog owns their limits. An unknown\noutput ceiling is never backfilled with a placeholder, and it applies **no**\ncompatibility clamp to a turn's requested `max_tokens`; only a concrete\nroute/offering maximum narrows the request. A model the catalogue simply has no\nrow for is a different fact — absence is not permission, so an uncatalogued id\nkeeps a conservative ceiling. The \"Max output metadata\" column below reads\n`unknown` wherever no documented maximum exists.\n\nThe exact Kimi Code membership roster contains `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. The two K3 ids share the\nsame reasoning and fixed-sampling contract; `k3-256k` stays at 262,144 tokens,\nwhile bare `k3` can use an entitled 1M override.\n\n| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |\n| --- | --- | --- | --- | --- | --- |\n| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses |\n| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |\n| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |\n| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| Volcengine Ark V4 model IDs | 1,000,000 | 384,000 | yes | yes | not documented in code |\n| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code |\n| Xiaomi MiMo `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Flash / Plus | 1,000,000 | 65,536 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 35B / 27B | 262,144 | 262,140 | yes | no | not documented in code |\n| OpenRouter Qwen 3.6 Max Preview | 262,144 | 65,536 | yes | no | not documented in code |\n| OpenAI API `gpt-5.5` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| OpenAI API `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | 1,050,000 | 128,000 | yes | no | not documented in code |\n| Anthropic API `claude-opus-5`, `claude-opus-4-8`, `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-fable-5` | 1,000,000 | 128,000 | yes | yes | not documented in code |\n| Google Gemini API `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-3.1-pro-preview`, `gemini-2.5-pro`, `gemini-2.5-flash` | 1,048,576 | 65,536 | model-dependent | no | not documented in code |\n| Meta Model API `muse-spark-1.2` | 1,000,000 | 32,000 | yes | no | not documented in code |\n| ChatGPT plan route (`openai-codex`) | conservatively budgeted; account listing does not state a window | no output cap sent in preview | model dependent | no | public `/v1/responses`; no context limit is inferred from account eligibility |\n| OpenModel default/custom model IDs | 200,000 fallback unless model metadata or config overrides it | 64,000 fallback | model-dependent | no | route uses Messages payload at `/v1/messages` |\n| Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | unknown (no documented maximum) | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-thinking` | 262,144 | 262,144 | yes | no | not documented in code |\n| Direct Arcee API `trinity-large-preview` | 262,144 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Direct Moonshot `kimi-k3` | 1,048,576 | 1,048,576 documented maximum; 131,072 provider default | yes | no | exact route uses `max_completion_tokens` and omits fixed sampling fields ([K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart)) |\n| Kimi Code membership `k3` | 262,144 safe baseline; 1,048,576 with an explicit entitled-plan override | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Kimi Code membership `k3-256k` | 262,144 fixed | 131,072 conservative default ceiling; membership maximum is not published | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog |\n| Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route |\n| Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price |\n| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` |\n| Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code |\n| Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling |\n| Direct MiniMax `MiniMax-M3` | 1,000,000 | 524,288 | yes | no | not documented in code |\n| Direct MiniMax M2.x models | 204,800 | unknown until MiniMax output metadata is promoted | yes | no | not documented in code |\n| MiniMax Messages route (`MiniMax-M3`, `MiniMax-M2.7`) | model-specific values above | model-specific values above | yes | no | route uses `/anthropic/v1/messages` |\n| Generic `openai` and AtlasCloud | 128,000 | unknown (no documented maximum) | no in doctor capability metadata | no | not documented in code |\n| Ollama | 8,192 | unknown (no documented maximum) | no | no | not documented in code |\n| Hugging Face Inference Providers V4 model IDs | 131,072 | unknown (no documented maximum) | yes | no | not documented in code |\n| Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | unknown (no documented maximum) | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only |\n\nMiniMax M3 uses input-length and service tiers. Codewhale omits\n`service_tier`, so requests use the standard tier and cost estimates select the\ncorrect standard rate from total input usage. Priority rates are listed to keep\nthe official tier structure visible. Prices are USD per million tokens.\n\n| Model / service tier | Input length | Input | Output | Cache read | Cache write |\n| --- | --- | ---: | ---: | ---: | ---: |\n| `MiniMax-M3` standard | up to 512,000 input tokens | $0.30 | $1.20 | $0.06 | not published |\n| `MiniMax-M3` standard | over 512,000 input tokens | $0.60 | $2.40 | $0.12 | not published |\n| `MiniMax-M3` priority | up to 512,000 input tokens | $0.45 | $1.80 | $0.09 | not published |\n| `MiniMax-M3` priority | over 512,000 input tokens | $0.90 | $3.60 | $0.18 | not published |\n| `MiniMax-M2.7` standard | all supported inputs | $0.30 | $1.20 | $0.06 | $0.375 |\n\nThese values come from the [MiniMax pay-as-you-go pricing\nguide](https://platform.minimax.io/docs/guides/pricing-paygo). M3 thinking is\nadaptive or disabled; the OpenAI-compatible API defaults to adaptive and the\nAnthropic-compatible API defaults to disabled. M2.7 thinking cannot be\ndisabled. Codewhale sends explicit controls when the user selects a reasoning\nmode.\n\nTool-call support is tracked separately by the static `ModelRegistry` and by\nthe endpoint's ability to accept OpenAI-compatible `tools` payloads. A custom\nOpenAI-compatible or local endpoint can still reject tool calls even if\nCodewhale can send the schema.\n\n### Hugging Face Inference Providers Notes\n\nThe shipped Hugging Face route targets the OpenAI-compatible Inference Providers\nrouter at `https://router.huggingface.co/v1`. Configure auth with\n`HUGGINGFACE_API_KEY` first, or `HF_TOKEN` as a fallback. Configure the endpoint\nwith `HUGGINGFACE_BASE_URL` first, or `HF_BASE_URL` as a fallback; configure the\nmodel with `HUGGINGFACE_MODEL` first, or `HF_MODEL` as a fallback.\n\nThis route does not imply Hub browsing, model-card metadata, dataset access,\nJobs, uploads, or export. Those remain explicit Model Lab work items so\nprovider auth and artifact movement stay separate.\n\n### When a Local Model Prints Tool JSON\n\nCodewhale only executes tools when the provider returns Chat Completions\n`tool_calls` or streamed `delta.tool_calls`. If a local model prints text such\nas `{\"name\":\"File\",\"arguments\":{\"action\":\"search_content\",...}}` in the\nassistant message, that is ordinary model output, not an executable tool\nrequest.\n\nFor OpenAI-compatible or local runtimes, check:\n\n- The endpoint accepts the `tools` array in `/v1/chat/completions` requests.\n- The selected model or chat template is configured for function/tool calls.\n- The server returns `tool_calls` in the response rather than plain JSON text.\n- The compatibility layer does not strip tools before forwarding the request.\n- If in doubt, test a small `File` `read` or `search_content` action against a\n known tool-calling model before debugging Codewhale's tool registry.\n\nChanging `provider`, `base_url`, or `model` can select a route that supports the\nOpenAI-compatible payload shape, but Codewhale cannot convert arbitrary JSON\ntext into a trusted tool call after the model has emitted it as prose.\n\nDeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on 2026-07-24 at\n15:59 UTC. Codewhale migrates either name to `deepseek-v4-flash` before a\nrequest reaches DeepSeek's first-party OpenAI or Anthropic endpoint. If no\nreasoning tier was configured, `deepseek-chat` also migrates to `off` and\n`deepseek-reasoner` to `high`, preserving their former non-thinking / thinking\nintent; an explicit `reasoning_effort` remains authoritative. The mapping is\ndeliberately not global: Wanjie Ark, aggregators, self-hosted runtimes, and\ncustom endpoints continue to own their model ids.\n\n## Reasoning Effort\n\n`/reasoning ` (and the `reasoning_effort` config key) is translated to\neach provider's wire dialect by the client before the request is sent. `off`\ndisables thinking where the route supports it. Both exact K3 routes map `off`\nto their lowest supported tier, `low`, and the model is never switched to\nsatisfy `off` — but they do so for different reasons:\n\n- **Kimi Code membership K3** (exact `https://api.kimi.com/coding/v1` with\n `model = \"k3\"` or `model = \"k3-256k\"`) — the membership roster declares K3 always-thinking, so `off`\n cannot be honored without changing what the model is. The clamp preserves the\n fixed K3 identity.\n- **Direct Moonshot K3** (exact `https://api.moonshot.ai/v1` with\n `model = \"kimi-k3\"`) — this clamp is *defensive*, not a documented contract.\n The direct platform publishes no `off` state for K3, and Codewhale will not\n assert a fixed-thinking guarantee it cannot verify for a given key's\n entitlement, so the requested `off` is normalized to the lowest tier with the\n live entitlement left unknown.\n\nNormal dispatched\n`auto` uses Codewhale's auto-reasoning selector and sends a concrete tier;\nonly an omitted reasoning setting leaves the provider default in control.\nProviders marked \"omitted\" receive no reasoning fields at all for that tier.\n\n| Provider | `off` | `low`/`medium`/`high` | `max`/`xhigh` |\n| --- | --- | --- | --- |\n| `deepseek`, `deepseek-cn`, `siliconflow`, `siliconflow-CN`, `sglang`, `volcengine`, `atlascloud` | `thinking: {type: disabled}` | `reasoning_effort: \"high\"` + `thinking: {type: enabled}` | `reasoning_effort: \"max\"` + `thinking: {type: enabled}` |\n| `openrouter`, `novita`, other `together` models | `thinking: {type: disabled}` | `reasoning_effort` pass-through + `thinking: {type: enabled}` | `reasoning_effort: \"xhigh\"` + `thinking: {type: enabled}` |\n| `together` + `thinkingmachines/inkling` | `reasoning_effort: \"none\"` | exact `minimal`/`low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| Direct Moonshot `kimi-k3` at exact `https://api.moonshot.ai/v1` | top-level `reasoning_effort: \"low\"` (effective normalization) | top-level `reasoning_effort: \"low\"` / `\"high\"` (`medium` becomes `high`) | top-level `reasoning_effort: \"max\"` |\n| Kimi Code membership `k3`, `k3-256k` at exact `https://api.kimi.com/coding/v1` | `thinking: {type: enabled, effort: \"low\"}` (effective normalization) | `thinking: {type: enabled, effort: \"low\" | \"high\"}` | `thinking: {type: enabled, effort: \"max\"}` |\n| Other `moonshot` routes | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| `ollama` | `think: false` | `think: true` | `think: true` |\n| `ollama-cloud` | `reasoning_effort: \"none\"` | exact `low`/`medium`/`high` `reasoning_effort` | `reasoning_effort: \"max\"` |\n| `xiaomi-mimo` | `thinking: {type: disabled}` | `thinking: {type: enabled}` | `thinking: {type: enabled}` |\n| First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable |\n| First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: \"high\"` | enabled thinking + `reasoning_effort: \"max\"` |\n| First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable |\n| Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable |\n| `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"high\"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: \"max\"` |\n| `vllm` | `chat_template_kwargs.enable_thinking: false` | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort` low/medium/high | `chat_template_kwargs.enable_thinking: true` + `reasoning_effort: \"high\"` (vLLM has no max tier) |\n| `arcee`, `huggingface` | omitted | `reasoning_effort` pass-through | `reasoning_effort: \"high\"` |\n| `fireworks` | omitted | `reasoning_effort: \"high\"` | `reasoning_effort: \"max\"` |\n| `openai`, `wanjie-ark`, `telecomjs` | omitted | omitted | omitted |\n| `openmodel` | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration | Anthropic Messages adapter handles thinking/output configuration |\n| `openai-codex` | Responses API `reasoning` field (handled by the Responses bridge) | Responses API `reasoning` field | Responses API `reasoning` field |\n\nAtlasCloud serves DeepSeek models, so it speaks the DeepSeek reasoning dialect,\nincluding the `max` tier (#3024).\n\nOn the exact MiniMax OpenAI-compatible Chat endpoints, `MiniMax-M3` uses\n`max_completion_tokens`. Other MiniMax models and compatible gateways retain\n`max_tokens`; the MiniMax Anthropic endpoints use the separate Messages\nadapter.\n\n## Drift Check\n\nRun this before changing provider IDs, provider TOML tables, static model\nregistry rows, or provider default strings:\n\n```bash\npython3 scripts/check-provider-registry.py\n```\n\nThe check fails when:\n\n- `docs/PROVIDERS.md` omits a canonical `ProviderKind::as_str()` ID.\n- Descriptor presentation IDs or released wire tags are duplicated or drift\n from intrinsic kinds, or a second provider enum/ordinal bridge is introduced.\n- The shipped-provider table omits or adds a `[providers.*]` TOML table.\n- The static model registry table drifts from providers used by\n `crates/agent/src/lib.rs`.\n- A provider default model or base URL constant in `crates/tui/src/config.rs`\n is no longer mentioned here.\n\n## Provider Gotchas\n\nThree failures that look like something else (measured by a provider operator\non live traffic):\n\n- **A cold model looks broken.** The first call to a model the provider has not\n loaded can take 90 to 150 seconds, then drop to a second or less once warm.\n Raise `[stream].open_timeout_secs` (default 45 seconds, at most 300) before reading a timeout as \"model unavailable\".\n- **Listed is not callable.** A catalog entry can be visible and still fail\n every call. The same error text on every attempt (for example a \"resources\n busy\" 502) is not transient: a genuinely busy upstream returns successes in\n between. Probe a model before trusting it, and stop retrying one that fails\n identically.\n- **A truncated answer can report `stop`.** A reasoning model can spend the\n whole `max_tokens` allowance on reasoning. When the provider reports\n `completion_tokens` exactly equal to the `max_tokens` the request asked for\n and the finish reason is `stop`, Codewhale treats the turn as cut at the\n ceiling rather than finished (`stop_contradicts_output_ceiling` in\n `codewhale-models`); retrying the same prompt stops at the same count, so\n raise the ceiling or shorten the task instead. No inference is made when the\n provider reports no usage.\n\n## Planned, Not Shipped Yet\n\nThese items belong to the v0.8.48+ provider-abstraction milestone or related\nprovider docs work, but they are not native shipped behavior in this checkout:\n\n- A unified `Provider` trait in `codewhale-agent` that owns env precedence,\n secret resolution, base URL normalization, auth-header construction, and\n provider metadata. Those responsibilities are still split across\n `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`.\n- Hugging Face model passport metadata in the picker, including license, base\n model, context length, chat template, tool-call support, reasoning support,\n and gated/private status.\n\n## Sign in with Claude\n\nThis is an experimental 0.10.2 candidate flow; live sign-in, inference and\nprovider authorization are not release-qualified.\n\nRun `codewhale auth claude` (or `/auth claude` in a running session), open the\nprinted Claude sign-in URL, and paste the complete `code#state` callback.\n`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.\nThen select `anthropic` or its `claude` alias. API keys continue to use separate\nAnthropic API billing; selecting subscription sign-in never falls back to one.\n\nCodewhale stores the grant in its private credentials directory, commits the\nprovider's credential pointer atomically, and refreshes it before requests on a\nworker. Subscription tokens go only to `https://api.anthropic.com`; a custom\nendpoint is rejected. Each request keeps the existing Messages adapter, tool\nprotocol, cache prefix, and Codewhale inference-client identity. The login\nregistration is a separate fact: this candidate uses Claude Code’s OAuth\nclient ID, not a Codewhale-issued registration. No Claude Code credential\nfile or keychain entry is read or modified.\n\n`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local\ngrant. End remote access separately in your Claude account settings. The\nsubscription route then needs another login; select API-key billing explicitly\nto use an API key. Login and inference follow Anthropic's provider-side\navailability; local checks do not\nestablish that an account is eligible or that live subscription inference works.\n\nReference refreshed on 2026-10-13: upstream Pi is now `earendil-works/pi`.\nIts [v1.2.0 OAuth adapter](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/auth/oauth/anthropic.ts)\nsupports loopback and copy-code flows and uses `platform.claude.com` token and\ncopy-code endpoints; this candidate still uses `console.anthropic.com`.\nPi also uses [Claude Code inference identity](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/api/anthropic-messages.ts),\nwhich Codewhale does not adopt. Pi's implementation is a compatibility\nreference, not proof of account eligibility, billing behavior or permission\nto offer the same flow. See Anthropic's current\n[authentication guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)\nand [Agent SDK plan guidance](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan).\n" }, { "path": "docs/MODEL_LAB.md", @@ -64,8 +64,8 @@ "sources": [ { "path": "docs/CONFIGURATION.md", - "sha256": "72303118597855d7866b8c9c998c4d48106850868d17883c1ea7f58dcde4adf3", - "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" + "sha256": "f61542d1de3cd954591e3e4d686a1639d1327016c9e26cf9b92cefedcc5501a3", + "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nA custom table sends Chat Completions requests by default. Add\n`wire = \"responses\"` to send OpenAI Responses requests to\n`/responses` instead, or `wire = \"anthropic\"` for Anthropic Messages:\n\n```toml\n[providers.my-gateway]\nkind = \"openai-compatible\"\nbase_url = \"https://gateway.example/v1\"\napi_key_env = \"MY_GATEWAY_API_KEY\"\nmodel = \"gpt-5.6\"\nwire = \"responses\"\n```\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" }, { "path": "docs/LEGACY_PATHS.md", @@ -96,8 +96,8 @@ }, { "path": "docs/CONFIGURATION.md", - "sha256": "72303118597855d7866b8c9c998c4d48106850868d17883c1ea7f58dcde4adf3", - "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" + "sha256": "f61542d1de3cd954591e3e4d686a1639d1327016c9e26cf9b92cefedcc5501a3", + "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nA custom table sends Chat Completions requests by default. Add\n`wire = \"responses\"` to send OpenAI Responses requests to\n`/responses` instead, or `wire = \"anthropic\"` for Anthropic Messages:\n\n```toml\n[providers.my-gateway]\nkind = \"openai-compatible\"\nbase_url = \"https://gateway.example/v1\"\napi_key_env = \"MY_GATEWAY_API_KEY\"\nmodel = \"gpt-5.6\"\nwire = \"responses\"\n```\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" } ] }, @@ -224,8 +224,8 @@ }, { "path": "docs/CONFIGURATION.md", - "sha256": "72303118597855d7866b8c9c998c4d48106850868d17883c1ea7f58dcde4adf3", - "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" + "sha256": "f61542d1de3cd954591e3e4d686a1639d1327016c9e26cf9b92cefedcc5501a3", + "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nA custom table sends Chat Completions requests by default. Add\n`wire = \"responses\"` to send OpenAI Responses requests to\n`/responses` instead, or `wire = \"anthropic\"` for Anthropic Messages:\n\n```toml\n[providers.my-gateway]\nkind = \"openai-compatible\"\nbase_url = \"https://gateway.example/v1\"\napi_key_env = \"MY_GATEWAY_API_KEY\"\nmodel = \"gpt-5.6\"\nwire = \"responses\"\n```\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" } ] }, @@ -256,8 +256,8 @@ }, { "path": "docs/CONFIGURATION.md", - "sha256": "72303118597855d7866b8c9c998c4d48106850868d17883c1ea7f58dcde4adf3", - "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" + "sha256": "f61542d1de3cd954591e3e4d686a1639d1327016c9e26cf9b92cefedcc5501a3", + "content": "# Configuration\n\ncodewhale reads configuration from a TOML file plus environment variables.\nAt process startup it may also load literal built-in-provider credentials from\na workspace-local `.env` file. Use the tracked `.env.example` as the template;\ncopy it to `.env`, then add only credential values.\n\nA workspace is not configuration authority. Codewhale therefore ignores\nconfig/profile/home paths, provider/model/base-URL routing, MCP/plugin state,\napproval/sandbox/shell posture, executable paths, runtime settings, and every\nother non-credential `.env` entry. Variable expansion is rejected so a\nrepository cannot substitute an ambient secret into a credential value. Use\n`config.toml`, CLI flags, or values exported by the launching shell for those\nexplicit control-plane settings. `.env` is read from a stable regular-file\nhandle, is capped at 1 MiB, and symbolic links, reparse points, and multiply\nlinked files are rejected.\n\n## Reading and checking configuration from the CLI\n\n`codewhale config get ` reads scalar keys, whole tables such as `tools`,\nand nested paths such as `tools.user_input_timeout_seconds`. Displayed tables\nand nested values apply the same recursive credential redaction as `config dump`.\n\n`config set` supports its named scalar keys and the provider, route, and\nnotification commands. It refuses a key that nothing reads and suggests the\nnearest real key (`config set calm_mod on` names `calm_mode`). A settings.toml\nkey such as `calm_mode` or `tool_collapse` is validated and written to the\nuser-global settings.toml, not config.toml; `config get` reads it back from\nthere even when an old config.toml copy (which nothing reads) is still present,\nand names that copy so you can `config unset` it. Settings keys have no project\nscope, so `--project` refuses them. A config.toml key is checked against the\ntype its reader expects and stored as a TOML boolean or number where the\nreader needs one (`yolo = true`, `max_subagents = 4`); `reasoning_effort`\naccepts the same aliases as `/effort` (#6563). Other dotted writes fail before\nmodifying the file and name the TOML table to edit. For example, set a tools\ntimeout in the file as:\n\n```toml\n[tools]\nuser_input_timeout_seconds = 0\n```\n\n`codewhale config doctor` checks credential presence and endpoint shape, and\nwarns about each config.toml root key that nothing reads: a settings.toml key\nleft in config.toml is named as misplaced, and any other unread key gets a\ndid-you-mean. Keys read by any runtime reader are not reported. The warnings do\nnot fail the check, and a clean result does not validate every runtime setting's\nvalue (#6083, #6563).\n\n## Constitution, project instructions, and repo authority\n\nCodewhale has several instruction surfaces. They are deliberately separate so a\npersonal constitution, repo policy, project instructions, and runtime security\ncontrols do not blur together.\n\n- **Bundled global Constitution** — the compiled base law in the binary. It is\n the default floor for every session.\n- **User-global constitution** — the normal guided setup output. Manage it with\n `/constitution` or `/setup`; Codewhale stores structured data at\n `$CODEWHALE_HOME/constitution.json` (default `~/.codewhale/constitution.json`)\n and renders it into a separate `` prose block.\n This can express preferences and stop conditions, but it does not change\n runtime approval policy, sandbox, shell, network, trust, or MCP permissions.\n- **Repo-local constitution** — optional project policy in\n `.codewhale/constitution.json`, described below.\n- **`AGENTS.md`** — cross-agent **project instructions** (prose). This is the\n canonical file for \"how should an agent work in this repo.\" Run `/init` to\n scaffold one. `CLAUDE.md` and `.claude/instructions.md` are read as\n compatibility fallbacks.\n- **Memory and handoffs** — recalled state. Useful, but lower authority than\n constitutions and project instructions.\n\n### Managing the user-global constitution (`/setup` and `/constitution`)\n\nThe bundled **working agreement** is the safe default and no longer adds a\nrequired first-run screen. Customize it later through `/constitution` or the\nprogressive `/setup` guide. Provider/model readiness, workspace trust, and\nruntime posture stay separate from this guidance.\n\nOn the **Working agreement** (constitution) step:\n\n- **`1`–`6`** tune the guided draft. **`G`** previews it, and **`G`** again\n ratifies and saves a fresh structured `constitution.json`.\n- **`A`** (shown only when a provider is configured) asks your first configured\n model to draft the constitution. Drafting is **not** saving: the draft is\n rendered through the same preview and you still press **`G`** to ratify\n before anything persists.\n- **`K`** keeps your existing loaded constitution unchanged (shown only when a\n valid file is already present).\n- **`U`** (or `/constitution bundled`) records the bundled/default law.\n\n`/constitution` (alias `/law`) is the primary management surface once you are\nset up. Subcommands: `status` (the default), `preview`, `review`, `repo` (the\nrepo-local law block), `explain`, `base`, `suggestions`, `ratify `,\n`migrate`, `rollback`, `edit`/`guided`, `repair`, `posture`, and `bundled`.\nManaging the constitution never changes runtime approval, sandbox,\nshell, network, trust, default mode, or MCP authority — those stay in runtime\nposture/config.\n\nEach repo can carry two distinct, complementary files:\n\n- **`AGENTS.md`** — ordinary project working instructions.\n- **`.codewhale/constitution.json`** — Codewhale-specific **repo authority /\n prioritization policy**: when local sources conflict, which should Codewhale\n trust first, and what to verify before claiming a task is done. `.codewhale/`\n lives inside the repo (like `.github/`). Example:\n\n ```json\n {\n \"schema_version\": 1,\n \"authority\": [\n \"current user request\",\n \"live code and tests\",\n \"GitHub issue/PR details\",\n \"AGENTS.md\",\n \"memory\",\n \"old handoffs\"\n ],\n \"protected_invariants\": [\n \"do not break old-session transcript replay\"\n ],\n \"branch_policy\": \"PRs target the integration branch, not main\",\n \"verification_policy\": {\n \"before_claiming_done\": [\"run focused tests\", \"read changed files back\"]\n },\n \"escalate_when\": [\n \"a destructive action was not explicitly authorized\"\n ]\n }\n ```\n\n All fields are optional. When present, the file is rendered into the system\n prompt as concise prose in a higher-authority block. Legacy `WHALE.md` files\n are ignored and reported as migration-only diagnostics.\n\n Each `protected_invariants` entry may be either a plain string (advisory\n prose, the historical shape) or an object carrying path globs, which is\n additionally **mechanically enforced** in the tool gate. See\n [Enforced repo-law invariants](#enforced-repo-law-invariants) below.\n\n This is the **repo-local law** layer. When guidance conflicts, the\n **Whose word wins** section of the effective base constitution owns the\n ordering; the bundled source is\n [`BASE_PROMPT`](../crates/tui/src/prompts/text.rs). Use `/constitution base`\n to inspect the effective base, including any opted-in expert override.\n The order in which these files are described or assembled is not an\n authority ranking. Runtime policy (permissions, sandbox and cost limits\n enforced in code) is separate from prompt guidance; editing a constitution\n does not grant permissions.\n\n> **`WHALE.md` is deprecated.** It overlapped confusingly with `AGENTS.md`.\n> Codewhale no longer reads `WHALE.md` as project or global context. If one is\n> present, setup/context diagnostics report it as ignored so you can migrate it.\n> Move ordinary instructions to `AGENTS.md` and Codewhale-specific authority\n> policy to `.codewhale/constitution.json`. Personal standing guidance belongs\n> in `/constitution` / `$CODEWHALE_HOME/constitution.json`. (The global\n> Codewhale Constitution shipped in the model prompt is a separate thing and is\n> unaffected.)\n\n### Enforced repo-law invariants\n\nBy default a `protected_invariants` entry is advisory prose: it is rendered into\nthe prompt as guidance the agent should honor, but nothing stops a write. An\nentry written as an **object with `paths`** is different — it compiles into a\nmechanical write hold that the engine's tool gate evaluates before the write\nruns. The law becomes mechanism, not just a request.\n\nAn enforced entry has this shape:\n\n```json\n{\n \"schema_version\": 1,\n \"protected_invariants\": [\n \"Keep DeepSeek support first-class.\",\n {\n \"text\": \"The wire format is frozen; protocol changes need a human.\",\n \"paths\": [\"crates/protocol/**\"],\n \"action\": \"block\"\n },\n {\n \"text\": \"Release notes need human review.\",\n \"paths\": [\"CHANGELOG.md\"],\n \"action\": \"ask\"\n }\n ]\n}\n```\n\n- `text` — required. The reason surfaced on the hold. An empty `text` is skipped.\n- `paths` — workspace-relative globs (globset syntax, e.g. `crates/protocol/**`,\n `**/secrets.toml`, `CHANGELOG.md`). An object with no usable `paths` stays\n advisory-only despite the object shape.\n- `action` — optional, defaults to `ask`. `ask` force-prompts in Ask and\n Auto-Review; in Full Access it denies the protected write without opening a\n modal. `block` **denies the write outright** in every posture.\n\nSemantics:\n\n- **Tighten-only.** The schema has no allow/widen shape, so law can only *add*\n holds — a crafted constitution can never grant authority or weaken a gate\n above it.\n- **Not bypassable by mode.** Like the built-in safety floor, an `ask` hold\n force-prompts in Ask and Auto-Review. Full Access never opens approval\n modals, so the same hold fails closed as a hard block; `block` always denies.\n Mode cannot turn a hold off.\n- **Repo-local only.** Only the repo's `.codewhale/constitution.json`\n participates. The user-global constitution stays advisory prose and never\n reaches this mechanism.\n- **Fails safe.** A missing file, parse error, or invalid glob degrades to\n fewer or zero rules — never a hold on unprotected paths and never a poisoned\n gate. Across matches the strongest action wins, so `block` outranks `ask`.\n- **Leaves a receipt.** Every hold emits a `tool.repo_law_decision` tool-audit\n event naming the invariant, the matched path, and the source file; the\n approval/denial reason names the invariant too.\n\n**Coverage is deliberately limited.** Holds are evaluated only for the write\ntools `write_file`, `edit_file`, `apply_patch`, and `fim_edit`, and only\nagainst the filesystem targets named in their inputs (`path`/`target`/\n`destination`/`file_path`, `changes[].path`, and unified-diff /\n`apply_patch`-envelope headers). A shell command that writes a protected path is **not** held by\nrepo law — those writes are still governed by the ordinary approval, sandbox,\nand shell-write gates, not by this mechanism.\n\n### Expert full base-prompt override (#3638)\n\nThe global Constitution (the base system prompt, normally compiled in from\n`crates/tui/src/prompts/text.rs` as `BASE_PROMPT`) can be replaced per-user\nwithout rebuilding. This is\nan expert escape hatch, not the normal `/constitution` guided setup output.\nBecause this is a prompt trust boundary, it takes **two deliberate steps** — a\nfile alone is not enough:\n\n1. Drop the replacement at `~/.codewhale/prompts/constitution.md` (under\n `$CODEWHALE_HOME` when set).\n2. Set the explicit opt-in flag `CODEWHALE_ALLOW_BASE_PROMPT_OVERRIDE=1`\n (`true`/`on`/`yes` also accepted).\n\nIf the file exists but the flag is unset, the override is **ignored** (with a\nlog line pointing to the flag) and the bundled Constitution stays in place.\nThis is intended for repurposing the TUI beyond software engineering — e.g.\nlong-form writing or document review — where the engineering-oriented base\nprompt is a poor fit. It is loaded once at startup; a **missing or empty file\nis a no-op**, so existing installs keep the bundled prompt.\n\nScope is deliberately narrow: only the byte-stable **base prompt segment** is\noverridable. Mode deltas, the approval policy, the tool taxonomy, Context\nManagement, and the Compaction Relay are still owned by Codewhale's runtime\nassembly, so an override **cannot remove safety-relevant guidance** (sandbox,\napprovals) — it only swaps the task/voice framing. To customize ordinary\npersonal behavior, prefer `/constitution`; to customize per-repo behavior,\nprefer `AGENTS.md` + `.codewhale/constitution.json` above.\n\n## Where It Looks\n\nDefault config path:\n\n- `~/.codewhale/config.toml`\n- Legacy fallback: `~/.deepseek/config.toml`\n\nOverrides:\n\n- CLI: `codewhale --config /path/to/config.toml`\n- Env: `CODEWHALE_CONFIG_PATH=/path/to/config.toml`\n- Legacy env alias: `DEEPSEEK_CONFIG_PATH=/path/to/config.toml`\n\nIf both are set, `--config` wins. Environment variable overrides are applied after the file is loaded.\n\n### TUI editability audit\n\nInside the TUI, run `/config audit` to see which documented keys can be changed\nfrom the current session, which ones can also be persisted, and which ones stay\nfile-only or restart-only. The audit includes current values for the high-impact\nruntime controls such as `approval_policy`, `allow_shell`,\n`stream_chunk_timeout_secs`, `base_url`, `mcp_config_path`, and the\n`[subagents]` concurrency/depth/timeout keys.\n\nUse the command's \"Command / reason\" column as the source of truth before\nediting by hand. For example, `/config approval_mode on-request --save` writes\ntop-level `approval_policy = \"on-request\"`, while provider base URLs are saved\nbut still require restarting the model client.\n\n### User workspace entries\n\nInteractive Agent sessions expose shell tools by default with approval gating\nunless you explicitly disable them. For a shell opt-in that should live in the\nuser's global config for noninteractive or durable-task profiles rather than in\nthe repository, add a workspace-scoped entry:\n\n```toml\n[workspace.'/absolute/path/to/project']\nallow_shell = true\n```\n\nThe entry applies only when the launched workspace path matches the table key.\nThe legacy `[projects.\"/absolute/path/to/project\"]` table is also accepted for\nthis user-owned override.\n\nIn interactive mode, the per-project overlay\n`/.codewhale/config.toml` is applied after this user entry. A\nproject-level `allow_shell = false` can still tighten the session; project-level\n`allow_shell = true` is ignored.\n\n### Per-project overlay (#485)\n\nWhen the TUI starts in a workspace that contains a regular-file\n`/.codewhale/config.toml`, the safe values declared in that file are\nmerged on top of the global config. Legacy\n`/.deepseek/config.toml` files are still read when the Codewhale path\nis absent. Symlinked project config files are rejected. This lets a repo suggest\na model or tighten local safety posture without touching the user's\n`~/.codewhale/config.toml`. Pass `--no-project-config` to skip the overlay for\none launch.\n\nSupported keys in the project overlay (top-level fields only):\n\n| Key | Effect |\n|---|---|\n| `model` | override `default_text_model` |\n| `reasoning_effort` | force `\"high\"` / `\"max\"` for a complex repo |\n| `approval_policy` | only values that tighten the user's current permission posture |\n| `sandbox_mode` | only values that tighten the user's current sandbox posture |\n| `max_subagents` | clamp sub-agent concurrency for a constrained repo (clamped to 1..=128) |\n| `allow_shell` | `false` can disable shell access; `true` is ignored |\n\nThe overlay is intentionally narrow — it covers the fields a repo\nmaintainer is most likely to want to standardize across contributors.\nCredential, endpoint, provider-selection, MCP config, hooks, skills,\nretry, hotbar bindings, and `instructions = [...]` settings stay user-global.\nIf a repo-local config declares `api_key`, `base_url`, `providers`, `provider`,\n`mcp_config_path`, `notes_path`, `hotbar`, `allow_shell = true`, or `instructions`,\nCodewhale ignores that key and keeps the user's global setting.\n\nThe consolidated `codewhale` runtime uses `~/.codewhale/config.toml` for\nprovider and model settings. `codewhale auth set --provider deepseek` saves\nthe key in the local secret store (a private file under `~/.codewhale/secrets/`\nby default; the OS keychain is an explicit option), rather than writing it\ninto that config file. Legacy `~/.deepseek/config.toml` is migrated on first\nlaunch when needed. `codewhale --model deepseek-v4-flash` is forwarded to the\nTUI as `CODEWHALE_MODEL`. The dispatcher no longer writes `DEEPSEEK_*` twins;\na value you set yourself remains a legacy alias when its `CODEWHALE_*`\nequivalent is unset.\n\n### Account provider keys\n\nA Codewhale account lets you **manage your provider API keys in one place**.\n[Register](https://app.codewhale.net/register) or [sign in](https://app.codewhale.net/login),\nthen add, replace or remove keys in [Providers settings](https://app.codewhale.net/providers)\nor from the terminal:\n\n```bash\ncodewhale login # browser sign-in; also: account login\ncodewhale account keys set deepseek # hidden prompt; add or replace a key\ncodewhale account keys list # configured status, never secret values\ncodewhale --provider codewhale # use the account's model route\n```\n\nAccount keys stay on the service and are used for account-routed requests.\nSign in on another device and choose Codewhale in `/provider` to use them\nwithout pasting keys again. `/models` lists models available to that account.\nReplacing a saved account key affects subsequent requests through this route;\nit does not change independent local copies or revoke keys at the provider.\n`codewhale account keys remove deepseek` removes the account's saved key.\n\nLogin does **not** upload existing local keys. If you want to copy one, choose\n`codewhale account keys set deepseek --from-local` explicitly. Normal local\nprovider setup remains `codewhale auth set --provider `; local keys\nand local models work without a Codewhale account. Signing in preserves an\nexplicit or already configured local provider. Choose `--provider codewhale`\nwhen you want the account route instead.\n\nThe account session is separate from provider keys. `codewhale account status`\ninspects it; `codewhale account logout` removes it for the selected `--profile`\nwithout removing your provider keys. Account sessions use the private `0600`\nCodewhale secrets file, scoped to the profile and account API origin; they do\nnot use the OS keychain. The older `codewhale cloud ...` spelling remains an\nalias for account commands.\n\n### Portable config bundles\n\n`codewhale config export --portable [--project] [--out FILE]` writes a\nportable, secret-free bundle of your configuration: sorted TOML with\ncredential and machine-specific keys (API keys, base URLs, socket paths)\ndropped, never a redacted placeholder in their place. Without `--out` the\nbundle goes to stdout. Typed tables, arrays, numbers, booleans, and datetimes\nremain typed. Machine-bound authority is deliberately non-portable: project\ntrust overlays, credential readers, auto-running hooks, executable LSP\ndefinitions, and local path bindings are omitted rather than copied to a new\nhost. So is trust posture (`yolo`, `allow_shell`, `approval_policy`,\n`sandbox_mode`, `sandbox_network_access`, and the sandbox read-denylist\npaths) and local endpoints or executables (`[lifecycle_outbox]`,\n`[extension_host]`, `[control_socket]`). URLs that carry a credential —\nuserinfo, a token-named query parameter, or a Slack/Discord/Teams webhook\npath — are treated as credentials.\n\n`codewhale config import [--dry-run] [--yes] [--project]`\napplies a bundle. The envelope is strict (`schema_version = 1`, kind\n`codewhale.portable-config`; unknown fields fail). Import prints a\ndeterministic plan — added / changed / skipped / conflicting / rejected —\nthen asks for consent unless `--yes` is given; headless use requires it.\nCredential-shaped entries are rejected by key name and by value shape;\nrejections name the field, never a value. Remote bundles come from HTTPS\nonly (loopback http excepted) with a 5 MiB cap. Application backs up the\ntarget document to `.bundle-backup--`, rolls back on any\nfailure, and re-importing an applied bundle changes nothing.\n\nImport also rejects the non-portable authority classes omitted by export,\nincluding nested/camel/dotted credential keys and cookie headers. This keeps a\nhand-authored or remote bundle from reintroducing machine trust, local\ncredential access, or automatically executable commands that a local export\nwould refuse to carry. Structured tables are deep-merged: a portable model or\npreference update does not erase target-local provider credentials, endpoints,\nhooks, or executable definitions that were deliberately omitted from the\nbundle. Arrays and scalar values still replace the corresponding portable\nvalue.\n\nSections map to scope: `[project]` entries land only in the workspace\ndocument (`--project`, which must target an actual workspace config),\n`[global]` only in the user-global one; `preferences`, `profiles`, and\n`plugins` apply at either scope. A global bundle operation refuses a workspace\ndocument just as a project operation refuses the user-global document.\n\n### Credential read precedence (#5197)\n\nCredential reads are **folder-independent by default**: every layer below is\nuser-global or process-scoped, so a key saved in one repo resolves identically\nin every other repo. Repo-local config never carries credential material — the\nproject overlay above reads only its allowlisted keys and ignores `api_key`,\nand a credential write aimed at a workspace-scoped config is rescoped to the\nuser-global `~/.codewhale/config.toml` (#5045, #5193).\n\nFor the active provider, the runtime resolves the API key in this exact order\n(first match wins):\n\n1. **Route-specific auth contract.** Routes whose `auth_mode` disables API\n keys stop here with no credential. OAuth routes use their explicitly\n granted token: the official `openai-codex` plan route accepts only\n Codewhale-owned credentials from `codewhale auth chatgpt`, bound to its\n issued registration and verified account. Imported Codex CLI and process\n tokens cannot authorize this route;\n `[providers.xai] auth_mode = \"oauth\"` reads Codewhale's own xAI\n device-login store (or a consent-granted Grok CLI file).\n2. **Explicit CLI key.** `--api-key` forwarded with its source marker wins\n over every saved slot.\n3. **Config file `api_key`.** The `[providers.] api_key` table slot for\n the active provider. `deepseek-CN` also reads `[providers.deepseek]`. An\n older top-level `api_key` is read as `[providers.deepseek] api_key` (see\n [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key)).\n File-owned keys stay bound to their file-owned endpoint: when the\n environment replaces the route's base URL with a custom host, the saved\n key is not sent there.\n4. **`api_key_env` binding.** `[providers.] api_key_env = \"VAR\"` reads\n the named environment variable. For custom providers an unset or empty\n binding is a loud error, not a silent fallback (#5104).\n5. **Secret store.** The durable per-provider slot written by\n `codewhale auth set` (file-backed under `~/.codewhale/secrets/` by\n default; the OS keyring only when explicitly selected). Skipped for named\n custom routes, self-hosted providers, custom endpoints other than an\n explicitly authenticated loopback, and routes whose `auth_mode` needs no\n key.\n6. **Ambient environment.** The provider's own variable\n (`DEEPSEEK_API_KEY`, `OPENROUTER_API_KEY`, `MOONSHOT_API_KEY`, …).\n Ambient keys are only ever sent to the provider's official endpoint and\n are skipped under the same conditions as the secret store.\n7. **Keyless fallback.** Self-hosted providers and loopback endpoints may run\n with no credential; every other route fails with provider-specific setup\n guidance.\n\nLegacy compatibility: `~/.deepseek/config.toml` is migrated into\n`~/.codewhale/config.toml` on first launch, `DEEPSEEK_*` environment\nvariables remain accepted aliases for the `CODEWHALE_*` forms, and\n`DEEPSEEK_SECRET_BACKEND` is the legacy alias for `CODEWHALE_SECRET_BACKEND`.\n\nRun `codewhale auth status` to inspect the active provider's config\nfile, OS keyring backend, environment variable, winning source, and last-four\nlabel without printing the key itself. The command only probes the active\nprovider's keyring entry.\n\nFor hosted, generic OpenAI-compatible, self-hosted, OpenAI Responses, or native\nAnthropic providers, set `provider = \"\"` or pass\n`codewhale --provider `. The canonical provider IDs are `deepseek`,\n`nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`,\n`openrouter`, `orcarouter`, `xiaomi-mimo`, `novita`, `fireworks`,\n`siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`,\n`ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`,\n`anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`,\n`sakana`, `longcat`, `opencode-go`, `opencode-zen`, `meta`, `xai`,\n`mistral`, `telecomjs`, `modelstudio-token-plan`, `google`,\n`edenai`, `concentrate`, `codewhale`, and `custom` (a user-defined OpenAI-compatible endpoint via\n`[providers.]`).\nFor the provider-by-provider registry, including wire protocol, auth variables,\ndefault base URLs, model IDs, and capability metadata, see\n[PROVIDERS.md](PROVIDERS.md).\nThe facade saves provider credentials in the local secret store and forwards\nthe resolved key, base URL, provider, and model to the TUI process. Use\n`codewhale auth set --provider nvidia-nim --api-key \"YOUR_NVIDIA_API_KEY\"` or\n`codewhale auth set --provider openai --api-key \"YOUR_OPENAI_COMPATIBLE_API_KEY\"` or\n`codewhale auth set --provider atlascloud --api-key \"YOUR_ATLASCLOUD_API_KEY\"` or\n`codewhale auth set --provider wanjie-ark --api-key \"YOUR_WANJIE_API_KEY\"` or\n`codewhale auth set --provider xiaomi-mimo --api-key \"YOUR_XIAOMI_KEY\"` or\n`codewhale auth set --provider fireworks --api-key \"YOUR_FIREWORKS_API_KEY\"` or\n`codewhale auth set --provider siliconflow --api-key \"YOUR_SILICONFLOW_API_KEY\"` or\n`codewhale auth set --provider arcee --api-key \"YOUR_ARCEE_API_KEY\"` or the\nmatching provider ID from [PROVIDERS.md](PROVIDERS.md) to save provider keys\nthrough the facade. The generic `openai` provider defaults\nto `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to\n`gpt-5.6`. A custom OpenAI-compatible gateway can still select its own model\nexplicitly. `atlascloud` defaults to\n`https://api.atlascloud.ai/v1`, accepts `ATLASCLOUD_BASE_URL`, and uses\n`deepseek-ai/deepseek-v4-flash` as its default model. `wanjie-ark` targets\nWanjie Ark's OpenAI-compatible endpoint at\n`https://maas-openapi.wanjiedata.com/api/v1`, defaults to `deepseek-reasoner`,\nand passes model IDs through unchanged because Wanjie model access is\naccount-scoped. SGLang, vLLM, and Ollama are\nself-hosted and can run without an API key by default. Ollama defaults to\n`http://localhost:11434/v1` and sends model tags such as `codewhale-coder:1.3b`\nor `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom\nURLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store\nunless API-key auth is explicitly requested; use an env var or config-file key\nwhen a local server does require bearer auth.\nOllama Cloud is the separate hosted `ollama-cloud` provider. It defaults to\n`https://ollama.com/v1` and `gpt-oss:120b`; save its key with\n`codewhale auth set --provider ollama-cloud`. Ambient auth reads\n`OLLAMA_CLOUD_API_KEY` first, then Ollama's official `OLLAMA_API_KEY`.\nSiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts\n`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default.\n`provider = \"siliconflow-CN\"` selects the China regional default\n`https://api.siliconflow.cn/v1` with the `[providers.siliconflow_cn]` table and\n`SILICONFLOW_API_KEY` credential slot.\nArcee AI defaults to `https://api.arcee.ai/api/v1`, accepts `ARCEE_BASE_URL`,\nand uses `trinity-large-thinking` by default for Codewhale agent work.\n`trinity-large-preview` is also listed as a direct Arcee API model; OpenRouter's\n`arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced form, while\nthe direct Arcee provider uses the bare `trinity-large-thinking` ID. Direct\nArcee large-model API calls are tracked as 256K-context BF16 serving; Thinking\nis reasoning-capable, while Preview is not marked as a thinking model.\n\n### OpenRouter vendor pinning\n\nOpenRouter serves each model through several upstream vendors, and Codewhale\ncan pin requests to a vendor with `[providers.openrouter] vendor` (#6007):\n\n```toml\nprovider = \"openrouter\"\n[providers.openrouter]\nmodel = \"deepseek/deepseek-v4-pro\"\nvendor = \"deepinfra\" # copy the vendor slug from the model's OpenRouter page\n```\n\nThis sends `\"provider\": {\"order\": [\"deepinfra\"], \"allow_fallbacks\": false}`\non OpenRouter requests. A base slug can match multiple endpoint variants;\ncopy a full slug such as `deepinfra/turbo` to select one variant. An unavailable\npin fails at OpenRouter. Codewhale's separate `fallback_providers` setting can\nstill switch the whole route after a recoverable error.\n\nThe pin applies across OpenRouter models, including auxiliary requests on that\nroute. Set `vendor = \"\"` to clear it. Reload config or restart to apply edits;\nrequests already in flight keep their captured route. Other providers do not\ninherit the pin. `/preview-request` shows the primary request's routing fields.\n\nModel strings still pass through verbatim: `:floor` sorts by price, `:nitro`\nsorts by throughput, and `@preset/my-team-preset` references an account preset.\nSee OpenRouter's [provider routing](https://openrouter.ai/docs/guides/routing/provider-selection)\nand [presets](https://openrouter.ai/docs/guides/features/presets) documentation.\nCodewhale does not fetch per-vendor endpoint prices or availability; pinned\nusage reports a routing-dependent unknown cost instead of a catalog estimate.\n\n### Custom OpenAI-Compatible Gateways\n\nFor a single third-party service that implements the OpenAI Chat Completions\nAPI, the simplest setup is the built-in `openai` provider name pointed at the\ngateway:\n\n```toml\nprovider = \"openai\"\ndefault_text_model = \"your-model-id\"\n\n[providers.openai]\napi_key = \"YOUR_OPENAI_COMPATIBLE_API_KEY\"\nbase_url = \"https://your-gateway.example/v1\"\n```\n\nPut the endpoint under `[providers.openai]`, not the legacy top-level\n`base_url`, so the OpenAI-compatible provider receives it. `default_text_model`\nis the model ID sent to the gateway; `[providers.openai].model` can be used as\nthe OpenAI-provider-specific override.\n\nIf you keep several OpenAI-compatible gateways, or need a stable name for an\nAgentProfile provider pin, define a user-named custom provider table:\n\n```toml\nprovider = \"lm-studio\"\n\n[providers.lm-studio]\nkind = \"openai-compatible\"\nbase_url = \"http://127.0.0.1:1234/v1\"\napi_key = \"lm-studio\"\nmodel = \"qwen-2.5-7b\"\n```\n\nCustom provider names may be selected with `provider = \"\"`,\n`--provider `, or an AgentProfile `provider = \"\"` when the matching\n`[providers.]` table exists.\n\nA custom table sends Chat Completions requests by default. Add\n`wire = \"responses\"` to send OpenAI Responses requests to\n`/responses` instead, or `wire = \"anthropic\"` for Anthropic Messages:\n\n```toml\n[providers.my-gateway]\nkind = \"openai-compatible\"\nbase_url = \"https://gateway.example/v1\"\napi_key_env = \"MY_GATEWAY_API_KEY\"\nmodel = \"gpt-5.6\"\nwire = \"responses\"\n```\n\nStepFun has a first-class provider entry, so keep Coding Plan credentials and\nbase URL scoped to `[providers.stepfun]`:\n\n```toml\nprovider = \"stepfun\"\n\n[providers.stepfun]\napi_key = \"YOUR_STEPFUN_API_KEY\"\nbase_url = \"https://api.stepfun.ai/step_plan/v1\"\nmodel = \"step-3.7-flash\"\n```\n\n`/provider` setup asks which StepFun billing route the key belongs to —\npay-as-you-go (`https://api.stepfun.ai/v1`) or a Step Plan subscription\n(`https://api.stepfun.ai/step_plan/v1`) — and validates the key against the\nendpoint you pick before saving it. The answer is written to\n`[providers.stepfun].base_url` and nowhere else. If that key already holds a\nbase URL Codewhale does not recognize as one of those two routes, the question\nis skipped and your value is left untouched.\n\nAlibaba Bailian / Model Studio DashScope Qwen routes use the same OpenAI\nprovider shape:\n\n```toml\nprovider = \"openai\"\n\n[providers.openai]\napi_key = \"YOUR_DASHSCOPE_API_KEY\"\nbase_url = \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\"\nmodel = \"qwen-plus\"\ncontext_window = 1000000\n```\n\nUse the regional DashScope `compatible-mode/v1` base URL that matches the\nregion of your API key. Codewhale keeps `qwen-plus` scoped to the `openai`\nprovider route and does not infer a different provider from the model prefix.\nThe same rule applies to all provider-prefixed model strings: a prefix such as\n`deepseek-ai/...` or `deepseek/...` is a provider-owned wire ID under the\nselected provider, not an automatic switch to the DeepSeek provider.\nSet `context_window` to the gateway/model's real total context window when it\ndiffers from Codewhale's static model metadata. See\n[Context length (context window)](#context-length-context-window) for the full\nresolution order and for how to check which value is in effect.\n\nIf the gateway accepts `POST /chat/completions` but rejects\n`/v1/chat/completions`, set a provider-local `path_suffix`:\n\n```toml\n[providers.openai]\nbase_url = \"https://your-gateway.example/v1\"\npath_suffix = \"/chat/completions\"\n```\n\nThe suffix applies only to chat-completion requests. Model listing and\nDeepSeek beta paths keep their built-in routing so a generic gateway override\ndoes not accidentally rewrite `/models` or `/beta/completions`.\n\nFor private gateways with broken or intercepted certificates, use\n`SSL_CERT_FILE` with a trusted CA bundle. The legacy provider-table key\n`insecure_skip_tls_verify = true` is still parsed so `codewhale doctor` can\nreport stale configs, but provider clients reject it instead of disabling TLS\ncertificate verification.\n\nLocal HTTP endpoints such as Ollama, SGLang, and vLLM are allowed by default\nwhen they use localhost or loopback addresses. For a non-local `http://`\ngateway, launch with `DEEPSEEK_ALLOW_INSECURE_HTTP=1` only on a trusted network:\n\n```bash\nDEEPSEEK_ALLOW_INSECURE_HTTP=1 codewhale\n```\n\nThird-party OpenAI-compatible gateways that need extra request headers can set\n`http_headers = { \"X-Model-Provider-Id\" = \"your-model-provider\" }` at the top\nlevel or under a provider table such as `[providers.deepseek]`. When configured,\ncodewhale sends those custom headers on model API requests. The equivalent\nenvironment override is `DEEPSEEK_HTTP_HEADERS`, using comma-separated\n`name=value` pairs such as\n`X-Model-Provider-Id=your-model-provider,X-Gateway-Route=dev`. `Authorization`\nand `Content-Type` are managed by the client and are not overridden by this\nsetting.\n\n### Vision Model\n\nCodewhale's chat provider and `image_analyze` tool are configured separately.\nThe main chat path remains the selected text/tool provider; image analysis runs\nthrough `[vision_model]` when the `vision_model` feature is enabled.\n\nXiaomi's current image-understanding docs include `mimo-v2.5` for image input.\nTo use MiMo for `image_analyze`, configure the vision model explicitly:\n\n```toml\n[features]\nvision_model = true\n\n[vision_model]\nmodel = \"mimo-v2.5\"\napi_key = \"YOUR_XIAOMI_KEY\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\n```\n\nThe example above uses Xiaomi MiMo's pay-as-you-go OpenAI-compatible endpoint.\nIf you are using a Token Plan key (`tp-...`) for `[vision_model]`, you must set\n`base_url` explicitly because this generic OpenAI-compatible block does not\nauto-select MiMo endpoints. Use\n`https://token-plan-sgp.xiaomimimo.com/v1` for Singapore accounts,\n`https://token-plan-cn.xiaomimimo.com/v1` for China-region accounts, or\n`https://token-plan-ams.xiaomimimo.com/v1` for Europe/Amsterdam accounts.\n\n### Auto Model Routing (`[auto]`, `[auto.router]`)\n\nWith `model = \"auto\"`, each turn runs on your **declared default model** unless\nyou have opted into something else. Auto never guesses a cheaper or stronger\nmodel from how a request is worded; the old keyword-and-length heuristic was\nremoved (`auto_route_declared_fallback` in `crates/tui/src/model_routing.rs`).\nTwo optional layers change that:\n\n- an `[auto.router]` classifier, which you write down, that picks a model per\n turn; and\n- `[auto] cost_saving`, which prefers the active provider's fast sibling.\n\nWith neither set, Auto is local and free: the turn uses the default model and\nno classifier call is made.\n\n**There is no default classifier.** With `[auto.router]` unset, no classifier\ncall happens, whatever keys you hold. Holding a DeepSeek key used to elect\n`deepseek-v4-flash` automatically. That was removed because it spent tokens on\na route the user never chose and privileged one provider over the rest\n(`AutoRouterConfig` in `crates/tui/src/config.rs`). Electing a network\nclassifier is now something you write down.\n\nPoint the classifier at any configured provider with `[auto.router]`:\n\n```toml\n[auto.router]\nprovider = \"zai\"\nmodel = \"glm-5-turbo\"\nthinking = \"off\" # optional; defaults to off\ntimeout_secs = 4 # optional; default 4, 0 = default, capped at 300\n```\n\nA classifier call happens only when `[auto.router]` names both `provider` and\n`model` *and* that provider has a key:\n`router_available = router_configured && has_api_key_for(...)` in\n`ModelInventory::from_config` (`crates/tui/src/model_inventory.rs`). If either\ncondition fails, or the classifier call errors or times out, the local\nfallback decides: the default model, or the fast sibling under `cost_saving`.\nThe turn's route receipt (Turn Inspector, Ctrl+Alt+O or `/turn inspect`, \"Model\nroute + tokens/cost\") records which path was taken, and a\nrouter you configured that cannot run or fails (missing key, HTTP error,\ntimeout, invalid answer) is shown as `Auto router: failing — …` rather than\nsilently ignored.\n\n#### Set up model routing\n\n`/router` (also `/model router`) opens one Router setup view with these\npresets. Each writes only `[auto.router]`; none is ever chosen for you.\n\n| Preset | What it writes | Cost and privacy |\n| --- | --- | --- |\n| `/router jev` | Jev, TypeSafe's decision model, over OpenRouter (`typesafe/jev-1.13`) or TypeSafe direct, whichever key you have | About $0.00002 per turn ($0.042 per million input tokens, output free). Your latest request and up to six recent context lines go to OpenRouter → TypeSafe (or TypeSafe). |\n| `/router fast` | The active provider's runnable fast tier with thinking off | Your existing key; the classifier sees the same request text. |\n| `/router off` | Removes `[auto.router]` | No router call; Auto turns use the default model, or the fast tier while `[auto] cost_saving = true` (Off leaves that setting alone). |\n| `/router custom` | Nothing; prints the TOML to edit | — |\n\nChoosing Jev or Fast makes **one test call** with a fixed sample request and\nshows the tier it picked, the probabilities and confidence, the latency and the\nprovider-reported cost. `Enter` (or `/router save `) then saves through\nthe normal config writer; `Esc` discards it. TypeSafe paused new signups on\n2026-09-22, so OpenRouter is the default route for new users. A TypeSafe key is\nread from `TYPESAFE_API_KEY`, the `typesafe` secret-store entry, or\n`[providers.typesafe] api_key` / `api_key_env`.\n\n#### Decision routers (`kind = \"decision\"`)\n\nA decision router asks a non-generative decision model typed Choice questions\nfor the active provider's `fast` and `strong` tiers and thinking level, then\nreads calibrated probabilities. No prose is parsed.\n\n```toml\n[auto.router]\nkind = \"decision\" # default \"chat\"\nprovider = \"openrouter\" # or \"typesafe\"\nmodel = \"typesafe/jev-1.13\" # \"~typesafe/jev-latest\" also works; TypeSafe direct: \"jev-latest\"\ntimeout_secs = 2\nmin_confidence = 0.5 # default 0.5, clamped to 0..1\n```\n\n- The router is called only when the active provider has a runnable strong/fast\n pair; otherwise there is no call and no spend.\n- An answer below `min_confidence` takes the local fallback. Under\n `[auto] cost_saving`, a `strong` answer also needs a probability of at least\n 0.75, or the turn stays on the fast tier.\n- An unknown `kind`, or a decision `provider` other than `openrouter` /\n `typesafe`, leaves the router unconfigured and shown as failing.\n- `[auto.router] thinking` is ignored for decision routers. Both routes settle tokens through\n the originating session's routed-usage ledger. TypeSafe is a named Custom route\n with unknown billing; its price is never borrowed from the active chat provider.\n Provider-reported cost remains verbatim on the decision receipt.\n- OpenRouter uses `POST /api/alpha/decisions`; TypeSafe uses `POST /v1/systemone`.\n The shared client validates Choice, Noul and Score against the offered questions\n and bounds responses to 256 KiB. Malformed answers fail open with usage retained.\n\n#### Shadow Decision Gate (experimental, off by default)\n\nThe Superfast Decision Gate asks a decision model three typed questions about\nthe latest user message — does it need a tool, can it be answered from the\nconversation, and what is its intent — and logs a conservative recommendation.\nIt is shadow-only: it never changes routing, never skips or delays the model\ncall, and fails open on any error, timeout or malformed answer. It uses the same\nSystem One client as the decision router above; there is no separate HTTP\nclient. It is configured from the environment and reads it when a turn starts:\n\n```sh\nSUPERFAST_ENABLED=1 # off unless set\nSUPERFAST_PROVIDER=typesafe # or openrouter; required when enabled\nSUPERFAST_BASE_URL=http://localhost:8000/v1 # optional TypeSafe-route base, e.g. self-hosted\nSUPERFAST_MODEL=jev-latest # default jev-latest / ~typesafe/jev-latest\nSUPERFAST_TIMEOUT_MS=150 # 1..=10000, default 150\n```\n\n- Enabling the gate never picks an endpoint by itself: without\n `SUPERFAST_PROVIDER` nothing is sent and a warning is logged.\n- The key comes from the same place the decision router reads it. The TypeSafe\n route always authenticates, so a self-hosted server that ignores auth still\n needs a placeholder `TYPESAFE_API_KEY`.\n- Only the latest user message is sent, truncated to 4,000 characters and\n redacted of configured secrets. The log (target `superfast`) carries the\n route, failure class and latency, never prompt text.\n- The gate retains the originating turn's accounting owner and cancellation.\n Its tokens settle through the shared ledger; missing usage or a cancelled/timed\n out request after dispatch creates an explicit coverage gap. Unknown TypeSafe\n pricing is recorded as unpriced rather than free.\n- Provider-reported cost survives rejected answers and late responses in bounded\n receipts on the originating turn or session. These are diagnostic evidence;\n unknown pricing never becomes an authoritative dollar total. Incomplete token\n counters record a coverage gap while preserving the reported raw evidence.\n\nThe wire contracts are documented in [TypeSafe's OpenAPI schema](https://api.typesafe.ai/openapi.json)\nand [OpenRouter's Decisions examples](https://openrouter.ai/blog/insights/what-is-jev/).\n\nThe Decision Gate concept and reference implementation are by Andrea Bruno,\nreleased under CC BY 4.0:\n[harness-superfast](https://github.com/Andrea-Bruno/harness-superfast).\n\nTwo `[auto]` keys shape routing (`AutoConfig` in `crates/tui/src/config.rs`):\n\n```toml\n[auto]\ncost_saving = false # default false\ncross_provider = false # default false\n```\n\n- **`cost_saving`** (default `false`). Without a classifier, Auto pins the\n active provider's validated fast sibling instead of the default model. A\n provider with no runnable fast sibling stays on the default. With a\n classifier, the classifier is told to prefer the fast tier for routine or\n ambiguous work and to pick the strong tier only for clearly agentic,\n multi-step, architecture, security or debugging work. Cost-saving never\n switches provider just to save money.\n- **`cross_provider`** (default `false`). Auto stays on the provider the session\n is configured to use. The classifier is only shown that provider's models,\n and the fallback never leaves it. Setting `cross_provider = true` lets the\n classifier choose among every runnable provider. There is no interactive\n toggle for `cross_provider`; it has to be set in config. A decision router\n always chooses within the active provider.\n\nTo bootstrap MCP and skills directories at their resolved paths, run `codewhale setup`.\nTo only scaffold MCP, run `codewhale mcp init`.\n\nNote: `setup`, `doctor`, `mcp`, `features`, `sessions`, `resume`/`fork`, `exec`,\n`review`, and `eval` are all available from the installed `codewhale` command.\nThe consolidated dispatcher also provides `auth`, `config`, `model`, `thread`, `sandbox`,\n`app-server`, `mcp-server`, `completions`, `login`/`logout`, `account`,\n`metrics`, `update`, `lane`, `workflow`, and `web`. Plain prompts enter the\nin-process TUI runtime. Release installers expose the same bytes as `codew`.\n\n### Startup Update Checks\n\nBy default, the TUI starts a background check for the latest stable Codewhale\nrelease and shows a short toast only when a newer release is available and the\nofficial release assets are complete. The check never blocks startup, never\nblocks a turn, and fails silently when offline.\n\nDisable the startup check entirely for air-gapped, corporate-proxy, or managed\ndesktop environments:\n\n```toml\n[update]\ncheck_for_updates = false\n```\n\n#### Throttling\n\nThe answer is cached in `~/.codewhale/update-check.json` and reused for\n`check_interval_hours` (default `1`). Only the *network request* is throttled —\nthe notice still appears on every launch while an update is outstanding. Set `0`\nto check on every launch.\n\n```toml\n[update]\ncheck_interval_hours = 1\n```\n\nA failed check is not cached, so an outage does not suppress the notice until\nthe interval elapses.\n\n#### Automatic suppression\n\nChecks are skipped, without contacting the network, when any of these is set to\na non-falsey value:\n\n| Variable | Why |\n| --- | --- |\n| `CODEWHALE_NO_UPDATE_CHECK` | Explicit opt-out. |\n| `NO_UPDATE_NOTIFIER` | The cross-CLI convention, honored for compatibility. |\n| `CI`, `CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TEAMCITY_VERSION`, `TF_BUILD` | Automated build; nobody is at the terminal. |\n\nValues of `\"\"`, `0`, `false`, `no`, and `off` do not count as set, so a\n`CI=false` export does not disable checks for ordinary users.\n\n#### Which update command is offered\n\nCodewhale never installs anything on its own — it only tells you an update\nexists. The command it names depends on how the running binary was installed,\ndetected from its path:\n\n| Install | Command offered |\n| --- | --- |\n| GitHub release binary (including Termux) | `codewhale update` |\n| npm (`node_modules` on the path) | `npm install -g codewhale@latest` |\n| Homebrew (`Cellar` / `linuxbrew` prefix) | `brew upgrade codewhale` |\n| `cargo install` (`~/.cargo/bin`) | `cargo install codewhale-cli --locked --force` |\n\nFor package-managed installs the notice also warns against `codewhale update`:\nreplacing a binary Homebrew or npm owns leaves the manager describing a version\nthat is no longer on disk, and the next upgrade silently reverts you.\n\nOverride the detection with `CODEWHALE_INSTALL_METHOD=npm|homebrew|cargo|binary`\nif you relocated the binary somewhere the path heuristics cannot read.\n\nTo redirect the startup check, set `update_uri` to an internal endpoint that\nreturns GitHub-compatible latest-release JSON. Minimal mirror metadata with a\n`tag_name` field is accepted; if `assets` are present, Codewhale requires the\nsame uploaded asset set as the official release before showing the toast.\n\n```toml\n[update]\ncheck_for_updates = true\nupdate_uri = \"https://internal.mirror.example/codewhale/releases/latest\"\n```\n\nWhen `update_uri` is not set, startup checks honor release mirror environment\nvariables such as `CODEWHALE_RELEASE_BASE_URL` before falling back to the\nofficial GitHub API endpoint. If a configured `update_uri` cannot be fetched or\nparsed and a release mirror env var is set, the TUI falls back to that mirror\ninstead of failing startup.\n\n## Workshop output budgets\n\nBy default an oversized tool result uses bounded spillover: a result\nunder the byte threshold stays inline, a larger one gets a head/tail\npreview plus a session artifact the model can read back. There is no\nsynthesis sub-agent and no per-call `raw = true` escape.\n\n`[workshop] large_output_threshold_tokens` and\n`[workshop.per_tool_thresholds]` (exact model-visible tool names such as\n`bash`) only take effect when the process opts in to adaptive evidence\nrouting with `CODEWHALE_ADAPTIVE_OUTPUT_ROUTING=1`; they set where a result\nstops being inline and becomes handle-only evidence.\n\nTwo optional byte ceilings (#5367) apply either way. They raise the\nmodel-visible floor and never lower it:\n\n- `read_result_max_bytes` — cap for a single `read` / `read_file`\n result. Absent keeps the compile-time defaults (100000 bytes for\n `read`, which has no line cap; 16KiB / 500 lines for `read_file`).\n For `read` this is the middle of a three-layer budget: the model's\n own per-call `max_bytes` (hard maximum 500000) raises the budget for\n one call, this setting raises the floor for the whole process, and\n either way 2MiB is the absolute ceiling. Highest wins; neither layer\n can lower a budget the other granted.\n- `tool_result_max_bytes` — cap for a generic tool result after\n spillover. Absent keeps the 12K-character compact floor (48K on\n windows ≥500K tokens). Hard cap is 2MiB.\n\n## Context length (context window)\n\nAlso called context size, context limit, max context, or window. This is the\ntotal token window Codewhale budgets against, and it drives the header/footer\ncontext percent, the auto-compaction trigger, context-pressure checks, and the\nrequest output cap. If Codewhale compacts at 128K on a model you know serves a\n1M window, this is the setting to change (#5134).\n\n**See what is in effect, and where the value came from.** Every one of these\nprints the resolved window *and* its source:\n\n- `/status` — a `Context window:` row with the percent and token counts, and a\n `Window source:` row naming the provenance and the exact key that overrides\n it.\n- `/config` → Provider — `Context window` (your override, or `(not set)`) and\n `Effective context window` (`1048576 tokens · configured`). Typing\n `context length` in the `/config` filter jumps straight to them.\n- `/context report` — `Window: 1048576 tokens (12.4% used, ...; source: configured)`.\n- `/context json` — machine-readable `context_window_tokens` and\n `context_window_source`.\n\n**Change it** with the provider-table key `context_window`:\n\n```toml\n[providers.moonshot]\ncontext_window = 1048576\n```\n\nor from the CLI:\n\n```bash\ncodewhale config set providers.moonshot.context_window 1048576\ncodewhale config unset providers.moonshot.context_window # back to automatic\n```\n\nUse the table for the provider you are actually on (`providers.openai`,\n`providers.deepseek`, `providers.moonshot`, …); `/status` names it for you. The\nvalue is a positive token count for the route's *total* window.\n\nWhen one gateway fronts models with heterogeneous windows, scope the override\nto an exact wire model id with `[providers..model_context_windows]`:\n\n```toml\n[providers.command_code]\ncontext_window = 204800\n\n[providers.command_code.model_context_windows]\n\"MiniMaxAI/MiniMax-M2.5\" = 204800\n\"google/gemini-3.1-flash-lite\" = 1000000\n```\n\nKeys are the exact wire model ids the route sends (dotted and `org/model`\nspellings both work as TOML keys when quoted); each value must be a positive\ntoken count. A matching entry beats the provider-level `context_window` for\nthat model only — every other model on the provider still resolves against\n`context_window` and the rungs below. From the CLI:\n\n```bash\ncodewhale config set 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"' 204800\ncodewhale config unset 'providers.command_code.model_context_windows.\"MiniMaxAI/MiniMax-M2.5\"'\n```\n\n### How the effective window is resolved\n\nFirst match wins, and the source label each surface prints is exactly this\nrung:\n\n1. `configured (per-model)` — a `[providers..model_context_windows]`\n entry keyed by the route's exact wire model id. A hard override for that\n model only; it never rewrites another model's window.\n2. `configured` — `[providers.] context_window` in `config.toml`. A hard\n override for every model on the provider: nothing below it can raise or\n lower the result. Read-time aliases:\n `contextWindow`, `context_window_tokens`, `contextWindowTokens`,\n `context_length`, `contextLength`.\n3. `provider-reported` — route-scoped 1M metadata a provider actually reported\n for the Kimi Code `k3` route, when it was observed within the last 24 hours.\n4. `static Kimi Code safe floor` — 262,144 tokens for Kimi Code memberships,\n because 1M access is plan-gated (Allegretto and above).\n5. `catalog` — the bundled route catalog (hand-curated offerings first, then\n the bundled Models.dev rows). The official `openai-codex` roster supplies\n account-specific model choices, not context-window metadata. Refresh it\n with `codewhale models --update --provider openai-codex`; Codewhale does\n not read `$CODEX_HOME` for this route.\n6. `model-name hint` — an `_Nk` suffix parsed from the model name itself\n (`qwen3-32b-256k` → 256,000), vendor-agnostic. A naming convention the\n serving engine may not honor is not a fact about the route, so this rung\n sits *below* the catalog: any catalog row for the same id beats it (#5441).\n7. `fallback` — the static per-provider capability table: 200,000 for\n Anthropic-wire routes, 128,000 for `openai-codex`, 8,192 for Ollama,\n otherwise Codewhale's static per-model metadata, and finally 128,000 when\n the model is unknown.\n\n### What \"(unverified)\" means\n\nThe `model-name hint` and `fallback` rungs still drive real budgets — the\ncompaction trigger, the context meter, and the output reservation all use the\nnumber — but they are guesses, not capabilities anyone checked. Every surface\nthat renders one of these windows appends `(unverified)` to its source label\n(the status line, the context-pressure message, `/status`, `/config`, and the\nmodel picker chip), so a window you did not configure and no provider reported\ncan never read as a verified limit (#5239, #5441). The `context_window` and\n`model_context_windows` provider-table keys above are the fix: a configured\nwindow is a hard override and renders as `configured` (or\n`configured (per-model)`) with no marker.\n\nOutput ceilings follow the same rule (#5440): an Anthropic-family model the\ncatalog does not describe keeps the 64K Messages floor as its clamp, labeled\n`unverified` (or an \"assumed floor\") instead of `documented`. The official\nChatGPT plan preview does not accept `max_output_tokens`; Codewhale sends no\noutput-token cap on that route. A local request budget is not an upstream\nlimit or a guarantee about the completed response length.\n\nThere is no environment variable for the context window; the provider-table\n`context_window` and per-model `model_context_windows` keys are the user\nknobs. They are the right ones to set when a gateway or self-hosted runtime\nserves a window Codewhale's catalog does not model — per-model when only some\nof a provider's routes differ, provider-wide when they all do. Codewhale will\nnot invent a window it cannot justify — it falls back to a conservative value,\nlabels it `fallback`, and marks it `(unverified)` at every surface that shows\nit.\n\n### Adjacent knobs\n\n- `auto_compact_threshold_percent` (settings.toml; also accepted as\n `auto_compact_threshold`; `10`–`100`, default `80`): the share of the full\n route context window at which auto-compaction fires, clamped so it can never\n cross the spendable input ceiling after output reservation and headroom.\n Editable from `/config`. Raising the window without touching this raises the\n absolute compaction point along with it.\n- `auto_compact` (settings.toml, on/off): turns automatic compaction off\n entirely; `/compact` and Ctrl+L stay available.\n- `[compaction] summary_instructions` and\n `[compaction] retained_user_message_tokens` (config.toml): standing\n summarizer instructions and the verbatim user-message retention budget. See\n the `compaction.*` entry in the config-key reference below.\n- `CODEWHALE_MAX_OUTPUT_TOKENS` (environment variable; legacy alias\n `DEEPSEEK_MAX_OUTPUT_TOKENS`): overrides the requested output cap. Without an\n override, Codewhale starts at the safe `65536` request cap and intersects it\n with any smaller documented model or route ceiling; a catalog `max_output`\n such as DeepSeek V4's 384K remains a capability ceiling, not the amount every\n response requests. Explicit overrides are preserved within the resolved\n route context window and any route output ceiling, and preflight/emergency\n budgeting reserves the same effective value that can reach the wire. A\n separately documented route input ceiling also clamps preflight and\n compaction even when the total context window is larger. A blank canonical\n variable falls through to a nonblank legacy value; a nonblank invalid or\n zero canonical value is authoritative and falls back to the safe automatic\n default instead of activating a stale legacy setting. There is no\n `max_output_tokens` key in `config.toml`.\n\nBefore compaction replaces conversation history, Codewhale durably saves the\noriginal messages to the session's `artifacts/context-transfer-.json` and,\nwhen a model summary is produced, its handoff to the matching `.md` file.\nThese use the existing session artifact store and persistence redaction.\nA failed write aborts compaction without replacing context. Pruning-only passes\nsave the original messages without making an extra model call. Pressure metadata\nshows estimated input tokens and the configured trigger; it is an estimate, not\nan exact promise about a provider's remaining context.\n\nCompaction history is available through `codewhale metrics` (or `--json`) and\n`audit.log` in the Codewhale home. Completed passes record their trigger,\nsummary/pruning path, message and estimated-token counts, effective threshold,\nand summarizer token usage. Automatic refusals are recorded once per turn with\ntheir reason. These are local diagnostics, not provider invoice totals; earlier\nartifacts are not retroactively counted. A text-mode `exec` that attempts\ncompaction saves its owning session at turn completion so its recovery artifacts\nremain discoverable. A killed process may leave artifacts without that final\nsession snapshot; the audit writer reports I/O failures instead of inventing data.\n\nSee [Settings File](#settings-file-persistent-ui-preferences) for the\ncompaction settings and [Token Quantities and\nDrivers](#token-quantities-and-drivers) for what each displayed token number\nactually measures.\n\n## Profiles\n\nYou can define multiple profiles in the same file:\n\n```toml\ndefault_text_model = \"deepseek-flash\"\n\n[providers.deepseek]\napi_key = \"PERSONAL_KEY\"\n\n[profiles.work.providers.deepseek]\napi_key = \"WORK_KEY\"\nbase_url = \"https://api.deepseek.com/beta\"\n\n[profiles.nvidia-nim]\nprovider = \"nvidia-nim\"\ndefault_text_model = \"deepseek-ai/deepseek-v4-pro\"\n\n[profiles.nvidia-nim.providers.nvidia_nim]\napi_key = \"NVIDIA_KEY\"\nbase_url = \"https://integrate.api.nvidia.com/v1\"\n\n[profiles.fireworks]\nprovider = \"fireworks\"\ndefault_text_model = \"accounts/fireworks/models/deepseek-v4-pro\"\n\n[profiles.siliconflow]\nprovider = \"siliconflow\"\ndefault_text_model = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.siliconflow.providers.siliconflow]\nbase_url = \"https://api.siliconflow.com/v1\"\n\n[profiles.openai-compatible]\nprovider = \"openai\"\n\n[profiles.openai-compatible.providers.openai]\nbase_url = \"https://openai-compatible.example/v4\"\nmodel = \"glm-5\"\n\n[profiles.atlascloud]\nprovider = \"atlascloud\"\n\n[profiles.atlascloud.providers.atlascloud]\nbase_url = \"https://api.atlascloud.ai/v1\"\nmodel = \"deepseek-ai/deepseek-v4-flash\"\n\n[profiles.sglang]\nprovider = \"sglang\"\n\n[profiles.sglang.providers.sglang]\nbase_url = \"http://localhost:30000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.vllm]\nprovider = \"vllm\"\n\n[profiles.vllm.providers.vllm]\nbase_url = \"http://localhost:8000/v1\"\nmodel = \"deepseek-ai/DeepSeek-V4-Pro\"\n\n[profiles.ollama]\nprovider = \"ollama\"\n\n[profiles.ollama.providers.ollama]\nbase_url = \"http://localhost:11434/v1\"\nmodel = \"qwen2.5-coder:7b\" # any tag `ollama list` shows\n\n[profiles.ollama-cloud]\nprovider = \"ollama-cloud\"\n\n[profiles.ollama-cloud.providers.ollama_cloud]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"gpt-oss:120b\"\n```\n\nSelect a profile with:\n\n- CLI: `codewhale --profile work`\n- Env: `DEEPSEEK_PROFILE=work`\n\nIf a profile is selected but missing, codewhale exits with an error listing available profiles.\n\n## Legacy top-level `base_url` and `api_key`\n\nOlder releases kept DeepSeek's endpoint and key at the top of `config.toml`,\nand each reader decided for itself which other routes inherited them. They\nnow live in provider tables only. An older file keeps working unchanged:\nevery load reads the top-level keys as if they were already in their table,\nby one rule, per file and per `[profiles.]`:\n\n1. `provider = \"custom\"` with no `[providers.custom]` table: the endpoint, key\n and a copy of the model become `[providers.custom]`.\n2. An endpoint on another vendor's official host (for example\n `integrate.api.nvidia.com`, `xiaomimimo.com`, `openrouter.ai`, or the\n ChatGPT Codex endpoint) belongs to that vendor's table, so a DeepSeek route\n never sends requests there.\n3. Anything else belongs to `[providers.deepseek]`, which DeepSeek-CN also\n reads for its endpoint and key.\n\nThe key goes with DeepSeek (or the literal custom route). It follows the\nendpoint to another vendor only when the same table sets `provider` to that\nvendor, the host is that vendor's own, and the vendor's table has no key. When\nno `provider` is set, a NIM host still selects `nvidia-nim` and\n`api.deepseeki.com` still selects `deepseek-cn`. A `[vision_model]` without a\nkey of its own keeps using the top-level key. A profile's top-level\n`base_url` now overrides the base file's `[providers.deepseek] base_url`\n(before, the base table silently won).\n\nWhen a top-level value and its table disagree, the table's `base_url` and the\ntop-level `api_key` are used, which is what the runtime sent before.\n\nLoading never rewrites the file. The next save Codewhale makes (for example\n`codewhale config set`, `/config ... --save`, or `auth set`) moves the keys,\nkeeps comments, writes a one-time credential-free copy of the old file to\n`config.toml.pre-migrate.bak`, and prints one line saying what moved. A\ndisagreeing pair is never resolved in the file on its own; both values stay\nuntil you choose, and `codewhale config doctor` / `codewhale doctor` report\nit (sources only, never values). `codewhale config get|set|unset base_url`\n(and `api_key`) address the active provider's table.\n\nTo tidy the file yourself:\n\n```bash\ncodewhale config migrate --dry-run # show what would move\ncodewhale config migrate # move it (backup first)\ncodewhale config migrate --prefer top-level # resolve a conflict: keep the top-level value\ncodewhale config migrate --prefer table # resolve a conflict: keep the table value\n```\n\n## Environment Variables\n\nMost runtime environment variables override config values. API-key variables are\nfallbacks after saved config and keyring credentials.\n\nThe three user-facing slots — provider, model, base URL — expose `CODEWHALE_*`\naliases. When both forms are set the `CODEWHALE_*` value wins; the\n`DEEPSEEK_*` form is kept for older shells:\n\n- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) —\n `deepseek|deepseek-anthropic|nvidia-nim|openai|atlascloud|wanjie-ark|volcengine|openrouter|xiaomi-mimo|novita|fireworks|siliconflow|arcee|siliconflow-CN|moonshot|sglang|vllm|ollama|ollama-cloud|huggingface|modelscope|together|qianfan|openai-codex|anthropic|openmodel|zai|stepfun|minimax|deepinfra|mistral`\n- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider\n- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider\n\n`CODEWHALE_BASE_URL` applies to the **active** route only. A request pinned to\nanother provider — a subagent or fleet child, a routed tool, the per-turn\nauto-router, a picker preview — resolves its endpoint from that provider's own\n`[providers.
]`, then its provider-scoped variable (`MOONSHOT_BASE_URL`,\n`OPENAI_BASE_URL`, …), then that provider's default. It never inherits the\nactive session's host, and a custom route with no configured `base_url` fails\nclosed on a loopback placeholder rather than borrowing another provider's\nendpoint. The environment writes the value into the active identity's own\ntable: DeepSeek-CN falls back to `[providers.deepseek]` for its endpoint and\nkey, but never to a DeepSeek value the environment addressed to DeepSeek alone.\nA managed-config overlay that supplies or reselects the effective\nroute's endpoint takes the generic override away from every route.\n\nRemaining variables:\n\n- `DEEPSEEK_API_KEY`\n- `DEEPSEEK_ANTHROPIC_BASE_URL`\n- `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs)\n- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`)\n- `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `900`, clamped to `1..=3600`)\n- `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout; `stream.open_timeout_secs` (legacy `tui.stream_open_timeout_secs` fallback) wins when positive)\n- `CODEWHALE_CACHE_MAXIMAL` (`1`/`true`/`on`/`yes`) — cache-maximal context mode (#528). When on, the Repo Working Set block materializes the **full current contents** of the top active files into the system prompt each turn (deterministic order, byte-bounded), instead of only listing their paths. The block stays byte-stable while those files are unchanged so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses from its block onward. Off by default (path list only). Byte caps default to 24 KB per file / 96 KB total.\n- `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (when provider is `nvidia-nim`)\n- `NVIDIA_NIM_BASE_URL`, `NIM_BASE_URL`, or `NVIDIA_BASE_URL`\n- `NVIDIA_NIM_MODEL`\n- `OPENAI_API_KEY`\n- `OPENAI_BASE_URL`\n- `OPENAI_MODEL`\n- `ATLASCLOUD_API_KEY`\n- `ATLASCLOUD_BASE_URL`\n- `ATLASCLOUD_MODEL`\n- `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, or `WANJIE_MAAS_API_KEY`\n- `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, or `WANJIE_MAAS_BASE_URL`\n- `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, or `WANJIE_MAAS_MODEL`\n- `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, or `ARK_API_KEY`\n- `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, or `ARK_BASE_URL`\n- `VOLCENGINE_MODEL` or `VOLCENGINE_ARK_MODEL`\n- `OPENROUTER_API_KEY`\n- `OPENROUTER_BASE_URL`\n- `OPENROUTER_MODEL`\n- `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, or `MIMO_API_KEY`\n- `XIAOMI_MIMO_BASE_URL` or `MIMO_BASE_URL`\n- `XIAOMI_MIMO_MODEL` or `MIMO_MODEL`\n- `XIAOMI_MIMO_MODE` or `MIMO_MODE` (`token-plan-sgp`, `token-plan-cn`,\n `token-plan-ams`, or `pay-as-you-go`)\n- `NOVITA_API_KEY`\n- `NOVITA_BASE_URL`\n- `NOVITA_MODEL`\n- `FIREWORKS_API_KEY`\n- `FIREWORKS_BASE_URL`\n- `FIREWORKS_MODEL`\n- `HUGGINGFACE_API_KEY` or `HF_TOKEN` (`HF_TOKEN` is a fallback alias accepted when provider is `huggingface`)\n- `MODELSCOPE_API_KEY`\n- `HUGGINGFACE_BASE_URL` or `HF_BASE_URL`\n- `HUGGINGFACE_MODEL` or `HF_MODEL`\n- `SILICONFLOW_API_KEY`\n- `SILICONFLOW_BASE_URL`\n- `SILICONFLOW_MODEL`\n- `ARCEE_API_KEY`\n- `ARCEE_BASE_URL`\n- `ARCEE_MODEL`\n- `TOGETHER_API_KEY`\n- `TOGETHER_BASE_URL`\n- `TOGETHER_MODEL`\n- `QIANFAN_API_KEY` or `BAIDU_QIANFAN_API_KEY`\n- `QIANFAN_BASE_URL` or `BAIDU_QIANFAN_BASE_URL`\n- `QIANFAN_MODEL` or `BAIDU_QIANFAN_MODEL`\n- `OPENAI_CODEX_ACCESS_TOKEN` or `CODEX_ACCESS_TOKEN` (legacy process tokens; ignored by the official ChatGPT plan route)\n- `OPENAI_CODEX_BASE_URL` or `CODEX_BASE_URL`\n- `OPENAI_CODEX_MODEL` or `CODEX_MODEL`\n- `OPENAI_CODEX_ACCOUNT_ID` or `CODEX_ACCOUNT_ID` (legacy identity hints; ignored by the official ChatGPT plan route)\n- `CODEWHALE_CHATGPT_NEW_ACCOUNT=1` explicitly replaces the single selected ChatGPT registration after a validated browser sign-in\n- `ANTHROPIC_API_KEY`\n- `ANTHROPIC_BASE_URL`\n- `ANTHROPIC_MODEL`\n- `ZAI_API_KEY` or `Z_AI_API_KEY`\n- `ZAI_BASE_URL` or `Z_AI_BASE_URL`\n- `ZAI_MODEL` or `Z_AI_MODEL`\n- `STEPFUN_API_KEY` or `STEP_API_KEY`\n- `STEPFUN_BASE_URL` or `STEP_BASE_URL`\n- `STEPFUN_MODEL` or `STEP_MODEL`\n- `MINIMAX_API_KEY`\n- `MINIMAX_BASE_URL`\n- `MINIMAX_MODEL`\n- `DEEPINFRA_API_KEY` or `DEEPINFRA_TOKEN`\n- `DEEPINFRA_BASE_URL`\n- `DEEPINFRA_MODEL`\n- `MISTRAL_API_KEY`\n- `MISTRAL_BASE_URL`\n- `MISTRAL_MODEL`\n- `MOONSHOT_API_KEY` or `KIMI_API_KEY`\n- `MOONSHOT_BASE_URL` or `KIMI_BASE_URL`\n- `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL`\n- `SGLANG_BASE_URL`\n- `SGLANG_MODEL`\n- `SGLANG_API_KEY` (optional; many localhost SGLang servers do not require auth)\n- `VLLM_BASE_URL`\n- `VLLM_MODEL`\n- `VLLM_API_KEY` (optional; many localhost vLLM servers do not require auth)\n- `OLLAMA_BASE_URL`\n- `OLLAMA_MODEL`\n- `OLLAMA_API_KEY` (optional; many localhost Ollama servers do not require auth)\n- `OLLAMA_CLOUD_BASE_URL`\n- `OLLAMA_CLOUD_MODEL`\n- `OLLAMA_CLOUD_API_KEY` (preferred Cloud key; `OLLAMA_API_KEY` is the official fallback)\nFor every product-level `CODEWHALE_*` variable below, the matching legacy\n`DEEPSEEK_*` name is still read as a compatibility fallback; when both are set,\nthe `CODEWHALE_*` value wins.\n\n- `CODEWHALE_LOG_LEVEL` or `RUST_LOG` (`info`/`debug`/`trace` enables lightweight verbose logs)\n- `CODEWHALE_SKILLS_DIR`\n- `CODEWHALE_MCP_CONFIG`\n- `CODEWHALE_NOTES_PATH`\n- `CODEWHALE_MEMORY` (`1|on|true|yes|y|enabled` turns user memory on)\n- `CODEWHALE_MEMORY_PATH`\n- `CODEWHALE_TELEMETRY` / `DEEPSEEK_TELEMETRY` (legacy alias) — anonymous usage\n counting is on by default in the current 0.9.12 source, with a disclosure\n naming Codewhale and PostHog and an easy durable opt-out. Prior explicit\n declines remain off. Accepts `0|1|true|false|yes|no|on|off|enabled|\n disabled`. An explicit \"off\" is a **floor**: it beats `--telemetry true` and\n `telemetry = true` in config, and a value this list cannot read also resolves\n to off, because a typo in a kill switch must never resolve to \"on\". See\n [`TELEMETRY.md`](TELEMETRY.md).\n- `CODEWHALE_TELEMETRY_ENDPOINT` / `DEEPSEEK_TELEMETRY_ENDPOINT` (legacy alias)\n — `https://`, or plain `http://` only for loopback. Overrides the config file.\n Unset selects the shipped default,\n `https://telemetry.codewhale.net/v1/telemetry`; setting it to the **empty\n string** routes batches to a local dry-run file and contacts nobody. Either\n way it only decides where a session sends — it cannot override an opt-out.\n- `CODEWHALE_ALLOW_SHELL` (`1`/`true` enables)\n- `CODEWHALE_APPROVAL_POLICY` (`on-request|untrusted|never`)\n- `CODEWHALE_SANDBOX_MODE` (`read-only|workspace-write|danger-full-access|external-sandbox`)\n- `CODEWHALE_NO_NEW_PRIVS` (`0`/`false`/`no`/`off`/`disabled` opts out) — Linux only. The\n TUI process sets the kernel's irreversible no-new-privileges flag at startup\n as defense-in-depth, which blocks `sudo`/`su`/setuid helpers for Codewhale's\n whole process tree. The flag is already skipped when the startup sandbox\n mode resolves to `danger-full-access` (#5723), so this variable is the\n explicit override in the remaining cases: set it to a falsey value before\n launching if you administer through Codewhale as a wheel-group user under a\n narrower posture and need escalation to work (#5413), or set a truthy value\n to force the flag on even under `danger-full-access`. Unset, the posture\n decides; the other startup hardening (no ptrace, no core dumps) always\n stays on.\n- `CODEWHALE_MANAGED_CONFIG_PATH`\n- `CODEWHALE_REQUIREMENTS_PATH`\n- `CODEWHALE_MAX_SUBAGENTS` (clamped to `1..=128`)\n- `CODEWHALE_TASKS_DIR` (runtime task queue/artifact storage, default\n `~/.codewhale/tasks`, with legacy `~/.deepseek/tasks` fallback when only the\n legacy directory exists)\n- `CODEWHALE_RUNTIME_DIR` (override the runtime thread store root). Interactive\n sessions default to `$CODEWHALE_HOME/sessions//runtime` so each\n Codewhale process owns its own store (#5630). The store is single-owner: a\n second process on the **same** root fails at startup. Set this variable to\n share one store across processes, or when the runtime API server should use a\n stable non-session path. Unset, the API/server path remains\n `$CODEWHALE_HOME/tasks/runtime`. Legacy alias: `DEEPSEEK_RUNTIME_DIR`.\n- `CODEWHALE_ALLOW_INSECURE_HTTP` (`1`/`true` allows non-local `http://` base URLs; default is reject)\n- `CODEWHALE_FORCE_HTTP1` (`1|true|yes|on` pins the HTTP client to HTTP/1.1, disabling HTTP/2; useful on Windows or behind proxies that mishandle long-lived H2 streams)\n- `CODEWHALE_HOME` (override the base data directory; defaults to `~/.codewhale`).\n If you previously exported `DEEPSEEK_HOME`, rename it to `CODEWHALE_HOME`;\n the old env var is not used for new Codewhale state paths.\n- `CODEWHALE_RELEASE_BASE_URL` (release asset mirror used by `codewhale update`\n and by TUI startup update checks when `[update].update_uri` is not set, or as\n a fallback when that configured URI cannot be fetched)\n- `CODEWHALE_AUTOMATIONS_DIR` (override the automations storage directory; uses\n `~/.codewhale/automations` by default, with legacy `~/.deepseek/automations`\n fallback when only the legacy directory exists)\n- `NO_ANIMATIONS` (`1|true|yes|on` forces `low_motion = true` and\n `fancy_animations = false` at startup, regardless of the saved\n settings; see [`docs/ACCESSIBILITY.md`](./ACCESSIBILITY.md)).\n- `SSL_CERT_FILE` — corporate-proxy / TLS-inspecting MITM users\n point this at a PEM bundle (or single DER cert) and the cert(s)\n get added alongside the platform's system trust store. Failures\n log a warning and continue — the existing system roots still\n apply.\n\n### Instruction sources (`instructions = [...]`, #454)\n\nAdd a list of additional system-prompt sources that get\nconcatenated, in declared order, alongside the auto-loaded\n`AGENTS.md`:\n\n```toml\ninstructions = [\n \"./AGENTS.md\",\n \"~/.codewhale/global.md\",\n \"~/team/agents-shared.md\",\n]\n```\n\nRules:\n\n- Paths run through `expand_path` so `~` and env vars work.\n- Each file is capped at 100 KiB; oversized files are\n truncated with a `[…elided]` marker rather than skipped.\n- Missing files are skipped with a tracing warning so a stale\n entry doesn't fail the launch.\n- Only user-owned config, profiles, and managed config may set this array.\n Project config (`/.codewhale/config.toml`, or legacy\n `/.deepseek/config.toml`) ignores `instructions` so a cloned repo\n cannot choose arbitrary local files to place into the prompt.\n\n### Hooks\n\nHooks are a **TUI runtime feature**. They fire from the interactive TUI and\nthe engine turn loop it drives; `codewhale exec`, the CLI subcommands, the\napp-server / ACP surfaces, and the `workflow` tool do not fire them.\n\n[`docs/HOOKS.md`](HOOKS.md) is the authoritative reference for all eleven hook\nevents — their firing points, environment variables, stdin payloads, timeout\nand background semantics, and which three of them can steer Codewhale. The\nsections below cover the configuration surface and the steering contracts in\nmore depth.\n\nTwo contract points worth reading there before writing a hook:\n\n- `background = true` means **submitted and never awaited**. The hook still\n gets the documented stdin payload and the same timeout, but it has no exit\n code and cannot steer.\n- A condition that references context its event never carries (an `exit_code`\n condition outside `tool_call_after` / `on_error`, a `mode` condition on\n `shell_env`, a tool condition on a non-tool event) is **rejected at load**,\n logged, and shown in `/hooks list`. It does not silently never match.\n Rejection is per entry, so a broken hook never drops another one that merely\n shares its `name` or is likewise unnamed.\n\n### `/hooks` listing\n\nRun `/hooks` (or `/hooks list`) inside the TUI to see every\nconfigured lifecycle hook grouped by event, including each\nhook's name, command preview, effective timeout, and condition. When\n`[hooks].default_timeout_secs` is set it replaces every per-hook\n`timeout_secs`, and the listing shows that effective value and names the\noverride rather than echoing the per-hook number. A\n`default_timeout_secs = 0` is rejected at load — it would expire every hook\nin the config immediately — so the override is ignored, per-hook\n`timeout_secs` applies, the listing shows that per-hook value with no\noverride provenance, and the rejection appears under `configuration\nproblems`. The\n`[hooks].enabled` flag's state is shown at the top so it's\nobvious when hooks are globally suppressed, and any entry rejected\nat load is listed under `configuration problems` with the reason.\nHooks are configured under `[[hooks.hooks]]` entries — see\n[`docs/HOOKS.md`](HOOKS.md) for the full schema.\n\n### Mutable `message_submit` hooks\n\n`message_submit` hooks run before a submitted message is added to\nhistory or sent to the model. Unlike observer-only lifecycle hooks,\nnon-background `message_submit` hooks can replace or block the\nsubmitted text.\n\n```toml\n[[hooks.hooks]]\nevent = \"message_submit\"\ncommand = \"~/.codewhale/hooks/inject-context.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe hook receives JSON on stdin:\n\n```json\n{\n \"event\": \"message_submit\",\n \"text\": \"original user text\",\n \"text_bytes\": 18,\n \"text_original_bytes\": 18,\n \"text_truncated\": false,\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234\n}\n```\n\nThe entire serialized document is capped at 32 KiB. Codewhale retains the\nlargest UTF-8-safe `text` prefix that fits after JSON escaping and bounded\nmetadata, and the three `text_*` fields make truncation explicit. Immediate\nmessages, restored queue entries, merged steers, and prior-hook replacements\nall cross this same serialization boundary.\n\nIf the hook exits `0` and prints JSON with a non-empty string `text` field,\nthat value replaces the submitted text:\n\n```json\n{ \"text\": \"replacement user text\" }\n```\n\nExit `0` with empty stdout, or stdout JSON without `text`, leaves\nthe current text unchanged. A JSON `text` field must not be empty;\n`{\"text\":\"\"}` is treated as invalid stdout and ignored. Exit `2`\nblocks the submission before the turn starts; a structured `reason` field can\nprovide the bounded, redacted status message shown in the TUI. Raw stdout,\nstderr, and process-error text are not copied into denial receipts.\nOther non-zero exits follow the hook's `continue_on_error` setting.\nTimeouts and spawn failures are also surfaced as transient TUI status\nmessages when `continue_on_error = true` lets submission continue.\n\nMultiple `message_submit` hooks run in config order, and each hook\nreceives the text produced by the previous hook. Hooks marked\n`background = true` are observer-only and cannot transform or block\nthe message — they still receive the same stdin payload and the same\nenvironment, they are simply never awaited. Existing environment\nvariables remain available.\n`shell_env` hooks keep their existing `KEY=VALUE` stdout contract;\nJSON stdout contracts exist for `message_submit` (above) and\n`tool_call_before` (below).\n\n### `tool_call_before` decision hooks\n\n`tool_call_before` hooks run before each tool call executes. In\naddition to the legacy hard deny (exit code `2`, which always wins\nregardless of stdout), a foreground hook may print a JSON decision on\nstdout with exit code `0`:\n\n```json\n{\n \"decision\": \"allow\" | \"deny\" | \"ask\",\n \"reason\": \"human-readable explanation (used for deny)\",\n \"updatedInput\": { \"command\": \"ls -la\" },\n \"additionalContext\": \"text appended to the tool result for the model\"\n}\n```\n\nAll fields are optional. Empty stdout, non-JSON stdout, and JSON\nwithout a `decision` field behave exactly as before (allow). An\nunrecognized `decision` string logs a fixed warning without echoing the\nuntrusted value and is treated as allow.\n\n- `deny` blocks the tool; the model receives a permission-denied tool\n result containing `reason`.\n- `ask` forces the interactive approval prompt in Ask and Auto-Review even for\n tools that would otherwise auto-run. Full Access does not open tool-approval\n prompts, so hook `ask` does not downgrade that posture.\n- `updatedInput` must be a JSON object; it replaces the tool input\n before execution. When several hooks supply it, the last hook wins.\n- `additionalContext` is appended to the tool result sent back to the\n model as `[hook context] ...`. Multiple hooks' contexts are\n concatenated.\n\nWhen multiple hooks match, precedence is deny > ask > allow. Hooks\nmarked `background = true` cannot steer tool calls — they are\nsubmitted and never awaited, so they have no verdict to contribute.\n\nA foreground hook that produced no verdict at all — it hit its timeout, the\nprocess could not be started, or a strict process exited non-zero without an\nexplicit JSON decision — is not treated as permission. If\n*that* hook is configured with `continue_on_error = false`, the outcome\ndenies the tool call and the denial names the hook and a bounded reason.\nStrictness is read off the hooks that actually matched this call, so a\nstrict gate scoped to another tool cannot deny it, and a lenient hook's\ntimeout does not deny merely because a strict hook exists elsewhere in\nconfig. Under the default `continue_on_error = true` the outcome is\nlogged and the call proceeds.\n\n`reason` and `additionalContext` are capped (2 000 characters per field,\n8 000 for the concatenated context of one call) and stripped of control\ncharacters before they reach the TUI or the model.\n\nExample deny hook:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"blocked by project policy\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nExample ask hook (force approval for every MCP tool):\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"ask\"}' '''\ncondition = { type = \"tool_name\", name = \"mcp__*\" }\n```\n\nExample input rewrite:\n\n```toml\n[[hooks.hooks]]\nevent = \"tool_call_before\"\ncommand = \"~/.codewhale/hooks/clamp-shell-timeout.sh\"\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\nwhere the script reads the hook context, then prints\n`{\"updatedInput\": {...}}` with the adjusted arguments.\n\n`tool_name` conditions support `*` globs: `mcp__*` matches every MCP\ntool (e.g. `mcp__github__create_issue`) but not built-ins like\n`read_file`; exact names keep matching exactly. Other regex\nmetacharacters in the pattern are matched literally.\n\n### Project-local hooks\n\nRepositories can ship policy in `/.codewhale/hooks.toml`,\nusing the same shape as the `[hooks]` table (top-level fields plus\n`[[hooks]]` entries). Project hooks are executable shell\nconfiguration, so Codewhale only loads them after the workspace has\nbeen trusted in user-owned config through the trust prompt or a\n`[projects.\"\"] trust_level = \"trusted\"` entry. Session\n`/trust on` mode does not enable repo-supplied hooks by itself, and\nrepo-local legacy markers such as `.deepseek/trusted` do not enable\nproject hooks. Once trusted, project hooks are appended after global\nhooks from `config.toml`, so they run last and, for `updatedInput`,\nwin ties. A malformed trusted project file logs a warning and startup\nfalls back to global hooks only.\n\n```toml\n# .codewhale/hooks.toml\n[[hooks]]\nevent = \"tool_call_before\"\ncommand = '''echo '{\"decision\":\"deny\",\"reason\":\"no shell in this repo\"}' '''\ncondition = { type = \"tool_name\", name = \"exec_shell\" }\n```\n\n### Turn-end observer hooks\n\n`turn_end` hooks observe the end of each model turn after post-turn\nstate, usage totals, cost accounting, notifications, receipts, and\nqueue recovery have been updated. They receive JSON on stdin and are\nobserver-only: stdout is ignored, failures are logged as warnings, and\nthe hook cannot block user input, mutate the transcript, or change the\nnext queued follow-up.\n\nObserver-only UI events share one 32-entry queue and two persistent workers;\nthe terminal loop uses non-blocking submission and does not create a thread per\nevent. A full queue or unavailable dispatcher drops that observer event and is\nkept as an event-specific error toast, independent of ordinary agent/turn\nstatus text.\n\n```toml\n[[hooks.hooks]]\nevent = \"turn_end\"\ncommand = \"~/.codewhale/hooks/turn-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\nThe payload includes common hook metadata plus post-turn accounting:\n\n```json\n{\n \"event\": \"turn_end\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"created_at\": \"2026-07-12T10:30:00+00:00\",\n \"model_backed\": true,\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-chat\",\n \"billing_surface\": null,\n \"turn_id\": \"turn_12345678\",\n \"status\": \"completed\",\n \"error\": null,\n \"duration_ms\": 1834,\n \"usage\": {\n \"input_tokens\": 1200,\n \"output_tokens\": 180,\n \"prompt_cache_hit_tokens\": 900,\n \"prompt_cache_miss_tokens\": 300,\n \"prompt_cache_write_tokens\": 0,\n \"reasoning_tokens\": null,\n \"reasoning_replay_tokens\": null\n },\n \"totals\": {\n \"session_tokens\": 1380,\n \"conversation_tokens\": 1380,\n \"input_tokens\": 1200,\n \"output_tokens\": 180\n },\n \"tool_count\": 2,\n \"queued_message_count\": 1,\n \"stop_hook_active\": false\n}\n```\n\n`created_at` anchors time-window pricing; `provider` and `model` identify the\neffective route used for model-backed turns. `billing_surface` is an optional,\nnon-secret classification derived from the endpoint that actually served the\nturn. Recognized StepFun routes emit `stepfun-payg` or `stepfun-plan`; the raw\nbase URL is never written to hook or runtime records. Runtime `TurnRecord`\nexports call the same field `effective_billing_surface`, which `scorecard`\naccepts as an alias. This keeps subscription quota separate from token-priced\nusage. Unrecognized and custom endpoints remain `null` and unpriced.\n\nShell-only lifecycle completions set `model_backed` to `false` and may report a\n`null` provider; offline scorecards exclude those records from model token and\ncost totals. Completion-only shell, manual-compaction, and purge events that do\nnot have a matching `TurnStarted` retain the observer notification with a\nsynthetic `lifecycle_` turn id and the time the completion was observed.\n\nFor `interrupted` or `failed` turns, `status` reflects that terminal\nstate and `error` carries the engine error string when one is available.\n`stop_hook_active` is reserved for future re-entry protection and is\ncurrently always `false`.\n\n### Sub-agent lifecycle hooks\n\n`subagent_spawn` and `subagent_complete` hooks observe sub-agent lifecycle\nevents. They receive bounded JSON metadata on stdin and are observer-only:\nhook failures are logged as warnings and do not block sub-agent scheduling,\nchange prompts, or change results. For these observer events,\n`continue_on_error` has no effect: later matching hooks still run even when an\nearlier hook exits non-zero.\n\n```toml\n[[hooks.hooks]]\nevent = \"subagent_complete\"\ncommand = \"~/.codewhale/hooks/subagent-audit.sh\"\ntimeout_secs = 2\ncontinue_on_error = true\n```\n\n`subagent_spawn` receives:\n\n```json\n{\n \"event\": \"subagent_spawn\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"prompt_preview\": \"bounded prompt preview\",\n \"prompt_truncated\": false\n}\n```\n\n`subagent_complete` receives the same common fields plus terminal metadata:\n\n```json\n{\n \"event\": \"subagent_complete\",\n \"agent_id\": \"agent_12345678\",\n \"session_id\": \"sess_12345678\",\n \"workspace\": \"/path/to/workspace\",\n \"mode\": \"agent\",\n \"model\": \"deepseek-chat\",\n \"total_tokens\": 1234,\n \"status\": \"completed\",\n \"result_preview\": \"bounded result preview\",\n \"result_truncated\": false\n}\n```\n\nPreviews are capped before delivery so lifecycle hooks do not receive full\nsub-agent prompts, transcripts, or unbounded results. Use the transcript handle\nreturned by `agent` when full sub-agent details are needed.\n\n### Running-turn input\n\nComposer shortcuts keep the same role throughout a session:\n\n- **Enter** sends when idle and queues a next-turn follow-up while busy. The\n behavior does not change before versus after the provider's first token.\n- With an empty composer and queued follow-ups visible, **Enter** sends the\n oldest queued follow-up into the active turn now.\n- **Ctrl+Enter** (or **Cmd+Enter** when the terminal forwards it) explicitly\n steers the active turn. It sends normally when idle.\n- By default, **Shift+Enter**, **Alt+Enter**, and **Ctrl+J** insert a newline.\n- Set `composer_multiline_mode = true` to make **Enter** insert a newline and\n **Shift+Enter** send instead. **Alt+Enter**, **Ctrl+J**, and supported\n **Ctrl+Enter** / **Cmd+Enter** behavior is unchanged.\n- **Ctrl+G** and **Ctrl+S** only stash drafts; they never send or steer.\n\n### Composer stash (`/stash`, Ctrl+G / Ctrl+S)\n\nPress **Ctrl+G** in the composer to park the current draft to\n`~/.codewhale/composer_stash.jsonl`. `/stash list` shows parked\ndrafts with one-line previews and timestamps; `/stash pop`\nrestores the most recently parked draft (LIFO); `/stash clear`\nwipes the file. Capped at 200 entries; multiline drafts round-trip intact.\n**Ctrl+S** remains an alias in terminals that forward it; Cursor and VS Code\nreserve Ctrl+S for Save, so Ctrl+G is the portable default.\n\n## Settings File (Persistent UI Preferences)\n\ncodewhale also stores user preferences in:\n\n- `~/.codewhale/settings.toml` on new installs\n- `~/.deepseek/settings.toml` or the legacy platform config-dir\n `deepseek/settings.toml` when an existing settings file is present\n\nNotable settings include `auto_compact`, which uses a model-aware default-on\npolicy for known context windows up to the 1M-token V4 class. Automatic\ncompaction runs before the active model limit and carries the compacted summary\nforward into the next request. The trigger defaults to\n`auto_compact_threshold_percent = 80`. Users who prefer manual continuity can\npersist `auto_compact = false`; manual `/compact` / Ctrl+L remains available.\nYou can inspect or update these from the TUI with `/settings` and `/config`\n(interactive editor).\n\nCommon settings keys:\n\n- `theme` (`system`, `terminal`, `underwater`, `underwater-retro`,\n `shoreline`, `shoreline-light`, `dark`, `light`, `grayscale`,\n `catppuccin-mocha`, `tokyo-night`, `dracula`, `gruvbox-dark`, `claude`,\n `matrix`, `solarized-light`, `uwu`; default `underwater`): `underwater` is\n the dark navy fresh-install default, `shoreline` is its warm charcoal\n alternative and `shoreline-light` the light variant, `system` follows terminal\n background detection, `dark`/`light` use the Codewhale Whale pair,\n `terminal` inherits the host terminal, `grayscale` is the low-opinion\n black/white theme, and the named community presets apply across the TUI.\n Aliases such as `whale`, `mono`, `black-white`, `tokyonight`, and `gruvbox`\n are accepted. In Whale, cobalt blue owns action/focus, seafoam owns live\n work, Signal Gold owns human decisions and the whale, coral owns warnings,\n rose owns danger, violet owns Operate, and green remains completed/verified.\n Text labels, markers, and motion policy carry the same states when color is\n unavailable; color is never the only cue.\n User-authored overlays live only at `~/.codewhale/themes/.json` (or\n `$CODEWHALE_HOME/themes/.json`) and are selected with\n `/theme custom:`. The filename is a bounded slug, symlinks and files\n over 64 KiB are refused, colors must be `#RRGGBB`, and unknown fields fail\n validation. `/theme schema` prints the embedded JSON Schema and `/theme path`\n shows the exact directory. An overlay names one compiled `base` theme and\n changes only listed semantic colors; it cannot include or read another file.\n Open `/theme` to browse valid overlays, preview them live, and keep the\n active `custom:` selector when the picker is opened without moving.\n- `auto_compact` (on/off, model-aware default on for known context windows\n unless explicitly configured)\n- `auto_compact_threshold_percent` (10-100, default `80`): pre-send\n auto-compaction threshold used only when `auto_compact` is enabled.\n- `paste_burst_detection` (on/off, default on): fallback rapid-key paste\n detection for terminals that do not emit bracketed-paste events. This is\n independent of terminal bracketed-paste mode.\n- `work_surface_placement` (`bottom`, `top`, `left`, `right`, or `off`;\n default `bottom`): places the workbar — Tasks / To-do / Workers — under the\n composer (the default bottom workbar), above the transcript, in a side\n workbar, or hides it entirely (`off`). Side choices fall back to the top\n layout on narrow terminals without changing the saved preference. Set it\n live with `/config work_surface_placement right --save` (or `left` / `top` /\n `bottom` / `off`).\n- `rail_panel` (`tasks`, `agents`, `background`, `files`, `notepad`,\n `context`, `git`, `price`; default `tasks`, alias key `rail`): which panel\n the workbar shows. Panel selection is orthogonal to placement. `tasks` is\n the full live work list (to-dos, then sub-agents); `agents` narrows to the\n sub-agent rows; `background` lists background shells and automations;\n `files` lists touched files; `notepad` shows the workspace notes; `context`\n is a read-only session-facts list; `git` shows branch status; `price`\n shows cost. In every panel except `context`, rows are selectable and\n clickable and open their detail surface. `Alt+!`/`Alt+@`/`Alt+#`/`Alt+$`\n switch panels live.\n- `work_surface_top_height` (2–16) and `work_surface_side_width` (26–80):\n ceilings for the top strip's height and the side workbar's width. Both are\n normally persisted by dragging the divider rather than edited by hand; the\n strip still auto-fits its content below the ceiling.\n- `focus_texture` (`off`, `scrim`, or `grain`; default `off`): focus-context\n texture for modal views. `scrim` dims the already-rendered background\n outside the focused modal toward the theme surface; `grain` sprinkles\n sparse dots over blank cells there. The texture is static (no time\n component, so it is unaffected by `low_motion`), never writes over a cell\n that carries text, and preserves the 4.5:1 body-text contrast floor\n wherever both colors are resolvable. It is skipped entirely on frames\n below the ambient-life minimum size and when the focused modal already\n covers 90% or more of the frame. Set it live with\n `/config focus_texture scrim --save`.\n- `mention_menu_limit` (integer, default `128`): maximum number of\n `@`-mention popup candidates retained before the composer renders the\n visible window. The visible rows still depend on terminal height.\n- `mention_walk_depth` (integer, default `10`): maximum workspace depth for\n `@`-mention completion walks. Set to `0` for unlimited depth in deeply\n nested workspaces; keep the default in very large repos unless needed.\n- `mention_menu_behavior` (`fuzzy`, `browser`; default `fuzzy`): controls how\n `@`-mention completions are populated. `fuzzy` searches the workspace and\n applies mention frecency. `browser` lists only the immediate children of the\n currently typed directory segment in deterministic alphabetical order.\n- `show_thinking` (on/off)\n- `thinking_default_expanded` (on/off, default off): renders thinking blocks\n expanded initially when `show_thinking` is enabled. Space still toggles the\n selected block, and it decides only the blocks you have not touched: a block\n you expanded or collapsed yourself keeps that state if you change this\n setting later. This is useful in SSH/tmux environments where the Space\n binding may be intercepted.\n- `thinking_preview_lines` (integer, default `2`): how many body rows a\n **collapsed** completed thought still shows. `0` is header-only; `10` is\n the older dump. Live streaming preview is unchanged. Expand a block with\n Space, or set `thinking_default_expanded` to open every block.\n- `help_expand_groups` (on/off, default off): start Help/shortcuts with every\n group expanded. Default folds the long tail (Grok-style); type-to-filter\n still unfolds matches.\n- `pin_last_prompt` (on/off, default on): pin the last user prompt at the top\n of the transcript viewport after it scrolls off.\n- `show_tool_details` (on/off)\n- `inline_diffs` (`full`, `summary`, or `off`; default `full`): controls the\n inline presentation of successful structured File mutations. `full` shows a\n bounded red/green diff and semantic statistics, `summary` keeps only the\n statistics, and `off` keeps the calm changed-file outcome. All three retain\n the exact applied change in the selected File receipt's Alt/Option+V detail.\n Failure and cancellation never render a successful diff. Save the choice\n with `/config inline_diffs --save`.\n- `locale` (`auto`, `en`, `ja`, `zh-Hans`, `zh-Hant`, `pt-BR`, `es-419`, `vi`,\n `ko`; default `auto`): UI chrome locale. `auto` checks `LC_ALL`,\n `LC_MESSAGES`, then `LANG`; unsupported locale selections resolve to English.\n Every shipped pack holds full `en.json` parity, so no string falls back\n to English. The runtime also exposes the resolved locale in the system\n prompt as the fallback natural language for V4 reasoning and replies when the\n latest user message is ambiguous. Clear user language still takes priority;\n Chinese turns should produce Chinese `reasoning_content` and Chinese final\n replies even when the resolved locale is English.\n- `background_color` (`#RRGGBB`, `RRGGBB`, or `default`): optional main TUI\n background color applied to the root, header, transcript, and footer\n surfaces while preserving panel contrast.\n- `cost_currency` (`usd`, `cny`; default `usd`): currency used by the footer,\n context panel, `/cost`, `/tokens`, and long-turn notification summaries. The\n aliases `rmb` and `yuan` normalize to `cny`.\n- `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary)\n- `launch_screen` (legacy, migration-only): this historical `on`/`off` value\n is still accepted when reading an existing settings file, but it no longer\n changes behavior and is omitted from new saves. A fresh interactive launch\n always opens Tideline Startup; only an explicit resume or an explicit\n initial prompt enters the live session directly.\n- `sidebar_focus` (legacy, migration-only): the classic right sidebar this key\n configured was removed in the 0.9.4 rail unification. The key is still read\n once so old settings carry forward, then folds into the live keys:\n `pinned`/`work`/`plan`/`todos` become `rail_panel = \"pinned\"`,\n `agents`/`subagents` become `rail_panel = \"agents\"`, `context`/`session`\n become `rail_panel = \"context\"`, `tasks`/`auto` (the old default) become the\n `tasks` panel, `sessions` enables `sessions_rail`, and `hidden` turns the\n workbar off via `work_surface_placement = \"off\"`. An explicit `rail_panel`\n in the file always wins over the migrated value. Configure the workbar with\n `rail_panel` and `work_surface_placement`, not this key.\n- `sessions_rail` (`on`/`off`; default `off`): show the persistent Sessions\n list in the workbar. Rows list this workspace's recent\n non-archived sessions, newest first, with the active one marked; activating a\n row opens the session picker preselected on it (`/sessions open `), so\n resume keeps its single implementation. Rows are projected from cached\n session metadata — the list never reads a transcript per frame, and never\n contacts a provider.\n- `session_auto_resume` (`on`/`off`; default `off`): reattach to this\n workspace's most recent session when Codewhale starts. Off by default so\n plain `codewhale` keeps starting fresh. `--resume`, `--continue`, and\n `--fresh` always take precedence. When it is on, startup still refuses to\n resume a session that is archived, fails to load, or is recorded against a\n different workspace; each of those falls back to a fresh transcript and says\n which session was skipped and why. It applies to the interactive launch only\n — `codewhale \"\"` and `codewhale exec` are never silently prefixed\n with a prior conversation.\n- `max_input_history` (number of submitted input history entries; cleared\n drafts are also kept locally for composer history search). Note the spelling:\n the serde field on disk is `max_input_history`\n (`crates/tui/src/settings.rs:426`, default 100). `max_history` is the key\n name accepted by `/config set` and `settings.set()` (`settings.rs:1388`), not\n a settings.toml key — writing `max_history` into the file is silently\n ignored.\n- `default_model` (model name override)\n\n`/task digest` (alias `/tasks digest`) renders the canonical Work Graph\noperations and four-state To-do list as plain text, running work first. It\nreads the same snapshots as the styled Work surface and owns no parallel\nprogress state.\n\nPlan and Work are the everyday visible modes in the UI; Operate is an explicit\npreview entry while its Workflow control surface is still being built. Switch\nbetween them with `/mode`. For compatibility, older settings files with\n`default_mode = \"normal\"` still load as `agent`.\n\nLocalization scope is tracked in [LOCALIZATION.md](LOCALIZATION.md). The v0.7.6\ncore pack covers high-visibility TUI chrome only; provider/tool schemas,\npersonality prompts, and full documentation remain English unless explicitly\ntranslated later.\n\nReadability semantics:\n\n- Selection uses a unified style across transcript, composer menus, and modals.\n- Footer hints use a dedicated semantic role (`FOOTER_HINT`) so hint text stays readable across themes.\n\n### Token Quantities and Drivers\n\nDeepSeek V4 prefix caching makes token labels matter. These quantities are kept\nseparate:\n\n| Quantity | Meaning | Allowed to drive |\n|---|---|---|\n| Active request input estimate | Conservative estimate of the next request's live system prompt and transcript payload. | Header/footer context percent, auto-compaction trigger, opt-in Flash seam trigger, and emergency overflow preflight. |\n| Reserved response headroom | The effective request cap plus `1024` safety tokens on every route. Normal no-override requests start at `65536`; a smaller route/provider ceiling narrows that value, and an explicit output override raises it only within the resolved route window and output ceiling. The identical cap reaches the wire and drives preflight; reasoning effort does not add a second hidden reservation. A separately published route input ceiling independently clamps the spendable input budget. | Emergency overflow budget checks only. |\n| Cumulative API usage | Provider-reported input plus output tokens summed across completed API calls; multi-tool turns may count the same stable prefix more than once. | Session usage and approximate cost telemetry only. |\n| Prompt cache hit/miss | Provider cache telemetry for the most recent call when available. | Cache-hit display and cost estimation only; never compaction or seam triggers. |\n| Context percent | Active request input estimate divided by the model context window. | Display only; it mirrors the active-input basis used by context safeguards. |\n| Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. |\n\nFor known context-window models, including 1M-class V4 models, replacement\ncompaction is enabled by default unless the user explicitly configures\n`auto_compact = false`. It fires at the active model's compaction threshold and\nreplaces old history with recent user context followed by one ordinary\ncheckpoint message. The standing system prompt remains unchanged. Unknown model\nids remain opt-in.\n\n### Command Migration Notes\n\nIf you are upgrading from older releases:\n\n- Old: `/codewhale`\n New: `/links` (aliases: `/dashboard`, `/api`)\n- Old: `/set model deepseek-reasoner`\n New: `/config` and edit the `model` row to `deepseek-v4-pro` or `deepseek-v4-flash`\n- Old: visible `Normal` mode or `default_mode = \"normal\"`\n New: use `Agent` / `default_mode = \"agent\"`; legacy `normal` still maps to `agent`\n- Old: discover `/set` in slash UX/help\n New: use `/config` for editing and `/settings` for read-only inspection\n\n## Key Reference\n\n### Kimi Code membership model IDs\n\nThe exact `https://api.kimi.com/coding/v1` endpoint accepts `k3`, `k3-256k`,\n`kimi-for-coding`, and `kimi-for-coding-highspeed`. Use `k3-256k` for a fixed\n262,144-token K3 window; use bare `k3` with `context_window = 1048576` only\nwhen the membership plan includes the 1M entitlement. Both K3 ids use the same\nreasoning contract, and all four membership ids omit generic sampling fields.\n\n### Core keys (used by the TUI/engine)\n\n- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. 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. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `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, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = \"token-plan-cn\"` for China and `mode = \"token-plan-ams\"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/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`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `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`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets official ChatGPT plan inference with Codewhale-owned OAuth at `https://api.openai.com/v1`; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.\n- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).\n- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.\n- `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 local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.\n- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = \"api_key\"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = \"https://api.kimi.com/coding/v1\"`, and bare `model = \"k3\"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = \"kimi-for-coding\"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = \"kimi_oauth\"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.\n- `base_url` (string, optional, `[providers.]` key; see [Legacy top-level `base_url` and `api_key`](#legacy-top-level-base_url-and-api_key) for the older top-level spelling): 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://api.deepseek.com/anthropic` for `deepseek-anthropic`, `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://token-plan-sgp.xiaomimimo.com/v1` for `xiaomi-mimo` when the API key starts with `tp-...` and `https://api.xiaomimimo.com/v1` otherwise, `https://api.novita.ai/openai/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`, `https://api.minimax.io/v1` for `minimax`, `https://api.openmodel.ai` for `openmodel`, `https://api.z.ai/api/coding/paas/v4` for `zai`, `https://api.stepfun.ai/v1` for `stepfun`, `https://api.deepinfra.com/v1/openai` for `deepinfra`, `https://api.sakana.ai/v1` for `sakana`, `https://router.huggingface.co/v1` for `huggingface`, `https://api-inference.modelscope.cn/v1` for `modelscope`, `https://api.together.xyz/v1` for `together`, `https://api.baiduqianfan.ai/v1` for `qianfan`, `https://api.openai.com/v1` for `openai-codex`, `https://api.anthropic.com` for `anthropic`, `https://api.mistral.ai/v1` for `mistral`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, `http://localhost:11434/v1` for `ollama`, and `https://ollama.com/v1` for `ollama-cloud`. Set `base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"` for China-region Xiaomi MiMo Token Plan accounts or `base_url = \"https://token-plan-ams.xiaomimimo.com/v1\"` for Europe/Amsterdam accounts. Mistral-specific reasoning fields and polymorphic replay are enabled only on the documented first-party HTTPS `/v1` hosts; a custom Mistral base URL keeps generic Chat semantics. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features.\n- `ollama-cloud` route: select `provider = \"ollama-cloud\"`, configure `[providers.ollama_cloud]` when overriding the default `https://ollama.com/v1` / `gpt-oss:120b` tuple, and save a key from [Ollama account settings](https://ollama.com/settings/keys) with `codewhale auth set --provider ollama-cloud`. Ambient precedence is `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`; arbitrary Ollama model IDs pass through unchanged.\n- Legacy Ollama Cloud migration: a released `provider = \"ollama\"` config whose normalized `[providers.ollama].base_url` is exactly `https://ollama.com/v1` is upgraded to the `ollama-cloud` runtime identity in memory. Only that exact tuple may read its old `ollama` provider table and secret slot. The config and secrets are never rewritten, and neighboring paths, HTTP downgrades, lookalike hosts, or an explicit `ollama-cloud` selection never consume the fallback.\n- `telecomjs` base URL and catalog: `[providers.telecomjs]` defaults to `https://aigw.telecomjs.com/v1`; `TELECOMJS_BASE_URL` overrides it. With `TELECOMJS_API_KEY`, `/models` refreshes a key-scoped catalog without mixing rows into another provider.\n- `edenai` gateway: select `provider = \"edenai\"`; `[providers.edenai]` defaults to `https://api.edenai.run/v3` and `deepseek/deepseek-v4-pro`. `EDENAI_API_KEY`, `EDENAI_BASE_URL`, and `EDENAI_MODEL` are accepted. Use `EDENAI_BASE_URL = \"https://api.eu.edenai.run/v3\"` for Eden AI's documented EU endpoint; the default `deepseek/deepseek-v4-pro` is only listed on the global catalog, so pair the EU endpoint with an EU-listed model such as `qwen/deepseek-v4-pro` via `EDENAI_MODEL` or `model`. The provider refreshes Eden AI's `/models` catalog, but leaves model-specific reasoning controls untouched because the gateway spans multiple model families.\n- `codewhale` (Codewhale API): select `provider = \"codewhale\"`; `[providers.codewhale]` defaults to `https://api.codewhale.net/v1` and `deepseek/deepseek-v4-pro`. The credential is a Codewhale account API key (`cwc_key_…`) with the `models:infer` scope, read from `CODEWHALE_API_KEY` or the `codewhale` secret-store slot; `codewhale account api-keys create --name --use` mints one and saves it locally. `CODEWHALE_API_BASE` overrides the origin and must be HTTPS except on loopback. The model catalog is the account's own authenticated `GET /v1/models`: ids are `provider/model` and each row states its wire (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`). Connect the underlying provider keys with `codewhale account keys set `.\n- `concentrate` gateway: select `provider = \"concentrate\"`; `[providers.concentrate]` defaults to `https://api.concentrate.ai/v1` and `deepseek-v4-pro` over the OpenAI Responses wire. `CONCENTRATE_API_KEY`, `CONCENTRATE_BASE_URL`, and `CONCENTRATE_MODEL` are accepted. Model ids pass through verbatim (`gpt-5.6-sol`, `openai/gpt-5.6-sol`, or `concentrate/auto` for the gateway router). BYOK only; see [PROVIDERS.md](PROVIDERS.md#concentrate-notes).\n- `mistral` model and reasoning contract: `[providers.mistral]` defaults to `mistral-code-latest`; `MISTRAL_MODEL` overrides it and the generic `CODEWHALE_MODEL` override wins when both are set. The current picker also lists `mistral-medium-latest`, `mistral-small-latest`, and `mistral-large-latest`. On exact first-party HTTPS `/v1` routes, Medium and Small accept only `reasoning_effort = \"none\" | \"high\"` and replay polymorphic thinking blocks. Deprecated native Magistral IDs may still be configured explicitly, remain always-reasoning, and never receive the adjustable effort field.\n- `context_window` (integer, optional provider-table key): override the total context window for the active `[providers.]` route when an OpenAI-compatible gateway, hosted model alias, or self-hosted runtime has a different limit than Codewhale's static model table. For example, `[providers.openai] context_window = 1000000` lets an OpenAI-compatible DashScope/Qwen route budget against a 1M-token window instead of the conservative fallback. For Kimi Code K3, keep `model = \"k3\"` and set `[providers.moonshot] context_window = 1048576` only when the membership plan includes 1M access; otherwise omit it to retain the 262,144-token safe baseline. The value must be greater than 0 and affects prompt context notes, compaction thresholds, context-pressure checks, and request output caps. Full resolution order, and how to see which rung produced the current window: [Context length (context window)](#context-length-context-window).\n- `path_suffix` (string, optional provider-table key): override the chat-completions path for OpenAI-compatible gateways that do not serve `/v1/chat/completions`. For example, `[providers.openai] path_suffix = \"/chat/completions\"` sends chat requests to the unversioned base URL plus `/chat/completions`; `models` and `beta/*` requests keep their normal routing.\n- `reasoning_stream_style` (string, optional provider-table key): override how streaming reasoning is separated from answer text for the active provider route. Use `separate_field` for `reasoning_content` / `reasoning` deltas, `inline_tags` for gateways that stream `...` inside `delta.content`, or `none` to render incoming content exactly as answer text. When unset, every Chat Completions route uses `separate_field` (Mistral's first-party route uses its typed thinking blocks); set `none` only for a gateway that streams its answer inside `reasoning_content`.\n- `[providers..auth]` (table, optional): provider-scoped auth source metadata. `source = \"command\"` stores a command argv plus optional `timeout_ms`; `source = \"secret\"` stores a `secret_id`. This slice lets provider readiness, `/provider`, and doctor JSON report the auth source class without exposing command argv output or secret values; executing commands and resolving external secret material is handled by the follow-up resolver work.\n- `insecure_skip_tls_verify` (bool, optional provider-table key): legacy compatibility key, disabled by default. When true on the active provider table, provider clients reject the configuration instead of skipping TLS certificate verification. Use `SSL_CERT_FILE` for corporate or private CA bundles; `codewhale doctor` reports stale uses of this setting.\n- `default_text_model` (string, optional): defaults to `deepseek-flash` for DeepSeek and `deepseek-anthropic`, `gpt-5.6` for OpenAI, `grok-4.6` for xAI, `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 and DeepInfra, `trinity-large-thinking` for Arcee AI, `kimi-k2.7-code` for Moonshot, `MiniMax-M3` for MiniMax, `GLM-5.3` for Z.ai, `step-3.7-flash` for StepFun, `ernie-4.0-turbo-8k` for Qianfan, `fugu` for Sakana AI, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, no fixed id for local Ollama (Codewhale adopts a chat-capable tag from the live local catalog — coder or tool-capable tags first, then the largest context — and never an embedding or reranker model), and `gpt-oss:120b` for Ollama Cloud. Hugging Face and Together AI both default to `deepseek-ai/DeepSeek-V4-Pro`; `openai-codex` defaults to `gpt-5.6`; `anthropic` defaults to `claude-sonnet-4-6`; `openmodel` defaults to `deepseek-v4-flash`. Current public DeepSeek IDs include `deepseek-v4-pro` and `deepseek-flash` (V4.1 Flash, shipped as the unversioned id), both with 1M context windows, 384K max output, and thinking mode enabled by default. DeepSeek's live pricing/model page now labels the Pro backend `DeepSeek-V4-Pro-0813`; the callable API ID remains `deepseek-v4-pro`, so Codewhale does not send the backend label or the Claude Code-specific `deepseek-v4-pro[1m]` selector. DeepSeek retires `deepseek-chat` and `deepseek-reasoner` on July 24, 2026; direct first-party routes migrate both to `deepseek-v4-flash`, with omitted reasoning settings preserving their former non-thinking (`off`) and thinking (`high`) intent. Explicit `reasoning_effort` wins, and provider-owned ids on Wanjie Ark, aggregators, self-hosted runtimes, and custom endpoints are not globally rewritten. SiliconFlow retains its own mapping: `deepseek-reasoner` and `deepseek-r1` select its Pro model while `deepseek-chat` and `deepseek-v3` select 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`, `minimax/minimax-m2.7`, `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`, `qwen/qwen3.7-max`, `google/gemma-4-31b-it`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, and `nvidia/nemotron-3-ultra-550b-a55b`; direct Arcee uses bare IDs such as `trinity-large-thinking` and `trinity-large-preview`; direct Moonshot recognizes `kimi-k3`, `kimi-k2.7-code`, and `kimi-k2.6`. The exact Kimi Code endpoint recognizes bare `k3` for K3 and `kimi-for-coding` for K2.7; those membership IDs are distinct from the direct Moonshot IDs and are never rewritten across routes. Direct MiniMax recognizes `MiniMax-M3` and the documented M2.x chat model IDs; direct Z.ai recognizes `GLM-5.3` (the default), `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo`, and OpenRouter recognizes the matching `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, and `z-ai/glm-5-turbo` IDs — `GLM-5.3` has been live on the Z.ai Coding Plan since 2026-08-13; it inherits its catalog metadata from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers and carries no price, and an explicit `GLM-5.2` selection keeps its own id; direct Sakana recognizes `fugu` and `fugu-ultra-20260615`; direct Xiaomi MiMo recognizes chat IDs `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, and `mimo-v2.5`, while TTS IDs are selected through `codewhale speech` / `tts`. Generic `openai`, `atlascloud`, `wanjie-ark`, `xiaomi-mimo`, `arcee`, `moonshot`, `minimax`, `openmodel`, `zai`, `stepfun`, `qianfan`, `sakana`, local Ollama, and Ollama Cloud 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.\n- TelecomJS uses `deepseek-v4-pro` only as a conservative pre-refresh fallback. Once its key-scoped `/models` catalog is available, the picker uses those live rows; Codewhale omits unsupported reasoning request fields on this route.\n- `reasoning_effort` (string, optional): `auto`, `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `ultra` (`ultracode` is an alias of `ultra`), or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. Ollama Cloud's OpenAI-compatible Chat Completions route preserves its documented `none` / `low` / `medium` / `high` / `max` ladder (`off` is sent as `none`; `xhigh` and `ultracode` normalize to `max`). Direct xAI `grok-4.7` and `grok-4.6` on exact `https://api.x.ai/v1` receive top-level `reasoning_effort = \"low\" | \"medium\" | \"high\" | \"xhigh\"` (the ladder comes from the bundled catalog row; `grok-4.5` maps `xhigh` to `high`, and rows without a documented effort such as `grok-4.3` get no field); Grok reasoning cannot be disabled, so `off` normalizes to `high`, `max`/`ultracode` to `xhigh`, and `auto` leaves the field omitted so xAI's documented default `high` applies. A custom xAI-compatible `base_url` does not inherit that dialect. Direct Moonshot `kimi-k3` on exact `https://api.moonshot.ai/v1` is always-thinking and receives only top-level `reasoning_effort = \"low\" | \"high\" | \"max\"`; `off` normalizes to `low`, and `medium` to `high`. Kimi Code membership `k3` on exact `https://api.kimi.com/coding/v1` instead receives nested `thinking.effort`, and its `off` setting also normalizes to enabled `low`. Normal dispatched `auto` uses Codewhale's auto-reasoning selector and sends a concrete route-normalized tier; only an omitted reasoning setting leaves the provider default in control. Neighboring gateways and model/endpoint combinations retain the generic Moonshot contract. OpenAI Codex normalizes stale `off` to `low` and sends `max` / `ultracode` as Responses `xhigh`. Z.ai receives documented `thinking` controls and treats enabled thinking as the GLM coding high/max lane. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`.\n- `verbosity` (string, optional): `normal` or `concise`. `normal` keeps the\n default conversational prompt. `concise` appends a prompt discipline block\n for direct, low-chatter output; CLI noninteractive commands (`exec` and\n `eval`) default to `concise` unless config/env/CLI overrides it.\n Override per process with `CODEWHALE_VERBOSITY` or the legacy\n `DEEPSEEK_VERBOSITY` alias.\n- `telemetry` (bool, optional): anonymous usage counting, **`true` by default\n in the current 0.9.12 source**. Notice version `5` names Codewhale and PostHog\n and describes the opt-out policy; no acceptance is invented for a default\n user. Existing explicit declines remain off. An explicit `false` here is the\n durable *opt-out*: it deletes the random install id, truncates buffered and\n dry-run events, and leaves a tombstone reasserted while the setting is false.\n It is a floor: `--telemetry true` and `CODEWHALE_TELEMETRY=1` lose to it.\n Use `/settings` or `codewhale config set telemetry true` to turn counting\n back on explicitly for new sessions through the existing privacy transition. `CODEWHALE_TELEMETRY`\n (legacy alias `DEEPSEEK_TELEMETRY`) and `--telemetry false` provide a run-scoped\n kill switch that stops collection and delivery without erasing the owner's\n state. A repo-local `.codewhale/config.toml` cannot set this preference.\n `codewhale config telemetry` shows the disclosure; `codewhale config get\n telemetry` reports preference and privacy status. Full schema and opt-out\n behavior: [`TELEMETRY.md`](TELEMETRY.md).\n- `telemetry_endpoint` (string, optional): where batches are POSTed. Leaving it\n unset selects the shipped default,\n **`https://telemetry.codewhale.net/v1/telemetry`** — the first-party ingest\n service described in [`TELEMETRY.md`](TELEMETRY.md), whose source is in\n `telemetry-ingest/`. This key decides only *where* a permitted session sends;\n it cannot override an opt-out. Setting it\n to the **empty string** is how you stay enabled and contact nobody: each batch\n is then written to `$CODEWHALE_HOME/telemetry/dryrun.jsonl` and no HTTP client\n is constructed at all, so you can read exactly what would have been sent. Any\n other value replaces the default outright. `https://` is required; plain\n `http://` is accepted only for loopback hosts, and there is no environment\n variable that overrides that refusal. A rejected endpoint turns telemetry off\n for the run rather than falling back to plaintext or to the default. Override\n per process with `CODEWHALE_TELEMETRY_ENDPOINT` (legacy alias\n `DEEPSEEK_TELEMETRY_ENDPOINT`), where an empty value means the same \"contact\n nobody\". A repo-local `.codewhale/config.toml` cannot set it.\n- `allow_shell` (bool, optional): in interactive TUI Agent sessions, omitting\n this keeps shell tools available with approval prompts; setting it to `false`\n hides shell tools. Headless, durable-task, and other noninteractive profiles\n keep the conservative omitted-field default and require `allow_shell = true`\n to expose shell. Plan mode always hides shell; Full Access enables shell and\n auto-approval.\n- `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.\n- `[approval] default_selection` (string, optional): which option an approval\n card highlights when it first appears — `deny` (default) or `allow_once`.\n `deny` means a reflexive Enter on a card you have not read refuses the call.\n The highlighted row is tagged `(Enter)`; press `y` to allow once.\n Set `allow_once` to restore the pre-v0.9.6 Enter-to-approve muscle memory\n (#5293). It moves the highlight only: which calls are prompted for is still\n `approval_policy` plus the rules in `permissions.toml`.\n\n ```toml\n [approval]\n default_selection = \"allow_once\"\n ```\n- `[approval] timeout_seconds` (integer, optional): bound how long an\n approval may wait — the TUI card and the Runtime API approvals the desktop\n app and web use alike. When the window elapses the call is refused\n (fail-closed) and recorded as a timeout, not as the operator's denial.\n Omitted or `0` waits indefinitely, which is the default everywhere: no\n approval is ever denied on your behalf unless you set this. Values above\n 24h clamp with a warning (#6101).\n\n ```toml\n [approval]\n timeout_seconds = 300\n ```\n- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.\n Platform support is not identical. macOS uses Seatbelt when its runtime\n probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`\n is installed and a probe proves it can confine a child; `prefer_bwrap =\n false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts\n with process-tree containment only and must not be described as read-only\n filesystem isolation, workspace-write enforcement, network blocking,\n registry isolation, or AppContainer isolation until those are implemented.\n- The cross-layer relationship between mode admission, hooks, registered tool\n requirements, typed rules, auto-review, repo law, human approval, and the\n execution sandbox is defined in\n [Authorization order](AUTHORIZATION_ORDER.md).\n- **Read deny-list.** Every sandbox posture — `read-only` included — grants\n read access to the whole filesystem; the postures differ in what they may\n *write* and whether they may reach the network. The read deny-list narrows\n that:\n - `sandbox_read_denylist_defaults` (bool, default `true`): apply the built-in\n credential-store set — `~/.ssh`, `~/.gnupg`, cloud credential directories\n (`~/.aws`, `~/.config/gcloud`, `~/.azure`, `~/.kube`, …), `~/.netrc`,\n `~/.npmrc`, `~/.git-credentials`, macOS keychains, browser profiles,\n Codewhale's own secret stores, and `.env` files (but not `.env.example`\n and friends). Ordinary source, `Cargo.toml`, `~/.gitconfig`, `~/.cargo`,\n and `~/.npm` stay readable so builds and tests still work. Set `false` to\n restore the pre-0.9.12 full-disk-read behavior.\n - `sandbox_denied_read_paths` (list of paths): additional denied subpaths.\n `~` expands. These can never be exempted.\n - `sandbox_read_denylist_exempt` (list of paths): subtract a path from the\n *built-in defaults* when a project genuinely needs it. Deny wins over\n allow: this never reopens anything in `sandbox_denied_read_paths`.\n\n Exemption granularity is **whole-rule**, not per-file. An exempt path\n removes a built-in rule only when the rule's own path is at or below it,\n so exempting `~/.ssh/config` does nothing: the `~/.ssh` rule still denies\n it, because `~/.ssh` does not lie within `~/.ssh/config`. To reopen that\n one file you must exempt `~/.ssh` itself — which also reopens the private\n keys next to it. That is the documented tradeoff: there is no shipped way\n to narrow a built-in rule to \"everything except one file\"; copy what you\n need out of the denied tree instead. The one name-shaped rule, `.env`\n files, is exempted by *name* rather than by path: any exempt entry whose\n file name is exactly `.env` — bare `.env`, `~/.env`,\n `some/project/.env` — disables the entire `.env` filename rule, i.e.\n every `.env` and `.env.` on disk rather than one project's.\n (`.env.example` and friends are never denied, so they need no exemption.)\n\n Enforced at two points: sandboxed shell commands (Seatbelt last-match-wins\n `deny file-read*` rules; bubblewrap masks each path) and Codewhale's own\n in-process tools, which the OS sandbox never wraps — `read_file` / `read` /\n `read_media` for contents, `list_dir` / `file_search` for *enumeration*\n (listing a denied directory, or searching one, is refused just as Seatbelt\n blocks its readdir; a name search rooted above a denied tree skips entries\n inside it). A refused read is always an explicit error, never an empty\n result, and the error names the path as the caller spelled it rather than a\n symlink target's real location.\n\n **This is defense-in-depth, not a security boundary.** It does not stop a\n hardlink to a denied file, a secret already copied into the workspace, an\n indirect read (`ssh-agent`, `security find-generic-password`, `aws sts …`),\n reads under `danger-full-access` shell commands, reads by MCP servers or\n other unwrapped child processes, or exfiltration of anything that *was*\n read. Keep least-privilege credentials and short-lived tokens doing the real\n work.\n- `permissions.toml` (sibling file, optional): typed permission rule records\n loaded next to `config.toml`, for example `~/.codewhale/permissions.toml`.\n This active user file is the only permission-rule source today; project\n config overlays do not load a project-local `permissions.toml`. A rule's\n optional `workspace` field is its repository scope, not a second source.\n Manually authored `[[rules]]` entries accept `tool`, optional `command` or\n `path`, optional absolute `workspace`, optional `command_exact = true`, and\n optional `action = \"deny\" | \"ask\" | \"allow\"`; omitted `action` defaults to\n `\"ask\"`. `workspace` limits a rule to that repository, while\n `command_exact = true` changes a command rule from the historical\n arity-aware prefix match to a complete-command match. `deny` blocks matching\n invocations before mode-based\n approval handling, `allow` skips approval for matching invocations, and\n `ask` forces approval only in modes that can prompt. Outside the TUI\n auto-approve path, a matching `ask` rule under `approval_policy = \"never\"`\n is rejected because no prompt can be shown. In Full Access / auto-approval sessions,\n `ask` rules do not downgrade the session into prompting or blocking; explicit\n `deny` rules still block according to the current execution-policy logic.\n\n In a supported approval card, press `S` to allow the request once and append\n exact `action = \"ask\"` rules to this file. For eligible safe requests, choose\n **Always allow this exact rule in this repo** (shortcut `P`) to append an\n `action = \"allow\"` rule with the current absolute `workspace` scope.\n Remembered shell grants set `command_exact = true`, so later commands with\n extra arguments do not inherit the grant. File and patch grants retain the\n exact workspace-relative paths produced by the existing validation path.\n Supported saves are intentionally narrow:\n `exec_shell` stores the exact approved command string; `write_file` and\n `edit_file` store the exact workspace-relative file path; `apply_patch`\n stores one exact workspace-relative `path` rule per validated touched file\n from apply-patch preflight. Existing exec command matching remains\n arity-aware for manually authored prefix rules; approval-card allow grants\n use complete-command matching. File paths are normalized to the same\n workspace-relative form used by runtime matching.\n\n `read_file` rules can still be authored manually when you want future reads\n of a specific path to ask, allow, or deny, but the approval UI does not save\n `read_file` rules. Commands classified as requiring approval or dangerous,\n critical approval cards, and repo-law prompts cannot save allow grants and\n continue to require review.\n\n `/permissions` (or `/permissions list`) is the narrow rule-management\n surface. It lists each numbered rule with the active user-file source, its\n exact effective matcher (tool-wide, command prefix, exact command, or exact\n normalized path), global or repository scope, and whether that scope\n applies in the current workspace. `/config ask-rules` remains a compatibility\n entry to the same list.\n\n Deletion is review-gated: `/permissions remove ` only previews the\n selected rule and prints a confirmation command. That command carries an\n opaque token bound to the exact file bytes and rule index; if another writer\n changes `permissions.toml`, confirmation fails instead of deleting a rule\n that moved into the old position. Confirmed removal and approval-card appends\n share the adjacent `permissions.toml.lock`, preserve unrelated TOML comments\n and formatting, and atomically replace the file. The running TUI reloads the\n user ruleset without clearing session-only approvals.\n\n This editor intentionally does not create or rewrite rules, persist deny\n choices from approval cards, expand globs, or create broad\n directory/recursive rules. Author those supported exact/prefix records\n manually when needed.\n- `[[hotbar]]` (array of tables, optional): user-owned 1-8 slot bindings for\n the TUI hotbar. Each entry has `slot`, `action`, and optional `label`.\n The Hotbar is hidden by default (#3807): omitting `hotbar` and setting\n `hotbar = []` both mean no hotbar. `/hotbar on` writes the eight default\n slots (`slash.workflow`, `slash.goal`, `slash.auto`, `mode.plan`,\n `mode.agent`, `mode.operate`, `palette.open`, `sidebar.toggle`) as explicit\n `[[hotbar]]` tables. When one or more `[[hotbar]]` tables are present, that\n list is the whole bar; missing slots stay empty. Invalid slots outside `1..=8` are skipped with a warning, duplicate\n slots use the later entry, and unknown action IDs are kept so the UI can show\n a disabled/unknown cell instead of silently deleting user config. Trusted\n user config, profiles, and managed config replace the whole list; project\n overlays cannot change hotbar bindings. Setup or wizard flows that persist\n hotbar bindings write this same schema to the resolved `~/.codewhale/config.toml`\n path, preserving legacy `~/.deepseek/config.toml` only when that fallback file\n is already the active config.\n\n ```toml\n [[hotbar]]\n slot = 1\n action = \"mode.plan\"\n label = \"Plan\"\n\n [[hotbar]]\n slot = 2\n action = \"session.compact\"\n ```\n- `[auto_review]` (table, optional): tool-call review policy — a deterministic floor plus a model guardian tier.\n This layer sits on top of the existing permission posture; it can hold or block a\n tool call, but it is not an auto-push, auto-merge, or hosted review service.\n Block rules are checked first, then the built-in safety floor, then allow\n rules. In Ask, a safety hold opens approval that no session grant or allow\n rule can answer; in Auto-Review or a non-interactive `never` posture it\n fails closed as a hard block. Full Access skips the built-in floor. The\n safety floor covers publish-like actions, destructive background/headless\n actions, and `curl`/`wget` uploads whose request body carries a credential\n file or a secret-named variable, or reads stdin fed from one of those or\n from `env`/`printenv`, even if an allow rule matches; a shell command,\n shell input or verifier over 64 KiB or 4,096 words whose text contains\n `curl` or `wget` anywhere, or one nested too deeply to read, is held too.\n Outside Full\n Access it also refuses, in every posture, a\n shell command, shell input or verifier containing a hidden Unicode character\n (bidirectional controls, zero-width and other invisible characters, tag\n characters, stray variation selectors, and terminal control characters\n except ASCII control keys in shell input), naming the code point and its\n position.\n\n ```toml\n [[auto_review.allow]]\n id = \"read-only-inspection\"\n action_kind = \"read\"\n reason = \"Read-only inspection is safe to run automatically.\"\n\n [[auto_review.block]]\n id = \"no-release-publish\"\n action_kind = \"publish\"\n reason = \"Release and publish actions require maintainer review.\"\n ```\n\n Rule matchers are exact `tool` and/or `action_kind`. At least one matcher is\n required. `action_kind` accepts the six decision-relevant kinds `read`,\n `write`, `shell`, `external`, `publish`, and `destructive`. Invalid names\n fail config validation instead of silently broadening into another policy\n class. In block rules, the old names remain conservative compatibility\n aliases: `network`, `git`, `mcp_action`, `browser`, and `unknown` map to\n `external`; `secret` maps to `destructive`; and `mcp_read` maps to `read`.\n Retired narrow kinds in allow rules fail validation rather than widening to\n a broader class. The retired `text_contains` matcher likewise fails\n validation instead of silently broadening an old intent-dependent rule.\n Fallback holds in interactive Auto-Review escalate to one stateless guardian\n request. The request contains the exact held call and deterministic\n observations as separate JSON fields. Conversation history, skill\n instructions, attached file contents, and other expanded model context are\n excluded. The guardian does not infer user intent or compute an authorization\n score. It exposes no tools and returns a risk level, allow/deny, and a\n rationale. High or critical risk cannot auto-run even if the model says\n allow. An oversized exact call is denied rather than truncated. Exactly one\n reviewer request is made; incomplete or malformed output, timeout,\n cancellation, provider failure, or an empty rationale all fail closed. The\n deterministic floor is never model-reviewed, and headless adapters use the\n deterministic-only tier. The pinned Codex, Kimi, and DeepSeek source\n boundaries are linked from [Permission Posture](MODES.md#permission-posture).\n Reviewer outcomes emit `tool.auto_review` audit events with\n `gate = \"guardian\"`.\n\n Auto-review decisions emit `tool.auto_review` audit events with\n `gate = \"deterministic\"` when tool\n audit logging is enabled. Future PreToolUse/PostToolUse hooks can add\n observer input around this layer, but the configured auto-review policy is\n evaluated before a tool call is allowed to proceed.\n- `managed_config_path` (string, optional): managed config file loaded after user/env config.\n- `requirements_path` (string, optional): requirements file used to enforce allowed approval/sandbox values.\n- `max_subagents` (int, optional): defaults to `64` and is clamped to `1..=128`.\n- `subagents.*` (optional compatibility table): manual per-role model pins\n for direct and Workflow `agent` starts. An explicit saved profile wins,\n then a manual role pin, then a unique saved role pin. Conflicting tool\n `model` or `model_strength` choices are refused before admission. Unpinned\n roles allow task model/strength choices before inherited defaults.\n `[subagents.roles.] model = \"provider/model\"` folds into the existing override\n map and wins over `[subagents.models]`, then the convenience keys. Structured\n canonical role keys win over legacy aliases. Only this structured syntax\n separates the explicit provider from the model suffix; unknown providers\n fail before admission. Bare structured model ids inherit the active provider.\n Legacy scalar/map values preserve namespaced provider-owned ids unchanged.\n Supported convenience keys are\n `default_model`, `worker_model`, `scout_model`, `planner_model`,\n `reviewer_model`, `custom_model`, `max_concurrent`, `max_admitted`,\n `launch_concurrency`, `token_budget`, `api_timeout_secs`, and\n `heartbeat_timeout_secs`. The v0.9.x keys `explorer_model`, `awaiter_model`,\n and `review_model` remain accepted as aliases. The `[subagents]\n max_concurrent` value overrides\n top-level `max_subagents` and is also clamped to `1..=128`. `[subagents]\n max_admitted` (aliases: `max_total`, `admission_limit`) is the bounded total\n of queued plus running sub-agents; it defaults to `1024`\n (`MAX_SUBAGENT_ADMISSION`, `crates/tui/src/config/subagent_limits.rs:21`,\n applied at `config.rs:6400`) so high-fanout turns can queue and drain while\n runtime launch pressure remains bounded, and is clamped to\n `max_concurrent..=1024`. `[subagents]\n launch_concurrency` sets how many direct children start at once before the\n rest queue for a launch slot; it defaults to the resolved `max_subagents` cap\n and is clamped to `1..=max_subagents` (the deprecated\n `interactive_max_launch` key is accepted as an alias, with the new key\n winning when both are set). `[subagents] token_budget` is an optional\n aggregate token ceiling for each root `agent` run and its descendants; unset\n or `0` preserves unlimited legacy behavior. `[subagents] api_timeout_secs`\n controls the per-step API timeout for sub-agent model calls and is clamped to\n `1..=3600`, with `0` or unset preserving the 600 second default; a timed-out\n attempt is retried with exponential backoff (up to 5 retries) before the\n step interrupts with a preserved checkpoint.\n `[subagents] heartbeat_timeout_secs` controls stale running agent cleanup,\n defaults to `300`, and is clamped to `30..=3600` while staying above the\n resolved API timeout. `[subagents.providers.]` accepts the same\n fanout, depth, budget, and timeout knobs (`enabled`, `max_concurrent`,\n `max_admitted`, `launch_concurrency`, `max_depth`, `token_budget`,\n `api_timeout_secs`, `heartbeat_timeout_secs`) and inherits the global\n `[subagents]` value for any key you omit. Provider keys accept canonical\n names such as `deepseek`, `zai`, `openrouter`, `anthropic`, plus convenience\n aliases such as `glm` for Z.ai and `deepseek_api` for direct DeepSeek:\n\n ```toml\n [subagents]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n max_depth = 6\n\n [subagents.providers.deepseek]\n max_concurrent = 20\n launch_concurrency = 20\n max_admitted = 200\n\n [subagents.providers.glm]\n max_concurrent = 4\n launch_concurrency = 3\n max_admitted = 12\n max_depth = 2\n\n [subagents.providers.openrouter]\n max_concurrent = 5\n launch_concurrency = 3\n max_admitted = 20\n ```\n\n `/config subagents status` prints both global values and the active\n provider's resolved profile so rate-limit tuning is visible in the TUI.\n `[subagents.models]` accepts lower-case Fleet role keys such as `worker`,\n `scout`, `planner`, `reviewer`, `builder`, and `verifier`; legacy type keys\n remain accepted during v0.9.x. Values are validated\n against the active provider at spawn time; direct DeepSeek requires DeepSeek\n IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs\n through to that provider. To route a child to a different provider than the\n parent session, save a Fleet/AgentProfile with explicit `provider` and\n `model` fields (including user-named custom providers such as `lm-studio`)\n and call `agent(profile: \"...\")`; see [SUBAGENTS.md](SUBAGENTS.md).\n- `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is\n a directory containing `SKILL.md`). Workspace-local `.agents/skills` or\n `./skills` are preferred when present; the runtime also discovers global\n agentskills.io-compatible `~/.agents/skills` and the broader Claude-ecosystem\n `~/.claude/skills`. First launch installs versioned bundled skills for common\n workflows including skill creation, delegation, MCP/plugin scaffolding,\n documents, presentations, spreadsheets, PDFs, and Feishu/Lark. Only\n Codewhale-owned roots (`/.codewhale/skills` and\n `~/.codewhale/skills`) are writable install/import targets; compatible harness\n roots stay read-only. Bare `/skills` opens the Skills Manager (owned-only,\n zero network). See [SKILLS.md](SKILLS.md) for the manager, audit statuses,\n provenance markers, and mutation rules, and\n [CLAUDE_PLUGIN_COMPAT.md](CLAUDE_PLUGIN_COMPAT.md) for the supported boundary\n between portable `SKILL.md` bundles and Claude Code plugin runtimes.\n- `[skills].scan_codewhale_only` (bool, default `false`): when `true`, session\n skill discovery ignores cross-tool roots such as `.claude/skills`,\n `.opencode/skills`, `.cursor/skills`, and `~/.agents/skills`. Codewhale still\n scans `/.codewhale/skills`, `~/.codewhale/skills`, and any explicit\n `skills_dir` override. The Skills Manager can still toggle a local compatible\n audit scan independently of this runtime knob — see [SKILLS.md](SKILLS.md).\n- `[skills].flat_workspace_root` (bool, default `false`): opt in to the flat\n `/skills` compatibility root after workspace trust. Without this\n opt-in it is an audit candidate only; an explicit `skills_dir` remains an\n alternative. `scan_codewhale_only = true` excludes the flat compatibility\n root regardless of this flag, unless it is the explicit `skills_dir`.\n- `[skills].registry_url` / `[skills].max_install_size_bytes` (optional): used by\n `/skills --remote`, `/skills suggest `, `/skills sync`, and `/skill\n install|update`. The default manager open path does not contact the registry.\n- `[verifier].enabled` (bool, default `false`): enables automatic\n claim-of-done verifier preview once that runtime trigger is active. The\n manual `run_verifiers` tool is still available when this is false.\n- `mcp_config_path` (string, optional): defaults to `~/.codewhale/mcp.json`, with\n legacy `~/.deepseek/mcp.json` fallback when the Codewhale path is absent.\n Custom paths must be absolute; a relative value falls back to the user-global\n path so changing the launch directory cannot silently change the MCP pool.\n It is visible in `/config` and can be changed from the TUI. The new path is\n used immediately by `/mcp`, but rebuilding the model-visible MCP tool pool\n requires restarting the TUI.\n- `notes_path` (string, optional): defaults to `~/.codewhale/notes.txt`, with\n legacy `~/.deepseek/notes.txt` fallback when the Codewhale path is absent, and\n is used by the model-visible `note` tool.\n- `[memory].enabled` (bool, optional): defaults to `false`. When `true`,\n the TUI loads the user memory file into a `` prompt block,\n enables `# foo` quick-capture in the composer, surfaces the `/memory`\n slash command, and registers the `remember` tool. The same toggle is\n available via `DEEPSEEK_MEMORY=on`.\n- `memory_path` (string, optional): anchors the native memory store. The\n configured filename is **not** the file that is written. Under the Native\n backend (the only backend) the store is re-rooted to\n `/memory/global/MEMORY.md` — so the default\n `~/.codewhale/memory.md` yields `~/.codewhale/memory/global/MEMORY.md`\n (plus workspace-scoped files and a rebuildable SQLite FTS5 index). See\n [`MEMORY.md`](MEMORY.md) for the full feature surface (`# foo` composer\n prefix, `/memory` slash command, `remember` tool, opt-in toggle).\n- `snapshots.*` (optional): side-git workspace snapshots for file rollback:\n - `[snapshots].enabled` (bool, default `true`)\n - `[snapshots].max_age_days` (int, default `7`)\n - snapshots live under\n `~/.codewhale/snapshots///.git`, with legacy\n `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and\n never use the workspace's own `.git` directory\n- `context.*` (optional):\n - `[context].project_pack` (bool, default `false`): include a deterministic\n project context pack (a large pretty-printed directory listing) in the\n stable prompt prefix (#4781). Useful for weak tool-calling models; the\n model can rebuild the same information with one `File` call.\n - The removed seam-manager keys (`enabled`, `verbatim_window_turns`,\n `l1_threshold`, `l2_threshold`, `l3_threshold`, `seam_model`) are\n ignored if an older config still carries them; they no longer load.\n- `compaction.*` (optional, config.toml): how a compaction pass behaves once\n it fires. `auto_compact` / `auto_compact_threshold_percent` (settings.toml)\n still decide *when* it fires. Both keys are absent by default, and absent\n means the built-in behaviour, unchanged:\n - `[compaction].summary_instructions` (string, default empty): standing\n operator instructions appended to the summarizer prompt as a clearly\n delimited \"Additional instructions from the operator\" section on **every**\n pass, manual and automatic — the effort-free counterpart to a one-off\n `/compact `, which still composes after this text. Useful for\n \"always list exact file paths and line numbers\", \"always restate open\n decisions and their trade-offs\", \"always write a TL;DR first\". Truncated\n at 4 000 characters with a warning naming the key; whitespace-only reads\n as unset. The summarizer still runs with no system prompt and no tools —\n this suffix is the only operator-authored input it sees.\n - `[compaction].retained_user_message_tokens` (int, default `20000`, clamped\n to `2000`–`200000`; also accepted as `retained_user_message_max_tokens`):\n token budget for the recent plain user messages kept **verbatim** in the\n replacement history. Raising it keeps more of the user's own earlier\n messages instead of only whatever the lossy summary captured; the\n last-round survival contract still applies on top. The `/compact` receipt\n names the effective budget and whether operator instructions were applied,\n so you can tell the knob took effect.\n- `retry.*` (optional): retry/backoff settings for API requests:\n - `[retry].enabled` (bool, default `true`)\n - `[retry].max_retries` (int, default `3`)\n - `[retry].initial_delay` (float seconds, default `1.0`)\n - `[retry].max_delay` (float seconds, default `60.0`)\n - `[retry].exponential_base` (float, default `2.0`)\n - `[retry].jitter` (bool, default `true`): randomize each backoff delay\n - `[retry].jitter_factor` (float, default `0.1` = ±10%; clamps to `0.0..=1.0`)\n - `[retry].respect_retry_after` (bool, default `true`): wait for a server\n `Retry-After` header instead of the computed backoff\n\n `[retry]` schedules HTTP-request retries inside the client. The stream-level\n budgets that sit above it — how often a turn re-issues a request whose\n stream failed to open or died — are the `[stream]` keys below; legacy `tui.stream_max_*` keys remain fallbacks.\n- `[notifications]`: notification delivery, attention, categories and audio share one\n policy. `quiet = true`, `method = \"off\"`, `condition = \"never\"` and disabled\n categories suppress both the banner and Codewhale's selected sound.\n- `notifications.method`: `auto` (default), `osc9`, `kitty`, `ghostty`, `bel`, `off`.\n- `notifications.condition`: `unfocused` (default), `always`, `never`. When absent,\n the legacy `tui.notification_condition` remains the fallback. `always` also\n bypasses the duration threshold; `unfocused` requires two seconds away.\n- `notifications.threshold_secs`: nonnegative integer, default `30`.\n- `notifications.include_summary`: boolean, default `false`.\n- `notifications.sound`: optional `off`, `whale`, `bell`, `beep`, `file`.\n A selected value controls audio across enabled categories. Absent keeps legacy\n `completion_sound` and `event_sound` choices; `off` overrides both.\n- `notifications.sound_file`: custom local WAV path for `sound = \"file\"` or legacy\n `completion_sound = \"file\"`.\n- `notifications.subagent_completion`: `always`, `final-only` (default), `off`.\n Covers all background work that finishes: sub-agents, background shells and\n durable tasks. `final-only` sends one notice naming everything that finished\n once no agent, workflow or durable task is still running; a running shell\n (a dev server, a watcher) never holds it back. `always` sends one per item.\n- `notifications.quiet`: boolean, default `false`.\n- `notifications.events`: six boolean categories, all enabled by default; see below.\n- `notifications.completion_sound`: legacy completion cue, default `off`, with the\n same values as `sound`. Used only when `sound` is absent.\n- `notifications.event_sound`: legacy `enabled` (default `false`), `events`\n (default `[\"turn-complete\", \"approval-needed\"]`), and `quiet` (default `false`).\n `min_interval_ms` (default `2000`) applies to each category's audio in both modes.\n- `tui.alternate_screen` (string, optional, default `auto`): which screen an interactive session starts on. `auto` and `always` start on the TUI-owned alternate screen; `never` starts in inline mode — a ratatui viewport the full height of the terminal with no alternate screen, so the shell's scrollback survives the session and stays scrollable after exit. `/fullscreen` and `/inline` switch it in-process; a switch that the terminal refuses rolls back and says why. Inline mode paints the whole transcript inside its viewport — nothing is written into the host scrollback while the session runs.\n- `tui.mouse_capture` (bool, optional, default `true` on non-Windows terminals and on Windows Terminal/ConEmu/Cmder when the alternate screen is active; `false` on legacy Windows console and inside JetBrains JediTerm — PyCharm/IDEA/CLion/etc. — where mouse-event escapes leak into the input stream as garbled text, see #878 / #898): enable internal mouse scrolling, transcript selection, right-click context actions, and transcript scrollbar dragging. TUI-owned drag selection copies the intersected cells, removes visual wrap-column line breaks from paragraphs, and keeps selection scoped to the transcript pane; the payload is Markdown source by default, see `tui.selection_copy_markdown` below. Set this to `false` or run with `--no-mouse-capture` for raw terminal selection; set it to `true` or run with `--mouse-capture` to opt in anywhere it's defaulted off. On raw terminal selection, especially on legacy Windows console or when mouse capture is disabled, selection may cross the right workbar and include visual wraps because the terminal, not the TUI, owns the selection.\n On Linux, finishing a transcript or composer selection quietly copies text to\n PRIMARY, leaving the regular clipboard unchanged. Middle-click inside the\n composer pastes PRIMARY at the pointer without submitting it. This uses native\n X11 or Wayland data control; compositors must support PRIMARY selection. Over\n SSH without a forwarded graphical display, use your terminal's selection/paste\n gestures or `--no-mouse-capture`. Explicit Copy still uses the regular clipboard.\n\n- `tui.selection_copy_markdown` (bool, optional, default `true`): copy TUI-owned\n transcript selections (drag release, context-menu Copy, and `Cmd+C`/`Ctrl+C`\n on an active selection) as Markdown source instead of rendered text. Every\n intersected cell serializes through the same canonical projection `Ctrl-Y`\n and `/copy` use — user and assistant cells keep their authored Markdown,\n other cells keep their full transcript form — partial intersections round out\n to whole cells, cells join with blank lines, and a toast names the copied\n cell count. Set `false` to copy the rendered text as displayed. Composer\n selections and the Linux PRIMARY auto-copy are unchanged; PRIMARY always\n carries rendered text.\n\n- `tui.stream_chunk_timeout_secs` (int, optional, default `900`): per-SSE-chunk idle timeout for streamed model responses. Slow local or compatible servers can raise this with `/config stream_chunk_timeout_secs ` (add `--save` to write canonical `stream.chunk_timeout_secs`); `0` maps to the default and explicit values must be `1..=3600`. The legacy `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` env var is still honored when this key is omitted.\n- `tui.osc8_links` (bool, optional, default on for macOS/Linux, off for Windows): emit OSC 8 escape sequences around URLs in transcript output so supporting terminals (iTerm2, Terminal.app 13+, Ghostty, Kitty, WezTerm, Alacritty, recent gnome-terminal/konsole) can open them with the terminal's link gesture—usually Cmd-click on macOS and Ctrl-click on Linux/Windows. Terminals without OSC 8 support render the plain label and ignore the escape. The escapes are emitted out-of-band (not inside buffer cells), so column corruption is not a concern; set `false` only for terminals that misrender the OSC 8 terminator itself. Windows legacy consoles default off; opt in with `true`.\n- `tui.max_model_steps` (int, optional, default uncapped): optional model-step ceiling for one ordinary turn. Omission or `0` leaves model steps uncapped; explicit positive values are clamped to `1..=100000`. Headless `exec` and Fleet workers also have no implicit model-step ceiling; `exec --max-turns N` and positive worker budgets still apply. At ~80% of an explicit step budget the model gets one soft-landing notice; at exhaustion the turn ends `Failed` with `Maximum model steps reached before completion (limit: N)` after one bounded final-report response when needed. Cumulative wall-clock and per-stream limits remain independent. Active interactive goal turns use `goal.max_steps` instead (also uncapped by default); see the Goal loop section below.\n- `tui.turn_wall_clock_secs` (int, optional, default: no limit): cumulative per-turn wall-clock budget in seconds, measured across every model step of one turn (not per request). Time blocked on a human approval is excluded. Omitted or `0` means no limit; positive values clamp to `30..=86400` (24 hours is the ceiling). When exhausted the turn stops before authorizing another billable request with a message naming the limit and the key to raise.\n- `tui.stream_max_resumes` (int, optional, default `3`): how many times one turn re-issues a model request after its stream failed — the request never opened (connect failure or response-header stall), the stream died before any content, the host slept mid-stream, or the network dropped mid-stream. Every one of those paths spends this one budget, and a healthy stream resets it. `0` disables turn-level re-issues (a failed stream then fails the turn); values clamp to `0..=10`.\n- `tui.stream_max_transparent_retries` (int, optional, default `2`): in-stream re-requests while nothing has streamed yet. `0` disables them; values clamp to `0..=10`.\n- `tui.stream_max_errors` (int, optional, default `5`): recoverable errors tolerated within one stream before it ends. Unlike the two retry counts above, `0` does not switch anything off: like the other finite stream budgets it selects the default. Other values clamp to `1..=50`, so `1` ends the stream on its first recoverable error.\n- `tui.stream_open_timeout_secs` (int, optional, default `45`): wait for a streaming request's response headers (connection setup included). A header stall on HTTP/2 retries once over HTTP/1.1 with the same wait. Omitted or `0` falls back to `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS`, then the default; values clamp to `5..=300`.\n- `tui.connect_timeout_secs` (int, optional, default `30`): TCP/TLS connect timeout for the model HTTP client. Omitted or `0` uses the default; values clamp to `1..=300`.\n- `tui.force_http1` (bool, optional, default `false`): pin the model HTTP client to HTTP/1.1, for provider edges or proxies that mishandle long-lived HTTP/2 streams. `CODEWHALE_FORCE_HTTP1=1` does the same; either one pins.\n- `tui.stream_max_content_mb` (int, optional, default `10`) and `tui.stream_max_duration_secs` (int, optional, default `1800`): per-step caps on one stream's accumulated content and wall-clock duration. `0` selects the default; values clamp to `1..=512` MB and `10..=86400` seconds.\n- `transcript.prose_measure` (positive integer, optional, default absent = full width): wrap cap, in columns, for prose cells — user messages, assistant answers, and reasoning/thinking blocks — in the live transcript (#5436). Absent (or `0`) spends the full content width, consistent with tool/status cells and the #5322 wide-frame decision; the former 105-column prose rail is gone. Set a positive whole number (e.g. `prose_measure = 120` under `[transcript]`) to restore a bounded reading measure on ultrawide terminals. Narrow terminals always keep their content width — the cap clamps from above only. Tool, diff, and status cells never inherit this cap. Invalid values (negative or non-integer) are rejected at startup with a `transcript.prose_measure` config error. Resolved once per render pass, so the main transcript cache and the full-screen overlay always agree on the effective width.\n- `hooks` (optional): lifecycle hooks configuration (see `config.example.toml`).\n- `features.*` (optional): feature flag overrides (see below).\n\n### Stream and transport settings\n\n`[stream]` is the canonical table for model-stream policy and its HTTP clients.\n`codewhale config dump` and `codewhale config get stream` show effective values\nfrom the runtime resolver, including environment fallbacks and clamps; this does\nnot persist defaults. Set or unset individual fields with, for example,\n`codewhale config set stream.open_timeout_secs 120` and\n`codewhale config unset stream.open_timeout_secs`. Per-run\n`--set stream.open_timeout_secs=120` uses the same validation.\n\n| `[stream]` key | Default | Accepted behavior | Legacy `[tui]` fallback |\n| --- | --- | --- | --- |\n| `open_timeout_secs` | 45 | Positive values clamp to 5–300; 0 or omission falls through to the environment/default | `stream_open_timeout_secs` |\n| `chunk_timeout_secs` | 900 | 0 selects default; positive values clamp to 1–3600 | `stream_chunk_timeout_secs` |\n| `max_resumes` | 3 | 0 disables whole-request reissues; maximum 10 | `stream_max_resumes` |\n| `max_transparent_retries` | 2 | 0 disables retries before any content; maximum 10 | `stream_max_transparent_retries` |\n| `max_stream_errors` | 5 | 0 selects default; positive values clamp to 1–50 | `stream_max_errors` |\n| `max_duration_secs` | 1800 | Per-stream wall clock; 0 selects default, positive values clamp to 10–86400 | `stream_max_duration_secs` |\n| `max_content_mb` | 10 | Per-stream content; 0 selects default, positive values clamp to 1–512 MiB | `stream_max_content_mb` |\n| `connect_timeout_secs` | 30 | TCP/TLS setup; 0 selects default, positive values clamp to 1–300 | `connect_timeout_secs` |\n| `force_http1` | false | Boolean; a truthy environment pin always enables HTTP/1.1 | `force_http1` |\n| `tcp_keepalive_secs` | 30 | Idle time before TCP keepalive probes; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_interval_secs` | 15 | PING interval on active HTTP/2 connections; 0 disables, positive values clamp to 1–3600 | none |\n| `http2_keep_alive_timeout_secs` | 20 | PING acknowledgement deadline; 0 selects default, positive values clamp to 1–3600 | none |\n\nFor each field, an explicit canonical value wins over the legacy `[tui]` field,\nincluding zero or false; an absent canonical field preserves the legacy value.\nThe existing environment precedence is unchanged: a positive configured header\nwait wins over `CODEWHALE_STREAM_OPEN_TIMEOUT_SECS` (then its `DEEPSEEK_` alias);\nzero falls through to those variables. An omitted chunk timeout uses\n`CODEWHALE_STREAM_IDLE_TIMEOUT_SECS` (then its `DEEPSEEK_` alias), whereas an\nexplicit zero uses 900. `CODEWHALE_FORCE_HTTP1` (legacy `DEEPSEEK_FORCE_HTTP1`)\nis ORed with the selected config flag, so a false config flag cannot defeat a\ntruthy environment pin. Profile overlays merge canonical fields independently.\n\nHTTP transport values apply to all newly constructed model, catalog and HTTP/1\nfallback clients. They do not rebuild an active client, govern MCP/other network\nservices, or send HTTP/2 PINGs on idle pooled connections. HTTP/2 knobs have no\neffect when HTTP/1.1 is pinned. The operating system controls TCP probe details;\nthese settings do not disable certificate validation. `[retry]` still owns the\nHTTP-request backoff schedule independently of stream-level budgets.\n\n### Workspace notes\n\n`/note` manages a simple notes file in the current workspace at\n`.codewhale/notes.md` (legacy `.deepseek/notes.md` is the fallback path when\nno `.codewhale/notes.md` exists yet).\nExisting `/note ` usage still appends a note.\nThe management forms are:\n\n| Command | Action |\n|---|---|\n| `/note ` | Append a note (legacy shorthand) |\n| `/note add ` | Append a note explicitly |\n| `/note list` | List notes with temporary 1-based numbers |\n| `/note show ` | Show the full note at number `n` |\n| `/note edit ` | Replace note `n` with new text |\n| `/note remove ` | Delete note `n`; `rm` and `delete` are aliases |\n| `/note clear` | Empty the workspace notes file |\n| `/note path` | Show the resolved workspace notes path |\n\nThe numbers shown by `/note list` are not stored in the file; they are derived\nfrom the current order each time notes are read. This keeps the file format\ncompatible with the existing `---`-separated notes.\n\n### User memory\n\nUser memory is split across one top-level path setting and one opt-in\ntoggle table:\n\n```toml\n# Anchors the store only — actual writes go to\n# ~/.codewhale/memory/global/MEMORY.md (see MEMORY.md).\nmemory_path = \"~/.codewhale/memory.md\"\n\n[memory]\nenabled = true\n```\n\nNotes:\n\n- `memory_path` stays at the top level beside `notes_path` and\n `skills_dir`; it is not nested under `[memory]`.\n- The configured path is an **anchor**: its parent directory gains\n `memory/global/MEMORY.md`, workspace-scoped files, and `index.db`.\n Pointing `memory_path` at the native layout path itself would double-nest\n (`…/memory/global/memory/global/MEMORY.md`); keep the legacy-style\n anchor filename.\n- `DEEPSEEK_MEMORY_PATH` overrides the anchor path from the environment.\n- `DEEPSEEK_MEMORY=on` (also `1`, `true`, `yes`, `y`, or `enabled`)\n flips the feature on without editing `config.toml`.\n- The feature is inert when disabled: no file is injected, `# foo`\n falls through to normal message submission, and the model does not\n see the `remember` tool.\n- See [`MEMORY.md`](MEMORY.md) for examples and the full `/memory`\n command surface.\n\n### Goal loop (`[goal]`)\n\nOperate-mode goals run to their completion gate with no default token, time, or\ncontinuation ceiling (#5052). Token/time budgets, when explicitly supplied,\nare telemetry only and do not stop a goal. Users who want a circuit breaker can\nopt into one:\n\n```toml\n[goal]\n# Optional safety backstop on automatic goal continuation passes.\n# Default: 0 (unlimited). Set a positive value to opt into a ceiling.\nmax_continuations = 100\n\n# Optional cancellable quiet period between successful turns. This is useful\n# for coordinator goals that should poll on a cadence instead of keeping one\n# provider turn open. Default: 0 (continue immediately).\ncontinuation_delay_seconds = 300\n\n# Optional per-turn model-step ceiling while a goal is active (#6512).\n# Default: uncapped (0 or absent). Positive values clamp to 1..=100,000.\n# This bounds each provider turn, never the number of continuation passes.\nmax_steps = 0\n```\n\nThe effective delay is capped at 86,400 seconds (24 hours); use an automation\nfor schedules that are less frequent than once per day.\n\nWhen an explicit backstop fires, the goal pauses with a status message naming\n`[goal] max_continuations` and a warning is logged; resume the goal after\ninspecting progress, or raise/disable the backstop.\n\n`[goal] max_steps` governs one engine turn at a time. Like ordinary interactive\nturns, omitted or `0` leaves model steps uncapped. Explicit per-invocation\nceilings — `exec --max-turns N`, child-worker caps — always win over it. At\nabout 80% of an explicit positive budget the model is told to land; at exhaustion\nit gets one bounded final report and the turn classifies as budget-exhausted. An\nunfinished goal then pauses with the BudgetLimit reason instead of re-arming\nanother goal turn — resume it explicitly after reviewing the report. Wall-clock\nand stream protections are separate and still apply.\n\nThe delay starts only after a successful turn while an explicitly created goal\nis still active. `/goal pause`, `/goal done`, `/goal blocked`, `/goal clear`,\nEsc, or Ctrl+C cancels a pending continuation before another provider request\nstarts. Failed turns and policy/route failures never schedule another turn.\nOnly the numeric cadence is stored in config; no prompt, credential, or secret\nis persisted for the loop.\n\n### Reasoning-only recovery (`[reasoning_only]`)\n\nWhen a reasoning model (thinking mode) finishes a response with only hidden\nreasoning and no answer text or tool call, the engine can automatically\nre-request the answer. Configure this behavior with the `[reasoning_only]`\ntable:\n\n```toml\n[reasoning_only]\n# Maximum number of automatic re-requests. Default: 2.\n# Set to 0 to disable automatic recovery (fail immediately).\nmax_reprompts = 10\n\n# Optional custom message sent to the model on each re-request.\n# When set, overrides the built-in default message.\n# When unset (or commented out), the engine uses:\n# \"So, what's up ? Keep running !\"\nreprompt_message = \"Allez, répond quelque chose !\"\n```\n\nThis only applies when the model returns a clean `stop` finish reason with\nonly thinking content. An output-length stop (`length`/`max_tokens`) is never\nretried, and a persistently answerless model still fails honestly after the\nconfigured bound.\n\nTo disable the reprompt message entirely (silent retry), set it to an empty\nstring:\n\n```toml\n[reasoning_only]\nreprompt_message = \"\"\n```\n\n### Notifications\n\nNotification controls are available in the existing `/config` settings view,\nfrom terminal commands, and through the CLI. All write the same `config.toml`\nkeys. Terminal changes apply immediately; add `--save` to keep them. CLI writes\napply when the next process loads its configuration.\n\n```sh\ncodewhale config set notifications.sound whale\ncodewhale config set notifications.events.approval-needed false\ncodewhale config set notifications.quiet true\ncodewhale config get notifications\ncodewhale config unset notifications.quiet\n```\n\n```text\n/config notifications sound whale --save\n/config notifications condition unfocused --save\n/config notifications quiet true\n/config notifications status\n```\n\nNested CLI edits preserve TOML types, unrelated keys and comments. Unset removes\nonly the selected leaf. `notifications.sound legacy` in the TUI, or CLI unset of\n`notifications.sound`, restores previous sound choices. Invalid values are\nrejected before file or session changes. In an active TUI profile, saved edits\nupdate its notification table when it owns one; otherwise they update the\ninherited root table. The settings detail keeps saved and current values distinct.\n\n```toml\n[notifications]\nmethod = \"auto\" # auto | osc9 | kitty | ghostty | bel | off\ncondition = \"unfocused\" # unfocused | always | never\nthreshold_secs = 30\ninclude_summary = false\nsound = \"whale\" # optional; sound is opt-in, not enabled by default\nquiet = false\n\n[notifications.events]\nturn-complete = true\nsubagent-terminal = true\napproval-needed = true\ninput-needed = true\nelevation-needed = true\nmodel-notify = true\n```\n\n`quiet = true` mutes every category without changing saved choices. `method =\n\"off\"` also stops both banner and selected audio. Disabling a category stops its\nsound. Attention and duration gates apply before any sink runs. Title animation\ncompletion is silent; only the authorized notification event can request audio.\nSuccessful turn completion is a notification category; failed/cancelled turns do\nnot create a success notification.\n\n`auto` chooses a recognized terminal protocol or the existing macOS native\nfallback; unknown terminals remain unsupported and never invent a bell. `bel`\nis an audio-only transport: one selected cue is dispatched, without a second\ntransport bell. With `sound = \"off\"`, that transport is silent. `osc9`, `kitty`\nand `ghostty` use their terminal notification protocols; tmux passthrough is\npreserved. Terminal/OS notification preferences still govern display, attribution\nand any sound the host itself adds.\n\nBy default the terminal must stay unfocused for two seconds. `condition =\n\"always\"` allows foreground notifications and bypasses the duration threshold;\n`\"never\"` suppresses all delivery. The canonical condition takes precedence over\nlegacy `[tui].notification_condition`.\n\nThe bundled `whale` is a 1.55-second original whale-inspired cue, with no\nthird-party recording. It remains an opt-in candidate pending listening approval.\nWAV playback uses a background worker: macOS `/usr/bin/afplay`, Linux `aplay`\nfrom [ALSA utilities](https://github.com/alsa-project/alsa-utils), or Windows\n`PlaySoundW`. A missing player/file or unsupported platform has no fallback bell.\nOnly one WAV plays at a time. A worker-start receipt is a dispatch attempt, not\nproof of audible playback or OS acceptance. The existing macOS `osascript`\nbanner retains Script Editor attribution; this Core change does not provide a\nbranded native Apps banner.\n\n#### Previous sound settings\n\nWhen `notifications.sound` is absent, completion uses a non-off\n`completion_sound` selection, and other events use the existing event allow-list.\nThese are compatibility inputs to the same audio decision, not separate playback\npaths. When the global sound is selected, it takes precedence over the previous\ncompletion/event choices. The default remains silent unless a legacy sound or\nexplicit `bel` transport was already selected.\n\n```toml\n[notifications.event_sound]\nenabled = false\nevents = [\"turn-complete\", \"approval-needed\"]\nmin_interval_ms = 2000\nquiet = false\n```\n\nLegacy event cues use one bell for completion, subagent completion, input and\nmodel notices; approval/elevation cues use two. The per-category repeat interval\nsurvives settings refreshes. Old unknown event names are ignored on load; new\nCLI/TUI edits require names from the six categories above.\n\nLocal approval/input/elevation prompts and error receipts remain available when\nexternal notifications are muted. Action prompts use the selected UI language\nand retire when that request settles. Repeated live notices keep their first\nexpiry; a later routine update does not hide an unresolved warning at completion.\nOptional plugin suggestion toasts and contextual tips share one session guidance\nbudget. `/config contextual_tips off` hides those toasts while preserving\nrequired notices and explicit plugin review requests.\n\n#### What a notification can contain\n\nA desktop notification is a glance surface: on macOS it can appear on the\nlock screen, and on every platform it is visible to anyone near the machine.\nCodewhale therefore builds notifications from a typed payload with a fixed\nper-event disclosure policy rather than from whatever text was on hand:\n\n| Event | Shown | Never shown |\n|---|---|---|\n| Turn complete | status line (+ elapsed/cost when `include_summary`), preview of the assistant's reply | — |\n| Sub-agent finished | status line, agent id, preview of the child's summary line | — |\n| Approval needed | the tool name | the tool description, the command, the arguments |\n| Input needed | \"Answer the question in the terminal to continue\" | the question |\n| Sandbox elevation needed | the tool name and the denial reason | the command |\n| `notify` tool | model-supplied title and body | — |\n\nEvery field is capped (80 characters for the status line, 120 for the\nidentifier, 200 for the preview), stripped of control bytes and escape\nsequences, and passed through a redactor that replaces credential-shaped\nstrings with `[redacted]`, reduces absolute local paths to `…/basename`,\nand replaces raw tool JSON with `[details hidden]`. The redactor is\ndeliberately over-eager: an unbroken 40-character run has no word\nstructure, so it is redacted even when it is not a secret.\n\n#### macOS: why the banner says \"Script Editor\"\n\nOn macOS terminals that provide no notification escape of their own —\nApple Terminal, the VS Code and JetBrains embedded terminals, plain tmux\nwithout `LC_TERMINAL` — `method = \"auto\"` falls back to `osascript`'s\n`display notification`. That command posts on behalf of the *bundled*\nhost process, and `/usr/bin/osascript` is unbundled, so macOS attributes\nthe banner to `com.apple.ScriptEditor2`. That attribution supplies the\nScript Editor icon and owns the System Settings → Notifications entry\n(alert style, previews, Do Not Disturb). `display notification` has no\nicon parameter, so this cannot be fixed from the notification code; it\nneeds Codewhale to ship a real `.app` bundle. Tracked in\n[#4834](https://github.com/codewhale-hq/CodeWhale/issues/4834). In the meantime,\niTerm2, WezTerm, Ghostty, and kitty are matched first and use their own\nnotification protocols, and `method = \"osc9\"` / `\"bel\"` / `\"off\"` opt out\nof the `osascript` path explicitly.\n\n## Automations in the terminal\n\nOpen `/automation`, then choose **New automation** (`n`) or **Edit automation**\n(`e`). The form edits the name, multiline prompt, schedule, model, workspace,\nand enabled status. `Tab` moves between fields; `Enter` inserts a newline in\nthe prompt. Schedule presets include daily, weekly, hourly, once, and a custom\nRRULE. Time fields accept `HH:MM`; their arrow controls change the time by\n15 minutes. Weekly day buttons and model search support mouse and keyboard.\n\nThe next-run preview uses the scheduler's local time zone, shown with its UTC\noffset. An enabled automation can run after **Save** (`Ctrl+S`); a paused one\nhas no scheduled next run. **Cancel** (`Esc`) discards the draft. Editing keeps\nexisting permission settings, custom schedules, and additional workspace\nentries unless the corresponding supported field is explicitly changed.\n\nSchedules are evaluated in the machine's local time zone against the wall\nclock. A wall time that does not exist on a spring-forward day is skipped and\nan ambiguous fall-back time fires once. Occurrences missed while Codewhale was\nclosed, asleep, or still running the previous occurrence are coalesced: the\nnext start runs one catch-up occurrence and then continues from the next\nfuture slot, never replaying every missed slot. An occurrence never starts\nwhile an earlier run of the same automation is still queued or running. Each\nrun is recorded durably with its status, timing and error; a run that needs a\ntool approval has no operator to ask and fails once the approval wait expires.\n\nChoosing a concrete model pins both the model and its exact configured\nprovider, including named custom routes. Later changes to the active provider\ndo not move that automation's pin. The default-model choice and legacy\ndefinitions without a provider pin keep the runtime's existing default\nbehavior. New automation records use schema v2 and tasks use v3 so older\nruntimes reject records whose provider pins they cannot preserve.\n\n## Lifecycle Outbox (`[lifecycle_outbox]`)\n\nThe lifecycle outbox is an opt-in, machine-readable stream of session,\nturn, and sub-agent lifecycle events. With a path configured, Codewhale\nappends one JSON line per event to that file — for interactive TUI\nsessions *and* headless `codewhale exec` runs — so a supervisor\n(terminal multiplexer wrapper, automation harness, alerting setup) can\nreact to what happened without scraping the screen or installing per-hook\nshell commands. Unset or empty `path` = the feature is **off** and\nbehavior is unchanged.\n\n```toml\n[lifecycle_outbox]\npath = \"~/.codewhale/notifications/outbox.jsonl\" # unset/empty = OFF\nwebhook_url = \"\" # optional; POSTs events as JSON when set\nwebhook_token = \"\" # optional bearer token for webhook_url\n```\n\n### Events emitted\n\n| Event | Kind | Fired at |\n|---|---|---|\n| `turn_start` | `turn.started` | a new turn begins (TUI TurnStarted; `exec` at message dispatch) |\n| `turn_end` | `turn.completed` / `turn.failed` / `turn.interrupted` | turn completion, kind projected from the turn status |\n| `turn_stalled` | `turn.stalled` | the stall watchdog recovers a wedged turn |\n| `subagent_spawn` | `subagent.spawned` | a sub-agent is spawned |\n| `subagent_complete` | `subagent.completed` | a sub-agent reaches a terminal state |\n| `session_start` | `session.started` | interactive session start |\n| `session_end` | `session.ended` | interactive session end |\n\n### File contract\n\nEach line is a `RuntimeEventEnvelope`:\n\n```json\n{\"schema_version\": 1, \"seq\": 3, \"event\": \"turn_start\", \"kind\": \"turn.started\",\n \"thread_id\": \"…\", \"turn_id\": \"…\", \"item_id\": null, \"timestamp\": \"…\",\n \"created_at\": \"…\", \"payload\": {…}}\n```\n\n- `seq` is monotonic per outbox file and recovers from the last written\n line when a new process opens the file.\n- Lines are written one complete JSON line per append, serialized by an\n internal writer task and flushed before the next event; concurrent\n sessions writing the same path do not interleave bytes mid-line, but\n separate processes each continue from their own recovered `seq`, so seqs\n can repeat across processes sharing one file — prefer one file per\n process for strict uniqueness.\n- Parent directories are created lazily on the first event.\n- Payloads are constructed from bounded, pre-redacted fields only — never\n raw tool arguments, environment, or full transcript text. Free-form\n fields (error messages, previews) are capped at the notification limits\n (80 headline / 120 detail / 200 preview characters) and stripped of\n control bytes.\n\n### Webhook delivery\n\nWith `webhook_url` set, every event is additionally POSTed as\n`{\"at\": \"\", \"event\": {…}}` with\n`Authorization: Bearer ` when a token is configured.\nDelivery is best-effort: failures are logged and dropped, never retried\ninto the agent loop, and a failing webhook never blocks the local file\nappend.\n\n## Control Socket (`[control_socket]`)\n\nPer-session control surface for supervised operation: with the feature\nenabled, the interactive TUI binds one unix domain socket per *running*\nsession at `//control.sock` (mode `0600`;\n`` is the same directory the session store uses, typically\n`~/.codewhale/sessions`). The socket is removed with the session's\nartifact directory, and a stale socket left by a crashed process is taken\nover by the next launch. Unset or `enabled = false` = the feature is\n**off** (the default) and behavior is unchanged. Unix-only; on other\nplatforms the key parses but no socket is bound.\n\n```toml\n[control_socket]\nenabled = false # default: OFF\n```\n\nThe socket speaks newline-framed JSON-RPC, one request per connection:\nwrite one request line, read one response line, close.\n\n```json\n{\"id\":\"1\",\"method\":\"message\",\"params\":{\"text\":\"hello\"}}\n{\"id\":\"2\",\"method\":\"interrupt\",\"params\":{}}\n{\"id\":\"3\",\"method\":\"status\",\"params\":{}}\n```\n\n- `message` — delivers `text` as a structured user message through the\n ordinary composer dispatch path; dispatched immediately when idle,\n queued when a turn is in flight (the response's `delivery` field says\n which).\n- `interrupt` — the Esc-shaped cancel of the active turn; `cancelled`\n reports whether active work was in flight.\n- `status` — answers `turn_state` (`idle` / `in_progress` / `waiting`) and\n `goal` (`objective`, `status`, `paused`).\n\nSuccess responses echo the request id with a `type`-tagged result;\nfailures carry `error.code` (`invalid_request`, `command_error`,\n`timeout`, `server_unavailable`). Requests are bounded at 1 MiB per line\nand a handler that does not answer within 5 s is reported as `timeout`.\n\n## Tool Catalog\n\nCodewhale loads a small core native tool catalog by default and leaves less\ncommon native tools discoverable through ToolSearch. To keep specific native\ntools loaded on every request, add them to `[tools].always_load`:\n\n```toml\n[tools]\nalways_load = [\"Git\", \"notify\"]\n```\n\n### Script tools and overrides\n\nScripts in `~/.codewhale/tools/` (or `[tools].plugin_dir`) that start with a\n`# name:` header become model-visible tools, and `/plugin tools` lists them.\nThe script reads the tool's JSON input on stdin and writes a JSON\n`ToolResult` (`{\"content\": \"...\", \"success\": true}`) on stdout.\n\n```sh\n#!/usr/bin/env sh\n# name: word_count\n# description: Count words in the given text\n# schema: {\"type\":\"object\",\"properties\":{\"text\":{\"type\":\"string\"}}}\n# approval: required\n```\n\n`# approval:` takes `suggest` (the default) or `required`; either way the\ntool follows the session's approval setting. A script cannot approve itself:\n`approval: auto` is no longer supported, so such a script gets the default,\nand the runtime log (`~/.codewhale/logs/`) and `/plugin tools` name it.\n\nA script cannot replace a built-in tool either. A script whose `# name:` is\nalready registered is not loaded. `[tools.overrides]` may disable a built-in,\nor add a script or command tool under a name of its own:\n\n```toml\n[tools.overrides]\n\"Web\" = { type = \"disabled\" } # turn a built-in off\n\"audited_shell\" = { type = \"script\", path = \"audit-shell.sh\" } # a new tool\n\"Bash\" = { type = \"script\", path = \"audit-shell.sh\" } # refused: Bash is built in\n```\n\nA `script` or `command` override keyed by a built-in is refused, and the\nbuilt-in stays active. A status line names the key once per session, and the\nruntime log records it. To route a\nbuilt-in through your own wrapper, disable the built-in and register the\nwrapper under a new name. An override keyed by a drop-in script's name still\nreplaces that script. Relative `path` values resolve against the plugin\ndirectory.\n\n### `request_user_input` limits\n\n`request_user_input` asks the user a short batch of multiple-choice questions.\nBoth ceilings are configurable (#5949): raise `user_input_max_questions` when a\nresearch or planning workflow legitimately needs more clarifications, lower it\nwhen interactive triage should stay terse.\n\n```toml\n[tools]\nuser_input_max_questions = 6 # default 6, clamped to 1..=10\nuser_input_max_options = 4 # default 4, clamped to 2..=10\n```\n\nThe effective values are applied in three places at once: the tool's JSON\nschema (`minItems` / `maxItems`), its model-visible description, and the\npayload validator. A rejected payload names the key to raise, so the model can\neither resize the batch or tell the user which setting to change.\n\n### User-input wait timeout\n\nQuestions from `request_user_input` wait until answered or canceled by default\n(#6003). An omitted setting or `0` leaves the wait unbounded; a positive value\ncancels the question when that many seconds pass, capped at 86,400 (24 hours).\nHeadless `exec` runs have no\nresponder, so `request_user_input` is withheld there by default:\nthe model reports the tool absent and finishes instead of stalling.\n\n```toml\n[tools]\nuser_input_timeout_seconds = 300 # opt into 5 minutes; omitted or 0 waits indefinitely; maximum 86400\n```\n\nThis key governs question waits only. Approvals have their own clock,\n`[approval] timeout_seconds`, which waits indefinitely unless you set it.\nWall-clock and stream protections elsewhere are unaffected.\n\n## Feature Flags\n\nFeature flags live under the `[features]` table and are merged across profiles.\nDefaults are enabled for built-in tooling, so you only need to set entries you\nwant to force on or off.\n\n```toml\n[features]\nshell_tool = true\nsubagents = true\nweb_search = true # enables deferred Web; the flag name is retained for config compatibility\napply_patch = true\nmcp = true\nexec_policy = true\ncode_mode = true # execute_tools composes MCP/plugin/native calls; false defers it behind tool_search\nverify_tool = true # agent-callable `verify` self-critique; false removes it from the tool catalog\nvision_model = false # true routes image analysis to the [vision_model] model (see above)\nextension_host = false # experimental: run reviewed plugins' native code (see EXTENSIONS.md)\n```\n\n`code_mode` is on by default: `execute_tools` is advertised from the first\nrequest and nested calls go through the same permission gate as direct calls\n(see [Tool surface](TOOL_SURFACE.md#code-mode-execute_tools)). Set\n`code_mode = false` to defer it behind `tool_search` again.\n\n`extension_host` is experimental and off by default. Turning it on lets reviewed\nplugins run their `native` TypeScript/JavaScript tool and slash-command code in\na separate Node (or, opt-in, Bun) process; toggling it in either direction\nchanges the plugin activation policy, so every plugin is reviewed again after a\nrestart. Its `[extension_host]` table (`runtime`, `node`, `bun`) and what the\nhost's sandbox does on each platform are in\n[Writing an extension tool](EXTENSIONS.md).\n\nEvery flag has a row in [`docs/features.toml`](features.toml), the feature\nregistry; a test fails when a flag and its row disagree.\n\nYou can also override features for a single run:\n\n- `codewhale --enable web_search`\n- `codewhale --disable subagents`\n\nUse `codewhale features list` to inspect known flags and their effective state.\nThe native `/config` view also includes a read-only **Experimental** section\nfor experimental feature flags. It shows each flag's effective enabled/disabled\nstate and whether that state comes from the default or a configured override.\nChange feature flags in `[features]` or with `--enable` / `--disable`; the\n`/config` section is an audit surface, not a stability promise. Goal and\nWorkflow preview rows may appear there as reserved entries until those workflows\ngraduate behind real gated flags.\n\n## Web Search Provider\n\n`web_search` uses keyless Firecrawl by default. Runtime failure or an exhausted\nkeyless quota degrades visibly through DuckDuckGo and Bing. China deployments\ncan explicitly select Baidu, Metaso, Volcengine, or a trusted SearXNG endpoint;\nCodewhale does not guess geography from locale or model provider.\n\nConfigured API providers are attempted first. Runtime failure or an empty\nresult visibly degrades through DuckDuckGo and then Bing; the structured search\nreceipt records every hop. Missing configuration and network-policy denials\nfail closed without sending the query to another provider.\n\n**Provider-native search.** On routes whose provider offers its own web-search\ntool (OpenAI, xAI, Anthropic, DeepSeek, Kimi and others), that search can run\nahead of the configured provider. It is a separate model call on the active\nroute. `[search] native` decides the order:\n\n- unset (default): native search leads only when no search provider is\n configured; a provider chosen in `[search] provider`,\n `CODEWHALE_SEARCH_PROVIDER`, or a Tavily key wins;\n- `native = true`: native search leads even when a provider is pinned;\n- `native = false`: native search is never used.\n\nThe native answer is returned whole; oversized tool output spills to a session\nartifact the model can page back.\n\n**Recency and locale.** `recency` and `locale` are forwarded where the\nbackend's API takes them, and the search receipt reports each as honored or\nignored:\n\n| Backend | Recency | Locale |\n| --- | --- | --- |\n| Firecrawl | `tbs=qdr:d/w/m/y` | `country` from the region (`de-DE` → `DE`); a bare language is ignored |\n| Tavily | `time_range` | not sent (Tavily takes country names) |\n| SearXNG | `time_range` | `language`, as given |\n| Serply | not sent (undocumented) | `hl` language, `gl` country |\n\nRecency is rounded up to the backend's nearest window (day, week, month,\nyear), so `recency = 10` searches the last month. Other backends ignore both\nknobs and say so in the receipt.\n\nFor a private/internal search service that serves DuckDuckGo-compatible HTML,\nkeep `provider = \"duckduckgo\"` and set `base_url`; Codewhale appends the `q`\nquery parameter to that endpoint and applies network policy to its host.\nCustom endpoints do not fall back to public Bing. `CODEWHALE_SEARCH_BASE_URL`\ncan override this per process; `DEEPSEEK_SEARCH_BASE_URL` remains accepted as\nthe legacy alias.\n\n**SearXNG** ([docs](https://docs.searxng.org/dev/search_api.html)) uses the\nconfigured instance's JSON API. Set `provider = \"searxng\"` and\n`base_url = \"https://your-searxng.example\"`; Codewhale calls\n`/search?q=...&format=json`. Codewhale does not use a public SearXNG instance\nby default because public instances often disable JSON output or rate-limit API\ntraffic.\n\nSelf-host it as a separate process (Docker is fine); Codewhale never bundles or\nmanages the search engine itself:\n\n- Enable JSON on the instance (`settings.yml`, `search.formats` must include\n `json`) and restart it. An HTML-only instance answers the API with HTTP 403;\n Codewhale reports that as a JSON/API-access problem on the SearXNG hop rather\n than silently returning no results.\n- Bind it to loopback, or to a host and port your network policy allows. The\n instance keeps its own engine list, limiter, and limits.\n- Point Codewhale at it with `[search] provider = \"searxng\"` and `base_url`\n (required; either the root URL or the `/search` endpoint). No instance ships\n as a default, and none is discovered automatically.\n- `codewhale doctor --probe-search` sends a transport-only `HEAD` to that\n origin — no `q=`, no credentials, no redirects, no audit receipt — so a green\n probe proves reachability and network-policy admission, not that JSON is on.\n\nConfirm the JSON API itself before assuming a Codewhale bug:\n\n```sh\ncurl -sS \"$BASE/search?q=codewhale&format=json\" | jq '.results[0] | {title,url,score}'\n```\n\nCodewhale ranks the returned rows by `score`, highest first, and applies\n`max_results` to that ranking; rows an instance reports without a usable score\nkeep their original relative order.\n\n**Metaso** ([metaso.cn](https://metaso.cn)) requires a user-supplied key. Set\n`METASO_API_KEY` or `[search] api_key`; Codewhale does not ship a shared key.\n\n**Firecrawl** ([docs](https://docs.firecrawl.dev/sdks/cli)) searches Firecrawl\nCloud without a key using its bounded per-IP daily quota. Set\n`FIRECRAWL_API_KEY` or `[search] api_key` for authenticated limits. Codewhale\nsends no `Authorization` header in keyless mode.\n\n**Baidu** uses Baidu AI Search at\n`https://qianfan.baidubce.com/v2/ai_search/web_search`. Set\n`BAIDU_SEARCH_API_KEY` or `[search] api_key`. This is a search-tool backend\nonly; it does not add a Baidu model provider.\n\n**Sofya** ([sofya.co](https://sofya.co)) returns full extracted page content\nrather than snippets. Set `[search] api_key` to your `ay_live_...` key, or the\n`SOFYA_API_KEY` env var. This is a search-tool backend only; it does not add a\nSofya model provider.\n\n**Serply** ([serply.io](https://serply.io)) returns Google organic results with\ntitle, URL, and snippet. Set `[search] api_key` to your Serply key, or the\n`SERPLY_API_KEY` env var. This is a search-tool backend only; it does not add a\nSerply model provider.\n\n**Tavily** ([tavily.com](https://tavily.com)) is selected automatically when a\nTavily key is present and no provider is pinned: `TAVILY_API_KEY` set, or\n`[search] api_key` / `CODEWHALE_SEARCH_API_KEY` in the `tvly-` family. Doctor\nreports that as `source: tavily key`. Autodetect is runtime-only — Codewhale\nnever writes `[search] provider` for it, and `TAVILY_API_KEY` is never merged\ninto `[search] api_key`. An explicit `[search] provider` or\n`CODEWHALE_SEARCH_PROVIDER` always wins, so `provider = \"firecrawl\"` keeps\nFirecrawl even with a Tavily key in the environment. Pinned `tavily` accepts\nany non-empty `[search] api_key` and is configured by that key or\n`TAVILY_API_KEY`; with both empty it fails closed.\n\n```toml\n[search]\nprovider = \"firecrawl\" # also duckduckgo | bing | tavily | bocha | metaso | searxng | baidu | volcengine | sofya | serply\n# base_url = \"https://search.example/\" # optional with provider = \"duckduckgo\"; required with \"searxng\"\n# api_key = \"YOUR_KEY\" # optional for firecrawl; required by the other API providers\n# native = false # provider-native search: unset = only when no provider is configured\n```\n\n## Local Media Attachments\n\nUse `@path/to/file` in the composer to add local text file or directory context\nto the next message. Use `/attach ` for local image/video media paths, or\n`Ctrl+V` to attach an image from a local clipboard or an explicitly forwarded\nX11/Wayland clipboard. SSH terminal paste without a forwarded graphical display\nis text-only; use the local terminal's paste command (`Cmd+V` on macOS or\n`Ctrl+Shift+V` on Linux/Windows), and use `/attach ` for remote image\nfiles. OpenSSH loopback X11 displays are detected automatically. For an\nexplicitly forwarded Wayland or non-loopback X11 display, set\n`CODEWHALE_SSH_CLIPBOARD=graphical`; set it to `terminal` to force terminal\ntransfer instead of an ambient remote display. DeepSeek's public Chat\nCompletions API currently accepts text message\ncontent, so media attachments are sent as explicit local path references instead\nof native image/video payloads.\nAttachment rows appear above the composer before submit; move to the start of\nthe composer, press `↑` to select an attachment row, then press `Backspace` or\n`Delete` to remove it without editing the sample text by hand.\n\n## Managed Configuration and Requirements\n\ncodewhale supports a policy layering model:\n\n1. user config + profile + env overrides\n2. managed config (if present)\n3. requirements validation (if present)\n\nBy default on Unix:\n- managed config: `/etc/deepseek/managed_config.toml`\n- requirements: `/etc/deepseek/requirements.toml`\n\nRequirements file shape:\n\n```toml\nallowed_approval_policies = [\"on-request\", \"untrusted\", \"never\"]\nallowed_sandbox_modes = [\"read-only\", \"workspace-write\"]\n```\n\nIf configured values violate requirements, startup fails with a descriptive error.\n\n## Notes On `codewhale doctor`\n\n`codewhale doctor` follows the same config resolution rules as the rest of the\nTUI. That means `--config`, `CODEWHALE_CONFIG_PATH`, and the legacy\n`DEEPSEEK_CONFIG_PATH` are respected, and MCP/skills\nchecks use the resolved `mcp_config_path` / `skills_dir` (including env overrides).\n\nTo bootstrap missing MCP/skills paths, run `codewhale setup --all`. You can\nalso run `codewhale setup --skills --local` to create a workspace-local\n`./skills` dir.\n\nBoth plain `codewhale doctor` and `codewhale doctor --json` are structural and\noffline by default. They do not check the release service, hosted provider\nAPIs, local provider endpoints, or MCP processes, and they do not load a\nworkspace credential `.env`. Use `--check-updates`,\n`--probe-api`, `--probe-local`, or `--probe-mcp` to opt into the corresponding\nlive boundary; `--probe-local` may start a desktop-managed service such as\nOllama. Only the explicit API/local probe paths may load workspace credential\n`.env` values. Live flags conflict with `--json`, so machine-readable doctor\noutput is always offline. Top-level keys include `version`, `paths`, `secret_backend`,\n`config_path`, `config_present`, `workspace`, `api_key.source`,\n`api_key.availability`, `base_url`,\n`default_text_model`, `mcp`, `skills`, `tools`, `plugins`, `sandbox`,\n`platform`, `api_connectivity`, and `capability`. CI consumers should rely on\n`api_key.source` (`config_declared`/`env_declared`/`external_auth_declared`/\n`secret_store_unprobed`/`secret_store_unavailable`/`oauth_unprobed`/\n`external_consent`/`none`/`local_runtime`/`unknown`) and\n`api_key.availability`\n(`present`/`not_required`/`not_probed`/`unavailable`/`unknown`) rather than parsing the\nhuman-readable `doctor` text. Source is declaration metadata, not proof that a\ncredential exists or works. Only a non-empty, non-sentinel literal config value\nis structurally `present`; no-auth and local routes are `not_required`. Environment,\nexternal-auth, OAuth, consent, and secret-store declarations remain `not_probed`\nand cannot make structural Setup or Fleet readiness true. A secret-store sentinel\non a named/custom endpoint that is prohibited from using the shared store is\n`secret_store_unavailable`/`unavailable`, while `unknown` remains reserved for\nthe absence of a supported structural conclusion. Exact and whitespace-wrapped\nlegacy sentinels are never treated as literal credentials. The structural\nloader still honors safe environment routing/model/policy fields, but it never\nmaterializes environment HTTP headers, sandbox API keys, or search API keys;\nonly an explicit API/local probe switches to the normal credential-loading\npath. An opted-in update check also emits only typed generic failures: untrusted\nrelease metadata and transport errors are not echoed.\n\nIf configuration loading or validation fails, `doctor --json` returns nonzero\nand prints a bounded JSON error envelope with\n`status = \"error\"` and `error.kind = \"config_validation\"`. It does not emit a\nnormal route or capability report—or the underlying possibly sensitive error—\nfor an invalid configuration.\n\nMCP entries are configuration diagnostics unless an explicit MCP command is\nrun. `mcp.probe_scope` is `configuration`, `mcp.live_health_checked` is false,\nand each server separates `checks.configuration` / `checks.command` from\n`checks.process_reachable`, `checks.protocol_initialized`, and\n`checks.backend_tool_health`. The latter three remain `not_checked` in doctor\noutput. Run `codewhale mcp validate` to explicitly start enabled servers and\nverify protocol initialization/discovery; backend health still requires an\nappropriate explicit tool call. Doctor reports only safe structural MCP fields:\nURL userinfo/path/query/fragment and raw command arguments, environment values,\nheader values, and token material are omitted. Provider URLs follow the same\nrule and expose only `scheme://host[:explicit-port]`.\n\nThe `capability` key contains per-provider capability info derived from\nstatic knowledge (release docs, API guides) rather than live API probes.\nTop-level sub-keys: `resolved_provider`, `resolved_model`, `context_window`,\n`max_output`, `thinking_supported`, `cache_telemetry_supported`,\nand `request_payload_mode`.\n\nUse `capability.context_window` and `capability.max_output` for model-limit\nchecks in CI scripts; do not treat `capability.max_output` as the per-turn\nrequest budget. Use `capability.thinking_supported` to decide whether to\nconfigure reasoning effort.\n\n## Setup status, clean, and extension dirs\n\n`codewhale setup` accepts a few flags beyond the existing `--mcp`,\n`--skills`, `--local`, `--all`, and `--force`:\n\n- `--status` — print a compact one-screen status (api key, base URL, model,\n MCP/skills/tools/plugins counts, sandbox, `.env` presence). Read-only and\n network-free; safe to run in CI. If `.env` is missing and `.env.example` is\n present in the workspace, the status output points at `cp .env.example .env`.\n- `--tools` — scaffold `~/.codewhale/tools/` with a `README.md` describing the\n self-describing frontmatter convention (`# name:` / `# description:` /\n `# usage:`) and an `example.sh` that follows it. The directory is\n intentionally not auto-loaded; wire individual scripts into the agent via\n MCP, hooks, or skills.\n- `--plugins` — scaffold `~/.codewhale/plugins/` with a `README.md` and an\n `example/plugin.toml` plus a namespaced example Skill. Bundles are discovered\n read-only, untrusted, and disabled; review them through `/plugin` before\n enabling. v0.9.1 activates only declared Skills and MCP servers. See\n [PLUGIN_BUNDLES.md](PLUGIN_BUNDLES.md).\n- `--all` now scaffolds MCP + skills + tools + plugins together.\n- `--clean` — list `~/.codewhale/sessions/checkpoints/latest.json` and\n `offline_queue.json` if they exist. Legacy\n `~/.deepseek/sessions/checkpoints/` files are not scanned automatically; set\n `CODEWHALE_HOME=~/.deepseek` for a one-off legacy cleanup. Pass `--force` to\n actually remove matched files. This never touches real session history or the\n task queue.\n\n`--status` and `--clean` are mutually exclusive with the scaffold flags.\n\n## Why the engine strips XML/`[TOOL_CALL]` text\n\ncodewhale sends and receives tool calls only over the API tool channel\n(structured `tool_use` / `tool_call` items). The streaming loop in\n`crates/tui/src/core/engine.rs` recognizes a fixed set of fake-wrapper start\nmarkers — `[TOOL_CALL]`, `` — and scrubs them from visible assistant text without ever\nturning them into structured tool calls. When a wrapper is stripped, the loop\nemits one compact `status` notice per turn so the user can see why their\nvisible text shrank. Treat any change that re-enables text-based tool\nexecution as a regression; the protocol-recovery tests in\n`crates/tui/tests/integration/protocol_recovery.rs` lock the contract.\n\n## Model-bound redaction (`[redaction] model_bound`)\n\nCodewhale masks credential-looking values in tool output **before it is sent\nto an upstream model** — the \"model boundary\". A file read by a tool can\ncontain a configured API key, a bare provider token, or a credential-shaped\nopaque string, and the model must not see those bytes. This backstop is\nseparate from the display/export scrubbers: it decides what the model itself\ncan quote back, and it is deliberately conservative (`CredentialShaped`\npolicy, see `crates/config/src/persistence.rs`), so ordinary code and config\nstay byte-exact while keys, JWTs, bearer tokens, PEM blocks, and long opaque\nruns are masked.\n\nTurning that masking **off** is a security decision, so it is not a plain\nboolean:\n\n```toml\n[redaction]\nmodel_bound = \"disabled\" # \"enabled\" (default) | \"disabled\"\n```\n\nSetting `\"disabled\"` only records a *request*. It takes effect only when all\nof these are true:\n\n1. You restart the interactive TUI.\n2. The startup gate appears and you press `1`/`Y` on its first stage\n (\"confirm and disable\"). This only advances to a second, final-confirmation\n stage - the gate repeats the red warning and asks \"are you really sure?\".\n3. On that second stage you press `1`/`Y` again. The gate is rendered with the\n same explicit-key discipline as workspace trust - `Enter` never confirms by\n reflex, and `2`/`U` on the second stage steps back.\n4. Only that second confirmation persists a receipt to\n `~/.codewhale/redaction-state.json` (next to `config.toml`) and rebuilds\n the engine with masking off for the rest of this launch and future ones.\n\nThe receipt is bound to the config it was made against and is valid only\nwhile that config still requests `\"disabled\"`. Setting `model_bound` back\nto `\"enabled\"` - or rewriting `config.toml` in any way after the\nconfirmation - invalidates it, so requesting `\"disabled\"` again later\nasks for a fresh confirmation on the next launch. The receipt checks both the\nconfig contents and modification time; missing, unreadable, or malformed config\nand older receipts without this binding keep masking enabled. Legacy-home\ninstalls store the receipt beside their resolved config file.\n\nUntil a confirmation exists, the effective mode is always `\"enabled\"`:\n\n- Choosing `2`/`U` (\"keep masking on\") leaves the config field untouched, so\n the next launch asks again. Edit the field back to `\"enabled\"` to stop being\n asked.\n- Non-interactive entry points (`codewhale exec`, hooks, automations, headless\n agents) never confirm anything and never apply an unconfirmed request.\n- Routing/classification summaries and durable goal-state text keep their own\n always-on redaction regardless of this switch; the opt-out exists so the\n model can quote file bytes for exact edits, not to relax stored state.\n\nThe config value itself is forgiving: `true`/`false`, `\"on\"`/`\"off\"`, and\n`\"enabled\"`/`\"disabled\"` (any casing) all parse, with `false`/`\"off\"` meaning\n`\"disabled\"`.\n\nA confirmed opt-out still sends your configured API keys to the provider you\nare already talking to. Only use it when the model must read and edit files\nthat contain real credentials.\n\n### Stored sessions\n\nThe masking runs once, when tool output enters the transcript, so saved\nsession files (`~/.codewhale/sessions/*.json` and their checkpoints) and\nRuntime API thread items (their text and tool metadata) do not store a\ncredential-shaped value from tool output. With the confirmed opt-out above,\ntool output is stored as the model saw it.\n\nNot yet masked: large outputs spilled to `~/.codewhale/tool_outputs`, shell\ncompletion evidence artifacts, and tool-call *inputs* (for example a\n`curl -H \"Authorization: Bearer …\"` command line). `doctor` and\n`scrub-secrets` do not check those either.\n\nSessions saved by builds before this change may still hold credentials in\ntheir tool output; nothing rewrites them automatically. `codewhale doctor`\nreports them under **Stored Sessions** (it checks the newest 50 files), and\nthis command finds and masks them across every saved session:\n\n```sh\ncodewhale sessions scrub-secrets # report only\ncodewhale sessions scrub-secrets --apply # rewrite the affected files\n```\n\n`--apply` rewrites each file under the same per-session lock a save takes,\nso it never loses a concurrent save. A session that is still open can write\nits in-memory copy back on its next save, so close open sessions first, and\nrotate any credential that was exposed — masking a stored copy cannot un-leak it.\n" } ] }, From 6e73d36cbc0cad31f6a6ad5dc961c789b9b3bbf7 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Sat, 17 Oct 2026 18:27:22 +0200 Subject: [PATCH 7/9] fix(commands): address core navigation extraction review Relocate the existing canonicalization budget without increasing total authority or blocking-call allowance. Correct both localization guides, restore native command tests for command source paths, and make the complete navigation proof tolerate unrelated registry insertions while rejecting omitted/duplicate/reordered entries. Enforce source and normal graph checks on heavy main and PR lint jobs; retain explicit dependency review. Verified: 1215 native command tests (including all 34 relocated host tests), 9 portable tests, 7 proof controls, 34 blocking-budget controls, workflow regression, docs/facts and formatting pass. Preserved maintainer merge fc5ac7d904. Workspace operation-level authority remains an explicitly documented follow-up; no capability renumbering or widening. Refs #5316. Signed-off-by: Paulo Aboim Pinto --- .github/workflows/ci.yml | 9 ++++++ crates/command-contract/src/handler.rs | 2 +- docs/LOCALIZATION.md | 14 ++++++--- docs/zh_hans/LOCALIZATION.md | 13 +++++--- scripts/check-blocking-calls-budget.json | 5 +-- .../check-command-core-navigation-proof.py | 10 +++--- scripts/dev-test.sh | 2 +- ...est_check_command_core_navigation_proof.py | 24 +++++++++++++- tests/portable-core-navigation/README.md | 31 +++++++++++++++++-- 9 files changed, 85 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7422be2e7d..bb76c34d34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -564,6 +564,15 @@ jobs: continue-on-error: true shell: bash run: sccache --show-stats + - name: Check core navigation source and normal dependency boundary + if: needs.changes.outputs.heavy == 'true' + shell: bash + run: | + python3 -B scripts/test_check_command_core_navigation_proof.py + graph=$(mktemp) + trap 'rm -f "$graph"' EXIT + cargo tree --locked -p codewhale-portable-core-navigation --edges normal --all-features --target all --prefix none > "$graph" + python3 -B scripts/check-command-core-navigation-proof.py --graph "$graph" - name: Check provider registry drift if: needs.changes.outputs.heavy == 'true' run: | diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index 7e377f4482..b21f5dabf8 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -95,7 +95,7 @@ impl CommandCapabilities { /// general configuration and system settings. pub const PET: Self = Self(1 << 26); /// Workspace trust and trusted-path allow-list host data (FEAT-028 D3), - /// independent of the general config facet and of `WORKSPACE` path reads. + /// independent of the general config facet and of `WORKSPACE` operations. pub const TRUST: Self = Self(1 << 27); /// Account/provider session state for `/login` and `/logout` (FEAT-028 D3). pub const LOGIN: Self = Self(1 << 28); diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 9f9a33dfd0..181e9fa674 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -196,11 +196,15 @@ carry an explicit `planned`/`partial`/`deferred` row in this matrix. from step 2), and the `/config` value list (`config_choice_values` in `crates/tui/src/tui/views/mod.rs`) and hint text (`configured_locale_values`) derive from `Locale::shipped()`, so those need - no edit. The exhaustive `match` arms the compiler will point at are the - setup wizard (`crates/tui/src/tui/setup/mod.rs`), `locale_display` in - `crates/tui/src/commands/groups/config/config.rs`, and - `public_site_locale_segment` (the `/links` site path) in - `crates/tui/src/commands/groups/core/core.rs`. Add a `LANGUAGE_OPTIONS` + no edit. Review the setup wizard (`crates/tui/src/tui/setup/mod.rs`) and + `locale_display` in `crates/tui/src/commands/groups/config/config.rs` for + explicit locale mappings. Also review `public_site_locale_segment` in + `crates/tui/src/commands/groups/core/links.rs`: the portable `/links` command + matches locale tags, not the host `Locale` enum. Unlisted tags fall back to + English website pages, so the compiler will not flag a newly added locale + here. Add an explicit tag mapping when that locale is available on the website, + and verify `/links` selects the intended documentation/community URLs. + Add a `LANGUAGE_OPTIONS` entry to the onboarding language picker (`crates/tui/src/tui/onboarding/language.rs`); its `picker_offers_every_shipped_locale` test fails until you do. Several no-English-leak tests diff --git a/docs/zh_hans/LOCALIZATION.md b/docs/zh_hans/LOCALIZATION.md index bd6aef8aa8..8680358e95 100644 --- a/docs/zh_hans/LOCALIZATION.md +++ b/docs/zh_hans/LOCALIZATION.md @@ -177,11 +177,14 @@ partial 区域设置在网站上还差的范围(下一波):首页之外的 `normalize_configured_locale` 校验(它复用第 2 步的 `parse_locale`);`/config` 的取值列表(`crates/tui/src/tui/views/mod.rs` 里的 `config_choice_values`)和提示文字 (`configured_locale_values`)都从 `Locale::shipped()` 派生,所以这两处不用改。 - 编译器会指出来的穷尽 `match` 分支有三处:设置向导 - (`crates/tui/src/tui/setup/mod.rs`)、 - `crates/tui/src/commands/groups/config/config.rs` 里的 `locale_display`, - 以及 `crates/tui/src/commands/groups/core/core.rs` 里的 `public_site_locale_segment` - (`/links` 的站点路径)。还要给引导流程的语言选择器 + 检查设置向导(`crates/tui/src/tui/setup/mod.rs`)以及 + `crates/tui/src/commands/groups/config/config.rs` 里的 `locale_display` + 是否需要更新显式区域设置映射。还要检查 + `crates/tui/src/commands/groups/core/links.rs` 里的 `public_site_locale_segment`: + 可移植的 `/links` 命令匹配的是区域设置 tag,而不是宿主的 `Locale` 枚举。 + 未列出的 tag 会回退到英文网站页面,因此编译器不会在这里提醒新增的区域设置。 + 当网站提供该语言版本时,添加显式 tag 映射,并验证 `/links` 选择了正确的文档和社区 URL。 + 还要给引导流程的语言选择器 (`crates/tui/src/tui/onboarding/language.rs`)加一条 `LANGUAGE_OPTIONS` 条目; 不加的话,它的 `picker_offers_every_shipped_locale` 测试会失败。有几个 “不泄漏英文”的测试(例如 `status_picker.rs` 和 `tool_card.rs` 里的)显式列出了 diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 0584ec3403..68059a4af8 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -96,7 +96,7 @@ "std_fs": 2 }, "crates/tui/src/commands/contract.rs": { - "path_canonicalize": 4, + "path_canonicalize": 5, "std_fs": 3 }, "crates/tui/src/commands/groups/config/import_claude.rs": { @@ -105,9 +105,6 @@ "crates/tui/src/commands/groups/core/acceptance.rs": { "std_fs": 1 }, - "crates/tui/src/commands/groups/core/core.rs": { - "path_canonicalize": 1 - }, "crates/tui/src/commands/groups/plugins/render.rs": { "path_canonicalize": 1 }, diff --git a/scripts/check-command-core-navigation-proof.py b/scripts/check-command-core-navigation-proof.py index 59bf42e469..c52e9d81ec 100644 --- a/scripts/check-command-core-navigation-proof.py +++ b/scripts/check-command-core-navigation-proof.py @@ -22,7 +22,6 @@ "home": ("home", "overview"), "workspace": ("workspace",), "translate": ("translate",), "voice": ("voice", "voicesend", "voicecontrol"), } -POSITIONS = (0, 2, 20, 24, 25, 26, 29, 30, 31, 34, 35, 37, 38) REQUIRED = {ROOTS[0]} | {f"{GROUP}/{name}.rs" for name in NAMES} ALLOWED_NORMAL = { "codewhale-portable-core-navigation", "codewhale-command-contract", "codewhale-protocol", @@ -125,10 +124,11 @@ def violations(root): path = root / GROUP / f"{module}.rs" if path.is_file() and tuple(re.findall(r'\bname:\s*"([^"]+)"', source_text(path))) != expected: errors.append(f"canonical metadata mismatch: {module}") - entries = list(re.finditer(r'core_navigation_host::registration::<(\d+)>\(\)|Box::new\(FunctionCommand::new\(', source_text(root / HOST))) - actual = [(position, int(entry.group(1))) for position, entry in enumerate(entries) if entry.group(1) is not None] - if len(entries) != 40 or actual != list(zip(POSITIONS, range(13))): - errors.append("real host core registry must interleave all13 inventory entries at their original positions") + actual = [int(index) for index in re.findall( + r'core_navigation_host::registration::<(\d+)>\(\)', source_text(root / HOST) + )] + if actual != list(range(len(PAIRS))): + errors.append("real host core registry must contain all 13 inventory entries exactly once in relative order") return errors diff --git a/scripts/dev-test.sh b/scripts/dev-test.sh index c2200e6c22..71e894a60a 100755 --- a/scripts/dev-test.sh +++ b/scripts/dev-test.sh @@ -115,7 +115,7 @@ if [ -e "$area" ] || printf '%s' "$area" | grep -q /; then tests/portable-config/*|tests/portable-config) area=portable-config ;; - tests/portable-core-navigation/*|tests/portable-core-navigation|crates/tui/src/commands/groups/core/navigation.rs|crates/tui/src/commands/groups/core/anchor.rs|crates/tui/src/commands/groups/core/help.rs|crates/tui/src/commands/groups/core/home.rs|crates/tui/src/commands/groups/core/hotbar.rs|crates/tui/src/commands/groups/core/links.rs|crates/tui/src/commands/groups/core/transcript.rs|crates/tui/src/commands/groups/core/translate.rs|crates/tui/src/commands/groups/core/turn.rs|crates/tui/src/commands/groups/core/voice.rs|crates/tui/src/commands/groups/core/workspace.rs) + tests/portable-core-navigation/*|tests/portable-core-navigation) area=portable-core-navigation ;; crates/command-contract/src/navigation.rs) diff --git a/scripts/test_check_command_core_navigation_proof.py b/scripts/test_check_command_core_navigation_proof.py index 49ce53019f..03c3307419 100644 --- a/scripts/test_check_command_core_navigation_proof.py +++ b/scripts/test_check_command_core_navigation_proof.py @@ -19,6 +19,17 @@ def fixture(self, root): def test_actual_complete_group_is_included(self): self.assertEqual(proof.violations(proof.ROOT), []) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + host = root / proof.HOST + source = host.read_text() + unrelated = 'Box::new(FunctionCommand::new(extra::ExtraCmd::info(), extra::ExtraCmd::execute)),\n' + host.write_text(source.replace( + 'crate::commands::core_navigation_host::registration::<0>(),', + unrelated + 'crate::commands::core_navigation_host::registration::<0>(),', + )) + self.assertEqual(proof.violations(root), []) def test_selected_leaf_stub_and_test_only_root_are_rejected(self): actual = (proof.ROOT / proof.PROOF).read_text() @@ -41,7 +52,7 @@ def test_selected_leaf_stub_and_test_only_root_are_rejected(self): self.assertTrue(any("conditional" in error for error in proof.violations(root))) def test_omitted_root_or_helper_is_rejected(self): - for case in ["anchor", "voice", "inventory", "host_order", "metadata", "include"]: + for case in ["anchor", "voice", "inventory", "host_order", "host_duplicate", "host_reorder", "metadata", "include"]: with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp: root = Path(tmp) self.fixture(root) @@ -53,6 +64,17 @@ def test_omitted_root_or_helper_is_rejected(self): elif case == "host_order": host = root / proof.HOST host.write_text(host.read_text().replace("registration::<0>()", "registration::<99>()")) + elif case in {"host_duplicate", "host_reorder"}: + host = root / proof.HOST + text = host.read_text() + if case == "host_duplicate": + text = text.replace('registration::<1>()', 'registration::<0>()') + else: + text = text.replace('registration::<0>()', 'registration::()') + text = text.replace('registration::<1>()', 'registration::<0>()') + text = text.replace('registration::()', 'registration::<1>()') + host.write_text(text) + self.assertTrue(any('relative order' in error for error in proof.violations(root))) elif case == "include": source.write_text(source.read_text() + '\ninclude!("untracked-helper.rs");\n') else: diff --git a/tests/portable-core-navigation/README.md b/tests/portable-core-navigation/README.md index 46100387e5..6275e62cb5 100644 --- a/tests/portable-core-navigation/README.md +++ b/tests/portable-core-navigation/README.md @@ -1,7 +1,7 @@ # Core navigation normal-library proof This unpublished workspace package mounts the **actual production navigation -root**, all ten leaf modules and all13 registrations. It contains no substitute +root**, all ten leaf modules and all 13 registrations. It contains no substitute App, copied implementation, test-only root or host adapter. Shared metadata, results/actions, facets and nested payloads are normal dependencies. @@ -13,7 +13,7 @@ sh scripts/check-portable-core-navigation.sh ``` The proof checks the declared complete source closure and real host inventory -positions, an all-feature/all-target **normal** dependency graph, a no-default +relative order (without pinning unrelated core entries), an all-feature/all-target **normal** dependency graph, a no-default normal-library build, and nonzero tests from the actual portable leaves. Rejection controls cover omitted/reordered registrations, metadata drift, selected-leaf/stub/test-only wrappers, conditional production modules and @@ -30,4 +30,29 @@ services. New normal packages require source review, not automatic approval. Host registration, concrete filesystem/view/config effects, real-App tests and voice recording/transcription remain outside the movable slice. The rest of core is not claimed portable by this package: the top-level `core` frontier remains -pending for its other27 registrations. Physical movement is a later feature. +pending for its other 27 registrations at adoption. Physical movement is a later feature. + +## Developer and CI coverage + +`sh scripts/dev-test.sh crates/tui/src/commands/groups/core/links.rs` (and the +other command source paths) runs the native `commands::` tests, including the +relocated host tests. The `portable-core-navigation` area or proof-package paths +select the fast portable tests; they do not replace host verification. +The heavy Lint job checks the source inventory and normal graph on main as well +as PRs; the Ubuntu PR test job additionally runs the full standalone proof above. +Dependency additions still require explicit review: tolerating unrelated registry +insertions must not silently admit runtime or storage services into the closure. + +## Capability limitations + +`NAVIGATION` and `VOICE_INPUT` occupy the last two bits of the existing `u32`. +No free bit remains. A future capability must first make an explicit, reviewed +representation/scoping decision; do not silently widen or renumber identities. + +The current `WORKSPACE` facet includes confined anchor read/append/replace, +path inspection and home-directory resolution, not just path reads. Other +first-party WORKSPACE consumers therefore have access to those operations even +though they do not call them. Exact facet selection is not per-operation least +authority. Splitting anchor authority is a separate contract decision; this +adoption preserves the agreed workspace facet and does not claim that narrowing +has already been implemented. From 2152e4df697c093adc6a663cccfe2fb820eb28fb Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Tue, 20 Oct 2026 21:02:39 +0200 Subject: [PATCH 8/9] fix(commands): resolve builtin voice shortcut by canonical name Address the non-blocking PR #7009 review nits: replace dispatch::<10> with builtin-registry lookup using VOICE_INFO.name, preserve capability-scoped dispatch and user-command shadowing isolation, and fix all 13 proof-message spacing. Verified 1215 native command tests, including existing shadowed-voice and pure action assertions plus safe missing-name rejection; 9 portable tests and 7 proof controls pass. Scoped TUI Clippy and formatting pass. No workspace authority redesign; maintainers own that follow-up and the post-v0.10.2 merge timing. Refs #5316. Signed-off-by: Paulo Aboim Pinto --- crates/tui/src/commands/core_navigation_host.rs | 13 ++++++++----- .../tui/src/commands/core_navigation_voice_host.rs | 3 ++- crates/tui/src/commands/mod.rs | 11 ++++++++++- scripts/check-command-core-navigation-proof.py | 4 ++-- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/commands/core_navigation_host.rs b/crates/tui/src/commands/core_navigation_host.rs index 10a5cda8bf..4e34c5da0d 100644 --- a/crates/tui/src/commands/core_navigation_host.rs +++ b/crates/tui/src/commands/core_navigation_host.rs @@ -53,11 +53,14 @@ fn host_result(result: CoreNavigationCommandResult) -> CommandResult { } } -pub(in crate::commands) fn dispatch( - app: &mut App, - arg: Option<&str>, -) -> CommandResult { - match Registration::::handler() { +pub(in crate::commands) fn dispatch(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + let Some(handler) = super::registry() + .get(name) + .and_then(Command::contextual_handler) + else { + return CommandResult::error(format!("Navigation command unavailable: {name}")); + }; + match handler { CommandHandler::Pure(run) => run(arg), CommandHandler::Contextual { capabilities, diff --git a/crates/tui/src/commands/core_navigation_voice_host.rs b/crates/tui/src/commands/core_navigation_voice_host.rs index f62c0d68b7..5f7f24be7e 100644 --- a/crates/tui/src/commands/core_navigation_voice_host.rs +++ b/crates/tui/src/commands/core_navigation_voice_host.rs @@ -1,6 +1,7 @@ //! Host-owned voice capture and builtin shortcut transport. use crate::commands::CommandResult; +use crate::commands::groups::core::navigation::voice::VOICE_INFO; use crate::config::Config; use crate::tui::app::App; use crate::voice::{ @@ -10,7 +11,7 @@ use codewhale_localization::{MessageId, tr}; use std::time::Duration; pub fn voice(app: &mut App) -> CommandResult { - super::core_navigation_host::dispatch::<10>(app, None) + super::core_navigation_host::dispatch(app, VOICE_INFO.name, None) } // --- Capture orchestration (UI event loop) --------------------------------- diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 4bd2955a9c..d77c9c76c5 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -667,6 +667,10 @@ mod tests { !app.voice_enabled, "builtin voice shortcut must not dispatch a shadowing user command" ); + let missing = + super::core_navigation_host::dispatch(&mut app, "__missing_navigation_builtin__", None); + assert!(missing.is_error && missing.action.is_none()); + assert!(!app.voice_enabled); } #[test] @@ -1531,9 +1535,14 @@ mod tests { panic!("/{name} must be pure and require no host envelope"); }; let result = run(None); - assert_eq!(result.action, Some(expected)); + assert_eq!(result.action, Some(expected.clone())); assert!(result.message.is_none()); assert!(!result.is_error); + let mut app = create_test_app(); + let shortcut = super::core_navigation_host::dispatch(&mut app, name, None); + assert_eq!(shortcut.action, Some(expected)); + assert!(shortcut.message.is_none()); + assert!(!shortcut.is_error); } for command in command_infos() { for lookup in [command.name.to_string(), format!("/{}", command.name)] { diff --git a/scripts/check-command-core-navigation-proof.py b/scripts/check-command-core-navigation-proof.py index c52e9d81ec..3a99047b0e 100644 --- a/scripts/check-command-core-navigation-proof.py +++ b/scripts/check-command-core-navigation-proof.py @@ -119,7 +119,7 @@ def violations(root): errors.append(f"conditional declared movable source: {name}") inventory = source_text(root / ROOTS[0]) if tuple(PAIR.findall(inventory)) != PAIRS or not re.search(r';\s*13\s*\]', inventory): - errors.append("production inventory must contain all13 real matching registrations in original filtered order") + errors.append("production inventory must contain all 13 real matching registrations in original filtered order") for module, expected in NAMES.items(): path = root / GROUP / f"{module}.rs" if path.is_file() and tuple(re.findall(r'\bname:\s*"([^"]+)"', source_text(path))) != expected: @@ -140,7 +140,7 @@ def main(): for error in errors: print(f"[core-navigation-proof] FAIL: {error}") if not errors: - print("[core-navigation-proof] PASS: all13 actual registrations, complete source closure, host parity and approved normal graph; core remains pending") + print("[core-navigation-proof] PASS: all 13 actual registrations, complete source closure, host parity and approved normal graph; core remains pending") return bool(errors) From ccbc8631280f8c2b6883ca0c22520bba8682da49 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Tue, 20 Oct 2026 22:46:32 +0200 Subject: [PATCH 9/9] fix(security): patch follow-redirects in Feishu and WeCom bridges Resolve the two red npm-audit checks on PR #7009 (GHSA-q3jh-wrc6-jgq8 and GHSA-8r9p-f939-6h3c). Update only the follow-redirects lock entries from 1.16.0 to 1.16.1; keep SDK versions, manifests and all other packages unchanged. No audit exclusions or threshold changes. Verified on Node 22.22.1: clean locked installs, syntax checks, Feishu 19/19 tests and WeCom 17/17 tests, zero vulnerabilities in both npm audit JSON reports, and installed dependency trees at 1.16.1. Original failing reports retained. Refs #5316. Signed-off-by: Paulo Aboim Pinto --- integrations/feishu-bridge/package-lock.json | 6 +++--- integrations/wecom-bridge/package-lock.json | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/integrations/feishu-bridge/package-lock.json b/integrations/feishu-bridge/package-lock.json index aad9b425c5..3b41eb4a0f 100644 --- a/integrations/feishu-bridge/package-lock.json +++ b/integrations/feishu-bridge/package-lock.json @@ -252,9 +252,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual", diff --git a/integrations/wecom-bridge/package-lock.json b/integrations/wecom-bridge/package-lock.json index a533c41a91..1740b0cefe 100644 --- a/integrations/wecom-bridge/package-lock.json +++ b/integrations/wecom-bridge/package-lock.json @@ -172,9 +172,9 @@ "license": "MIT" }, "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", "funding": [ { "type": "individual",