1use serde::{Deserialize, Serialize};
7use std::collections::{BTreeMap, HashMap};
8use std::path::PathBuf;
9
10use whycodes_core::network::{self, NetworkPolicy};
11use whycodes_core::sandbox::SandboxSettings;
12use whycodes_core::types::{
13 AgentInfo, ApprovalMode, ModelConfig, PermissionAction, ProviderConfig,
14};
15
16pub const DEFAULT_PROVIDER: &str = "openrouter";
18pub const DEFAULT_MODEL_ID: &str = "anthropic/claude-sonnet-4.5";
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct CommandConfig {
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub model: Option<ModelConfig>,
26
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub agent: Option<String>,
30
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub max_turns: Option<usize>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Config {
39 #[serde(default)]
41 pub providers: HashMap<String, ProviderConfig>,
42
43 #[serde(default)]
45 pub models: HashMap<String, ModelConfig>,
46 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
49 pub pricing: HashMap<String, PriceConfig>,
50
51 #[serde(default)]
53 pub agents: Vec<AgentInfo>,
54
55 #[serde(default = "default_agent")]
57 pub default_agent: String,
58
59 #[serde(default)]
61 pub default_model: Option<ModelConfig>,
62
63 #[serde(default)]
65 pub command_configs: HashMap<String, CommandConfig>,
66
67 #[serde(default)]
69 pub tools: ToolsConfig,
70
71 #[serde(default)]
73 pub session: SessionConfig,
74
75 #[serde(default)]
77 pub tui: TuiConfig,
78
79 #[serde(default)]
81 pub general: GeneralConfig,
82
83 #[serde(default)]
86 pub schema_version: u32,
87
88 #[serde(default)]
90 pub mcp_servers: HashMap<String, McpServerConfig>,
91
92 #[serde(default)]
94 pub lsp: LspConfig,
95
96 #[serde(default)]
99 pub permission: HashMap<String, PermissionAction>,
100
101 #[serde(default)]
103 pub commands: HashMap<String, CustomCommandConfig>,
104
105 #[serde(default)]
107 pub security: SecurityConfig,
108
109 #[serde(default)]
111 pub hooks: Vec<HookConfig>,
112
113 #[serde(default)]
115 pub memory: MemoryConfig,
116
117 #[serde(default)]
119 pub swarm: SwarmConfig,
120
121 #[serde(default)]
123 pub automation: AutomationConfig,
124
125 #[serde(default)]
127 pub notify: NotifyConfig,
128
129 #[serde(default)]
131 pub slop: SlopConfig,
132
133 #[serde(default)]
135 pub websearch: WebSearchConfig,
136
137 #[serde(default, skip)]
139 pub system_prompt_overlays: SystemPromptOverlays,
140}
141
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct SlopConfig {
145 #[serde(default = "default_slop_verbosity")]
147 pub verbosity: f64,
148 #[serde(default = "default_slop_erosion")]
150 pub erosion: f64,
151 #[serde(default = "default_slop_delta_loc")]
153 pub delta_loc: i64,
154 #[serde(default = "default_slop_block_verbosity")]
156 pub block_verbosity: f64,
157 #[serde(default = "default_slop_block_erosion")]
159 pub block_erosion: f64,
160 #[serde(default = "default_slop_block_delta_loc")]
162 pub block_delta_loc: i64,
163 #[serde(default = "default_slop_hotspots")]
165 pub hotspots: usize,
166}
167
168pub(crate) fn default_slop_verbosity() -> f64 {
169 0.25
170}
171pub(crate) fn default_slop_erosion() -> f64 {
172 0.50
173}
174pub(crate) fn default_slop_delta_loc() -> i64 {
175 800
176}
177pub(crate) fn default_slop_block_verbosity() -> f64 {
178 0.40
179}
180pub(crate) fn default_slop_block_erosion() -> f64 {
181 0.75
182}
183pub(crate) fn default_slop_block_delta_loc() -> i64 {
184 5_000
185}
186pub(crate) fn default_slop_hotspots() -> usize {
187 8
188}
189
190impl Default for SlopConfig {
191 fn default() -> Self {
192 Self {
193 verbosity: default_slop_verbosity(),
194 erosion: default_slop_erosion(),
195 delta_loc: default_slop_delta_loc(),
196 block_verbosity: default_slop_block_verbosity(),
197 block_erosion: default_slop_block_erosion(),
198 block_delta_loc: default_slop_block_delta_loc(),
199 hotspots: default_slop_hotspots(),
200 }
201 }
202}
203
204impl SlopConfig {
205 pub fn merge_with(&self, other: &Self) -> Self {
207 let d = Self::default();
208 Self {
209 verbosity: if (other.verbosity - d.verbosity).abs() > f64::EPSILON {
210 other.verbosity
211 } else {
212 self.verbosity
213 },
214 erosion: if (other.erosion - d.erosion).abs() > f64::EPSILON {
215 other.erosion
216 } else {
217 self.erosion
218 },
219 delta_loc: if other.delta_loc != d.delta_loc {
220 other.delta_loc
221 } else {
222 self.delta_loc
223 },
224 block_verbosity: if (other.block_verbosity - d.block_verbosity).abs() > f64::EPSILON {
225 other.block_verbosity
226 } else {
227 self.block_verbosity
228 },
229 block_erosion: if (other.block_erosion - d.block_erosion).abs() > f64::EPSILON {
230 other.block_erosion
231 } else {
232 self.block_erosion
233 },
234 block_delta_loc: if other.block_delta_loc != d.block_delta_loc {
235 other.block_delta_loc
236 } else {
237 self.block_delta_loc
238 },
239 hotspots: if other.hotspots != d.hotspots {
240 other.hotspots
241 } else {
242 self.hotspots
243 },
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct AutomationConfig {
251 #[serde(default = "default_max_background_jobs")]
253 pub max_background_jobs: usize,
254}
255
256pub(crate) fn default_max_background_jobs() -> usize {
257 8
258}
259
260impl Default for AutomationConfig {
261 fn default() -> Self {
262 Self {
263 max_background_jobs: default_max_background_jobs(),
264 }
265 }
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct SwarmConfig {
276 #[serde(default = "default_true")]
278 pub enabled: bool,
279 #[serde(default = "default_swarm_max_agents")]
281 pub max_agents: usize,
282 #[serde(default = "default_true")]
285 pub worktrees: bool,
286 #[serde(default)]
288 pub isolation: Option<String>,
289}
290
291impl SwarmConfig {
292 pub fn use_worktrees(&self) -> bool {
294 match self.isolation.as_deref() {
295 Some(s) if s.eq_ignore_ascii_case("checkout") => false,
296 Some(s) if s.eq_ignore_ascii_case("worktree") => true,
297 _ => self.worktrees,
298 }
299 }
300}
301
302pub(crate) fn default_swarm_max_agents() -> usize {
303 4
304}
305
306impl Default for SwarmConfig {
307 fn default() -> Self {
308 Self {
309 enabled: true,
310 max_agents: default_swarm_max_agents(),
311 worktrees: true,
312 isolation: None,
313 }
314 }
315}
316
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum HookEvent {
321 #[default]
323 PreTool,
324 PreToolTransform,
327 PostTool,
329 SessionStart,
331 UserPromptSubmit,
333 Stop,
335 PreCompact,
337 SubagentStop,
339}
340
341impl HookEvent {
342 pub fn as_str(self) -> &'static str {
344 match self {
345 Self::PreTool => "pre_tool",
346 Self::PreToolTransform => "pre_tool_transform",
347 Self::PostTool => "post_tool",
348 Self::SessionStart => "session_start",
349 Self::UserPromptSubmit => "user_prompt_submit",
350 Self::Stop => "stop",
351 Self::PreCompact => "pre_compact",
352 Self::SubagentStop => "subagent_stop",
353 }
354 }
355
356 pub fn can_block(self) -> bool {
358 matches!(self, Self::PreTool | Self::UserPromptSubmit)
359 }
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct HookConfig {
381 #[serde(default)]
384 pub event: HookEvent,
385
386 #[serde(default = "default_hook_match", rename = "match")]
388 pub tool_match: String,
389
390 pub command: String,
392
393 #[serde(default)]
396 pub block_on_failure: bool,
397
398 #[serde(default)]
402 pub feedback: bool,
403
404 #[serde(default = "default_hook_timeout")]
406 pub timeout_secs: u64,
407}
408
409pub(crate) fn default_hook_match() -> String {
410 "*".to_string()
411}
412
413pub(crate) fn default_hook_timeout() -> u64 {
414 30
415}
416
417impl Default for HookConfig {
418 fn default() -> Self {
419 Self {
420 event: HookEvent::PreTool,
421 tool_match: default_hook_match(),
422 command: String::new(),
423 block_on_failure: false,
424 feedback: false,
425 timeout_secs: default_hook_timeout(),
426 }
427 }
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SecurityConfig {
442 #[serde(default = "default_risk_threshold")]
446 pub bash_risk_threshold: String,
447
448 #[serde(default = "default_sandbox_mode")]
450 pub sandbox: String,
451
452 #[serde(default = "default_true_bool")]
455 pub sandbox_network: bool,
456
457 #[serde(default = "default_sandbox_fallback")]
460 pub sandbox_fallback: String,
461
462 #[serde(default, skip_serializing_if = "Vec::is_empty")]
465 pub network_allowlist: Vec<String>,
466
467 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 pub network_denylist: Vec<String>,
471
472 #[serde(default = "default_true_bool")]
477 pub redact_secrets: bool,
478}
479
480pub(crate) fn default_risk_threshold() -> String {
481 "destructive".to_string()
482}
483
484pub(crate) fn default_sandbox_mode() -> String {
485 "workspace".to_string()
486}
487
488pub(crate) fn default_sandbox_fallback() -> String {
489 "allow".to_string()
490}
491
492pub(crate) fn default_true_bool() -> bool {
493 true
494}
495
496impl Default for SecurityConfig {
497 fn default() -> Self {
498 Self {
499 bash_risk_threshold: default_risk_threshold(),
500 sandbox: default_sandbox_mode(),
501 sandbox_network: true,
502 sandbox_fallback: default_sandbox_fallback(),
503 network_allowlist: Vec::new(),
504 network_denylist: Vec::new(),
505 redact_secrets: true,
506 }
507 }
508}
509
510impl SecurityConfig {
511 pub fn network_policy(&self) -> NetworkPolicy {
513 NetworkPolicy {
514 allowlist: self.network_allowlist.clone(),
515 denylist: self.network_denylist.clone(),
516 }
517 }
518
519 pub fn sandbox_settings(&self) -> SandboxSettings {
521 SandboxSettings::from_raw(&self.sandbox, self.sandbox_network, &self.sandbox_fallback)
522 }
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct CustomCommandConfig {
528 pub template: String,
530 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub description: Option<String>,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub agent: Option<String>,
534 #[serde(default, skip_serializing_if = "Option::is_none")]
535 pub model: Option<String>,
536 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub subtask: Option<bool>,
539}
540
541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
548#[serde(rename_all = "lowercase")]
549pub enum McpTransportKind {
550 #[default]
551 Stdio,
552 Http,
554 Sse,
556 Auto,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
565pub struct LspConfig {
566 #[serde(
567 default,
568 alias = "idleTimeoutMs",
569 skip_serializing_if = "Option::is_none"
570 )]
571 pub idle_timeout_ms: Option<u64>,
572 #[serde(
575 default,
576 alias = "editDiagnosticsMs",
577 skip_serializing_if = "Option::is_none"
578 )]
579 pub edit_diagnostics_ms: Option<u64>,
580 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
581 pub servers: HashMap<String, LspServerConfig>,
582}
583
584#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
586pub struct PriceConfig {
587 pub input: f64,
588 pub output: f64,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub cache_read: Option<f64>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub cache_write: Option<f64>,
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
597pub struct LspServerConfig {
598 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub command: Option<String>,
600 #[serde(default, skip_serializing_if = "Vec::is_empty")]
601 pub args: Vec<String>,
602 #[serde(default, alias = "fileTypes", skip_serializing_if = "Vec::is_empty")]
603 pub file_types: Vec<String>,
604 #[serde(default, alias = "languageId", skip_serializing_if = "Option::is_none")]
605 pub language_id: Option<String>,
606 #[serde(default, alias = "rootMarkers", skip_serializing_if = "Vec::is_empty")]
607 pub root_markers: Vec<String>,
608 #[serde(
609 default,
610 alias = "initOptions",
611 skip_serializing_if = "Option::is_none"
612 )]
613 pub init_options: Option<toml::Value>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub settings: Option<toml::Value>,
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub disabled: Option<bool>,
618 #[serde(default, alias = "isLinter", skip_serializing_if = "Option::is_none")]
619 pub is_linter: Option<bool>,
620}
621
622impl LspConfig {
623 pub fn to_runtime_settings(&self) -> (Option<u64>, Vec<(String, RuntimeLspServer)>) {
628 let servers = self
629 .servers
630 .iter()
631 .map(|(name, spec)| (name.clone(), spec.to_runtime()))
632 .collect();
633 (self.idle_timeout_ms, servers)
634 }
635}
636
637#[derive(Debug, Clone)]
639pub struct RuntimeLspServer {
640 pub command: Option<String>,
641 pub args: Vec<String>,
642 pub file_types: Vec<String>,
643 pub language_id: Option<String>,
644 pub root_markers: Vec<String>,
645 pub init_options: Option<serde_json::Value>,
646 pub settings: Option<serde_json::Value>,
647 pub disabled: Option<bool>,
648 pub is_linter: Option<bool>,
649}
650
651impl LspServerConfig {
652 fn to_runtime(&self) -> RuntimeLspServer {
653 RuntimeLspServer {
654 command: self.command.clone(),
655 args: self.args.clone(),
656 file_types: self.file_types.clone(),
657 language_id: self.language_id.clone(),
658 root_markers: self.root_markers.clone(),
659 init_options: self.init_options.as_ref().and_then(toml_to_json),
660 settings: self.settings.as_ref().and_then(toml_to_json),
661 disabled: self.disabled,
662 is_linter: self.is_linter,
663 }
664 }
665}
666
667fn toml_to_json(value: &toml::Value) -> Option<serde_json::Value> {
668 serde_json::to_value(value).ok()
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct McpServerConfig {
676 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
678 pub transport: Option<McpTransportKind>,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub command: Option<String>,
682 #[serde(default)]
683 pub args: Vec<String>,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub env: Option<HashMap<String, String>>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub cwd: Option<String>,
688 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub url: Option<String>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub headers: Option<HashMap<String, String>>,
693}
694
695impl McpServerConfig {
696 pub fn resolved_transport(&self) -> std::result::Result<McpTransportKind, String> {
697 if let Some(kind) = self.transport {
698 return match kind {
699 McpTransportKind::Stdio if self.command.is_none() => {
700 Err("MCP transport 'stdio' requires `command`".into())
701 }
702 McpTransportKind::Http | McpTransportKind::Sse | McpTransportKind::Auto
703 if self.url.is_none() =>
704 {
705 Err(format!("MCP transport '{kind:?}' requires `url`"))
706 }
707 other => Ok(other),
708 };
709 }
710 match (&self.url, &self.command) {
711 (Some(_), _) => Ok(McpTransportKind::Auto),
712 (None, Some(_)) => Ok(McpTransportKind::Stdio),
713 (None, None) => {
714 Err("MCP server config needs either `command` (stdio) or `url` (http/sse)".into())
715 }
716 }
717 }
718
719 pub fn stdio_problem(&self) -> Option<String> {
723 let command = self.command.as_deref()?.trim();
724 if command.is_empty() {
725 return Some("MCP server `command` is empty".into());
726 }
727 let file = command.rsplit(['/', '\\']).next().unwrap_or(command);
729 let stem = file.split('.').next().unwrap_or(file).to_ascii_lowercase();
730 let runner = matches!(stem.as_str(), "npx" | "pnpx" | "bunx" | "uvx");
731 (runner && self.args.iter().all(|a| a.trim().is_empty())).then(|| {
732 format!(
733 "`{command}` needs the package to run in `args` \
734 (e.g. args = [\"-y\", \"<package>\"])"
735 )
736 })
737 }
738
739 pub fn is_remote(&self) -> bool {
740 matches!(
741 self.resolved_transport().ok(),
742 Some(McpTransportKind::Http | McpTransportKind::Sse | McpTransportKind::Auto)
743 )
744 }
745}
746
747impl Default for Config {
748 fn default() -> Self {
749 use whycodes_core::types::{AgentInfo, AgentMode, PermissionSet};
750
751 let build_agent = AgentInfo {
752 name: "build".to_string(),
753 description:
754 "Primary coding agent with full tool access — write, edit, shell, and network."
755 .to_string(),
756 mode: AgentMode::Primary,
757 permission: PermissionSet {
758 allowed_tools: None,
759 denied_tools: None,
760 allow_file_writes: true,
761 allow_network: true,
762 allow_shell: true,
763 allowed_paths: None,
764 rules: Default::default(),
765 },
766 model: None,
767 system_prompt: None, temperature: None,
769 top_p: None,
770 };
771
772 let plan_agent = AgentInfo {
774 name: "plan".to_string(),
775 description: "Read-only planning agent — analyzes code and proposes changes but does not modify files.".to_string(),
776 mode: AgentMode::Primary,
777 permission: PermissionSet {
778 allowed_tools: Some(vec![
779 "read".to_string(),
780 "grep".to_string(),
781 "glob".to_string(),
782 "list".to_string(),
783 "repomap".to_string(),
784 "webfetch".to_string(),
785 "websearch".to_string(),
786 "lsp".to_string(),
787 ]),
788 denied_tools: Some(vec![
789 "write".to_string(),
790 "edit".to_string(),
791 "bash".to_string(),
792 "shell".to_string(),
793 "apply_patch".to_string(),
794 "todowrite".to_string(),
795 "todo".to_string(),
796 "git_commit".to_string(),
797 "task".to_string(),
798 "swarm".to_string(),
799 ]),
800 allow_file_writes: false,
801 allow_network: true,
802 allow_shell: false,
803 allowed_paths: None,
804 rules: Default::default(),
805 },
806 model: None,
807 system_prompt: None, temperature: None,
809 top_p: None,
810 };
811
812 let ask_agent = AgentInfo {
814 name: "ask".to_string(),
815 description:
816 "Read-only Q&A agent — explains code and answers questions without modifying files."
817 .to_string(),
818 mode: AgentMode::Primary,
819 permission: PermissionSet {
820 allowed_tools: Some(vec![
821 "read".to_string(),
822 "grep".to_string(),
823 "glob".to_string(),
824 "list".to_string(),
825 "repomap".to_string(),
826 "webfetch".to_string(),
827 "websearch".to_string(),
828 "lsp".to_string(),
829 "question".to_string(),
830 ]),
831 denied_tools: Some(vec![
832 "write".to_string(),
833 "edit".to_string(),
834 "bash".to_string(),
835 "shell".to_string(),
836 "apply_patch".to_string(),
837 "todowrite".to_string(),
838 "todo".to_string(),
839 "git_commit".to_string(),
840 "task".to_string(),
841 "swarm".to_string(),
842 "plan".to_string(),
843 ]),
844 allow_file_writes: false,
845 allow_network: true,
846 allow_shell: false,
847 allowed_paths: None,
848 rules: Default::default(),
849 },
850 model: None,
851 system_prompt: None, temperature: None,
853 top_p: None,
854 };
855
856 let whycodes_agent = AgentInfo {
861 name: "whycodes".to_string(),
862 description: "WhyCodes operator — explains WhyCodes and repairs its config; never writes project source.".to_string(),
863 mode: AgentMode::Primary,
864 permission: PermissionSet {
865 allowed_tools: Some(vec![
866 "read".to_string(),
867 "grep".to_string(),
868 "glob".to_string(),
869 "list".to_string(),
870 "write".to_string(),
871 "edit".to_string(),
872 "bash".to_string(),
873 "skill".to_string(),
874 "question".to_string(),
875 ]),
876 denied_tools: Some(vec![
877 "apply_patch".to_string(),
878 "ast_edit".to_string(),
879 "todowrite".to_string(),
880 "todo".to_string(),
881 "git_commit".to_string(),
882 "task".to_string(),
883 "swarm".to_string(),
884 ]),
885 allow_file_writes: true,
886 allow_network: false,
887 allow_shell: true,
888 allowed_paths: None,
889 rules: HashMap::from([
890 ("write(**)".to_string(), PermissionAction::Deny),
891 ("write(.whycodes/**)".to_string(), PermissionAction::Allow),
892 ("bash(*)".to_string(), PermissionAction::Deny),
893 (
894 "bash(whycodes config *)".to_string(),
895 PermissionAction::Allow,
896 ),
897 (
898 "bash(whycodes debug *)".to_string(),
899 PermissionAction::Allow,
900 ),
901 ]),
902 },
903 model: None,
904 system_prompt: None, temperature: None,
906 top_p: None,
907 };
908
909 let explore_agent = AgentInfo {
911 name: "explore".to_string(),
912 description: "Fast read-only subagent for exploring codebases (find files, search keywords, answer questions).".to_string(),
913 mode: AgentMode::Subagent,
914 permission: PermissionSet {
915 allowed_tools: Some(vec![
916 "read".to_string(),
917 "grep".to_string(),
918 "glob".to_string(),
919 "list".to_string(),
920 "repomap".to_string(),
921 "webfetch".to_string(),
922 "websearch".to_string(),
923 "lsp".to_string(),
924 ]),
925 denied_tools: Some(vec![
926 "write".to_string(),
927 "edit".to_string(),
928 "bash".to_string(),
929 "shell".to_string(),
930 "apply_patch".to_string(),
931 "todowrite".to_string(),
932 "todo".to_string(),
933 "question".to_string(),
934 ]),
935 allow_file_writes: false,
936 allow_network: true,
937 allow_shell: false,
938 allowed_paths: None,
939 rules: Default::default(),
940 },
941 model: None,
942 system_prompt: Some("You are a read-only exploration subagent. Find files, search code, answer questions. No file modifications. Do not call `question` — report findings to the parent.".to_string()),
943 temperature: None,
944 top_p: None,
945 };
946
947 let general_agent = AgentInfo {
949 name: "general".to_string(),
950 description: "General-purpose subagent for complex multi-step tasks. Full tools except todo. Use for parallel units of work.".to_string(),
951 mode: AgentMode::Subagent,
952 permission: PermissionSet {
953 allowed_tools: None,
954 denied_tools: Some(vec![
955 "todowrite".to_string(),
956 "todo".to_string(),
957 "todoread".to_string(),
958 ]),
959 allow_file_writes: true,
960 allow_network: true,
961 allow_shell: true,
962 allowed_paths: None,
963 rules: Default::default(),
964 },
965 model: None,
966 system_prompt: Some("You are a general-purpose subagent for complex searches and multi-step tasks. Complete the goal thoroughly.".to_string()),
967 temperature: None,
968 top_p: None,
969 };
970
971 let scout_agent = AgentInfo {
973 name: "scout".to_string(),
974 description: "Read-only subagent for external docs and dependency research.".to_string(),
975 mode: AgentMode::Subagent,
976 permission: PermissionSet {
977 allowed_tools: Some(vec![
978 "read".to_string(),
979 "grep".to_string(),
980 "glob".to_string(),
981 "list".to_string(),
982 "repomap".to_string(),
983 "webfetch".to_string(),
984 "websearch".to_string(),
985 "bash".to_string(), ]),
987 denied_tools: Some(vec![
988 "write".to_string(),
989 "edit".to_string(),
990 "apply_patch".to_string(),
991 "todowrite".to_string(),
992 "todo".to_string(),
993 "question".to_string(),
994 ]),
995 allow_file_writes: false,
996 allow_network: true,
997 allow_shell: true,
998 allowed_paths: None,
999 rules: Default::default(),
1000 },
1001 model: None,
1002 system_prompt: Some("You are a scout subagent. Research external docs and dependencies. Prefer webfetch/websearch. Do not modify the workspace project files. Do not call `question` — report findings to the parent.".to_string()),
1003 temperature: None,
1004 top_p: None,
1005 };
1006
1007 Config {
1008 providers: HashMap::new(),
1009 models: HashMap::new(),
1010 pricing: HashMap::new(),
1011 agents: vec![
1012 build_agent,
1013 plan_agent,
1014 ask_agent,
1015 whycodes_agent,
1016 explore_agent,
1017 general_agent,
1018 scout_agent,
1019 ],
1020 default_agent: "build".to_string(),
1021 default_model: None,
1022 command_configs: HashMap::new(),
1023 tools: ToolsConfig::default(),
1024 session: SessionConfig::default(),
1025 tui: TuiConfig::default(),
1026 general: GeneralConfig::default(),
1027 schema_version: CONFIG_SCHEMA_VERSION,
1028 mcp_servers: HashMap::new(),
1029 lsp: LspConfig::default(),
1030 permission: HashMap::new(),
1031 commands: HashMap::new(),
1032 security: SecurityConfig::default(),
1033 hooks: Vec::new(),
1034 memory: MemoryConfig::default(),
1035 swarm: SwarmConfig::default(),
1036 automation: AutomationConfig::default(),
1037 notify: NotifyConfig::default(),
1038 slop: SlopConfig::default(),
1039 websearch: WebSearchConfig::default(),
1040 system_prompt_overlays: SystemPromptOverlays::default(),
1041 }
1042 }
1043}
1044
1045#[derive(Debug, Clone, Default, PartialEq, Eq)]
1054pub struct SystemPromptOverlays {
1055 pub providers: HashMap<String, String>,
1057 pub models: HashMap<String, String>,
1059}
1060
1061impl SystemPromptOverlays {
1062 pub fn is_empty(&self) -> bool {
1063 self.providers.is_empty() && self.models.is_empty()
1064 }
1065
1066 pub fn sections(&self, provider: &str, model: &str) -> Vec<(String, String)> {
1070 let p = overlay_key(provider);
1071 let m = overlay_key(model);
1072 let mut out = Vec::new();
1073 if !p.is_empty()
1074 && let Some(body) = self.providers.get(&p)
1075 {
1076 out.push((format!("Provider instructions ({p})"), body.clone()));
1077 }
1078 if !m.is_empty() {
1079 let body = if p.is_empty() {
1080 self.models.get(&m)
1081 } else {
1082 self.models
1083 .get(&format!("{p}/{m}"))
1084 .or_else(|| self.models.get(&m))
1085 };
1086 if let Some(body) = body {
1087 out.push((format!("Model instructions ({m})"), body.clone()));
1088 }
1089 }
1090 out
1091 }
1092}
1093
1094pub(crate) fn overlay_key(raw: &str) -> String {
1095 raw.trim().to_ascii_lowercase()
1096}
1097
1098#[cfg(test)]
1099mod overlay_tests {
1100 use super::*;
1101
1102 #[test]
1103 fn overlay_key_trims_and_lowercases() {
1104 assert_eq!(overlay_key(" XAI "), "xai");
1105 assert_eq!(overlay_key(""), "");
1106 }
1107
1108 #[test]
1109 fn sections_provider_then_specific_model() {
1110 let mut o = SystemPromptOverlays::default();
1111 o.providers.insert("xai".into(), "prov".into());
1112 o.models.insert("xai/grok-4.6".into(), "specific".into());
1113 o.models.insert("grok-4.6".into(), "generic".into());
1114 let s = o.sections("XAI", "grok-4.6");
1115 assert_eq!(s.len(), 2);
1116 assert_eq!(s[0].0, "Provider instructions (xai)");
1117 assert_eq!(s[0].1, "prov");
1118 assert_eq!(s[1].0, "Model instructions (grok-4.6)");
1119 assert_eq!(s[1].1, "specific");
1120 }
1121
1122 #[test]
1123 fn sections_bare_model_when_no_provider_or_specific() {
1124 let mut o = SystemPromptOverlays::default();
1125 o.models.insert("grok-4.6".into(), "generic".into());
1126 let s = o.sections("xai", "grok-4.6");
1127 assert_eq!(
1128 s,
1129 vec![("Model instructions (grok-4.6)".into(), "generic".into())]
1130 );
1131 let only_model = o.sections("", "grok-4.6");
1132 assert_eq!(only_model.len(), 1);
1133 assert!(o.sections("xai", "missing").is_empty());
1134 assert!(o.sections("", "").is_empty());
1135 assert!(SystemPromptOverlays::default().is_empty());
1136 }
1137}
1138
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1141pub struct MemoryConfig {
1142 #[serde(default = "default_true")]
1144 pub enabled: bool,
1145 #[serde(default = "default_true")]
1147 pub auto_inject: bool,
1148 #[serde(default = "default_true")]
1150 pub auto_retain: bool,
1151 #[serde(default = "default_true")]
1154 pub retain_llm: bool,
1155 #[serde(default)]
1157 pub retain_llm_always: bool,
1158 #[serde(default = "default_retain_every_n")]
1160 pub retain_every_n: usize,
1161 #[serde(default = "default_retain_max_facts")]
1163 pub retain_max_facts: usize,
1164 #[serde(default = "default_memory_index_lines")]
1166 pub max_index_lines: usize,
1167 #[serde(default = "default_memory_index_bytes")]
1169 pub max_index_bytes: usize,
1170 #[serde(default = "default_memory_top_k")]
1172 pub recall_top_k: usize,
1173 #[serde(default = "default_memory_min_score")]
1175 pub recall_min_score: f32,
1176 #[serde(default = "default_memory_token_budget")]
1178 pub recall_token_budget: usize,
1179 #[serde(default = "default_memory_embed_dim")]
1181 pub embed_dim: usize,
1182 #[serde(default = "default_memory_scope")]
1184 pub scope: String,
1185 #[serde(default = "default_memory_backend")]
1187 pub embed_backend: String,
1188 #[serde(default = "default_true")]
1190 pub code_inject: bool,
1191 #[serde(default = "default_code_top_k")]
1192 pub code_top_k: usize,
1193 #[serde(default = "default_code_min_score")]
1194 pub code_min_score: f32,
1195 #[serde(default = "default_true")]
1197 pub subagent_banks: bool,
1198 #[serde(default = "default_true")]
1200 pub auto_index: bool,
1201 #[serde(default = "default_auto_index_files")]
1203 pub auto_index_max_files: usize,
1204 #[serde(default = "default_auto_index_chunks")]
1206 pub auto_index_max_chunks: usize,
1207 #[serde(default = "default_true")]
1209 pub session_inject: bool,
1210 #[serde(default = "default_session_top_k")]
1211 pub session_top_k: usize,
1212 #[serde(default = "default_session_min_score")]
1213 pub session_min_score: f32,
1214 #[serde(default = "default_true")]
1216 pub consolidate: bool,
1217 #[serde(default = "default_consolidate_max")]
1218 pub consolidate_max: usize,
1219}
1220
1221impl Default for MemoryConfig {
1222 fn default() -> Self {
1223 Self {
1224 enabled: true,
1225 auto_inject: true,
1226 auto_retain: true,
1227 retain_llm: true,
1228 retain_llm_always: false,
1229 retain_every_n: default_retain_every_n(),
1230 retain_max_facts: default_retain_max_facts(),
1231 max_index_lines: default_memory_index_lines(),
1232 max_index_bytes: default_memory_index_bytes(),
1233 recall_top_k: default_memory_top_k(),
1234 recall_min_score: default_memory_min_score(),
1235 recall_token_budget: default_memory_token_budget(),
1236 embed_dim: default_memory_embed_dim(),
1237 scope: default_memory_scope(),
1238 embed_backend: default_memory_backend(),
1239 code_inject: true,
1240 code_top_k: default_code_top_k(),
1241 code_min_score: default_code_min_score(),
1242 subagent_banks: true,
1243 auto_index: true,
1244 auto_index_max_files: default_auto_index_files(),
1245 auto_index_max_chunks: default_auto_index_chunks(),
1246 session_inject: true,
1247 session_top_k: default_session_top_k(),
1248 session_min_score: default_session_min_score(),
1249 consolidate: true,
1250 consolidate_max: default_consolidate_max(),
1251 }
1252 }
1253}
1254
1255pub(crate) fn default_memory_index_lines() -> usize {
1256 200
1257}
1258pub(crate) fn default_memory_index_bytes() -> usize {
1259 25_600
1260}
1261pub(crate) fn default_memory_top_k() -> usize {
1262 5
1263}
1264pub(crate) fn default_memory_min_score() -> f32 {
1265 0.28
1266}
1267pub(crate) fn default_memory_token_budget() -> usize {
1268 800
1269}
1270pub(crate) fn default_memory_embed_dim() -> usize {
1271 256
1272}
1273pub(crate) fn default_retain_every_n() -> usize {
1274 1
1275}
1276pub(crate) fn default_retain_max_facts() -> usize {
1277 3
1278}
1279pub(crate) fn default_memory_scope() -> String {
1280 "user".into()
1281}
1282pub(crate) fn default_memory_backend() -> String {
1283 "hash".into()
1284}
1285pub(crate) fn default_code_top_k() -> usize {
1286 4
1287}
1288pub(crate) fn default_code_min_score() -> f32 {
1289 0.22
1290}
1291pub(crate) fn default_auto_index_files() -> usize {
1292 1500
1293}
1294pub(crate) fn default_auto_index_chunks() -> usize {
1295 4000
1296}
1297pub(crate) fn default_session_top_k() -> usize {
1298 3
1299}
1300pub(crate) fn default_session_min_score() -> f32 {
1301 0.22
1302}
1303pub(crate) fn default_consolidate_max() -> usize {
1304 80
1305}
1306
1307pub(crate) fn default_agent() -> String {
1308 "build".to_string()
1309}
1310
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1313pub struct QuestionToolConfig {
1314 #[serde(default = "default_question_timeout_enabled")]
1316 pub timeout_enabled: bool,
1317 #[serde(default = "default_question_timeout_secs")]
1319 pub timeout_secs: u64,
1320}
1321
1322impl Default for QuestionToolConfig {
1323 fn default() -> Self {
1324 Self {
1325 timeout_enabled: default_question_timeout_enabled(),
1326 timeout_secs: default_question_timeout_secs(),
1327 }
1328 }
1329}
1330
1331pub(crate) fn default_question_timeout_enabled() -> bool {
1332 true
1333}
1334
1335pub(crate) fn default_question_timeout_secs() -> u64 {
1336 1800
1337}
1338
1339#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1340pub struct ToolsConfig {
1341 #[serde(default = "default_true")]
1342 pub enable_read: bool,
1343 #[serde(default = "default_true")]
1344 pub enable_write: bool,
1345 #[serde(default = "default_true")]
1346 pub enable_edit: bool,
1347 #[serde(default = "default_true")]
1348 pub enable_glob: bool,
1349 #[serde(default = "default_true")]
1350 pub enable_grep: bool,
1351 #[serde(default = "default_true")]
1352 pub enable_shell: bool,
1353 #[serde(default = "default_true")]
1354 pub enable_webfetch: bool,
1355 #[serde(default = "default_true")]
1356 pub enable_websearch: bool,
1357 #[serde(default)]
1359 pub question: QuestionToolConfig,
1360 #[serde(default)]
1361 pub disabled_tools: Vec<String>,
1362 #[serde(default)]
1363 pub custom_tools: HashMap<String, CustomToolConfig>,
1364 #[serde(default)]
1366 pub bash: BashConfig,
1367 #[serde(default)]
1369 pub format: FormatConfig,
1370}
1371
1372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1376pub struct FormatConfig {
1377 #[serde(default = "default_true")]
1378 pub enabled: bool,
1379}
1380
1381impl Default for FormatConfig {
1382 fn default() -> Self {
1383 Self { enabled: true }
1384 }
1385}
1386
1387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1389pub struct BashConfig {
1390 #[serde(default = "default_true")]
1392 pub auto_background: bool,
1393 #[serde(default = "default_auto_background_after_secs")]
1395 pub auto_background_after_secs: u64,
1396}
1397
1398pub(crate) fn default_auto_background_after_secs() -> u64 {
1399 20
1400}
1401
1402impl Default for BashConfig {
1403 fn default() -> Self {
1404 Self {
1405 auto_background: true,
1406 auto_background_after_secs: default_auto_background_after_secs(),
1407 }
1408 }
1409}
1410
1411pub(crate) fn default_goal_max_turns() -> u32 {
1412 30
1413}
1414
1415pub(crate) fn default_true() -> bool {
1416 true
1417}
1418
1419pub const CONFIG_SCHEMA_VERSION: u32 = 1;
1421
1422#[derive(Debug, Clone, Serialize, Deserialize)]
1423pub struct SessionConfig {
1424 #[serde(default = "default_max_tokens")]
1425 pub max_context_tokens: usize,
1426 #[serde(default = "default_compaction_threshold")]
1427 pub compaction_threshold: usize,
1428 #[serde(default, skip_serializing_if = "Option::is_none")]
1432 pub compaction_buffer_percent: Option<u8>,
1433 #[serde(default)]
1434 pub store_path: Option<PathBuf>,
1435 #[serde(default = "default_true")]
1438 pub auto_title: bool,
1439 #[serde(default, skip_serializing_if = "Option::is_none")]
1442 pub title_model: Option<String>,
1443 #[serde(default = "default_tool_profile")]
1446 pub tool_profile: String,
1447 #[serde(default = "default_prompt_cache")]
1449 pub prompt_cache: String,
1450 #[serde(default)]
1454 pub cache_warm_minutes: u64,
1455 #[serde(default = "default_undo_depth")]
1458 pub undo_depth: usize,
1459 #[serde(default, skip_serializing_if = "Option::is_none")]
1462 pub model_fast: Option<String>,
1463 #[serde(default = "default_goal_max_turns")]
1466 pub goal_max_turns: u32,
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1470 pub goal_model: Option<String>,
1471 #[serde(default, skip_serializing_if = "Option::is_none")]
1474 pub model_smol: Option<String>,
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1477 pub model_plan: Option<String>,
1478 #[serde(default, skip_serializing_if = "Option::is_none")]
1482 pub prewalk: Option<bool>,
1483 #[serde(default = "default_model_race")]
1486 pub model_race: String,
1487 #[serde(default = "default_race_after_ms")]
1489 pub race_after_ms: u64,
1490 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1495 pub fallback_models: Vec<String>,
1496 #[serde(default = "default_stream_retry_secs")]
1500 pub stream_retry_secs: u64,
1501 #[serde(default = "default_response_cache")]
1503 pub response_cache: String,
1504 #[serde(default = "default_true")]
1507 pub preconnect: bool,
1508 #[serde(default = "default_intent_guidance")]
1515 pub intent_guidance: String,
1516 #[serde(default = "default_compaction_llm")]
1520 pub compaction_llm: String,
1521 #[serde(default = "default_compaction_method")]
1526 pub compaction_method: String,
1527 #[serde(default, skip_serializing_if = "Option::is_none")]
1532 pub reasoning_effort: Option<String>,
1533 #[serde(default)]
1536 pub magic_keywords: MagicKeywordsConfig,
1537 #[serde(default)]
1540 pub stream_rules: Vec<StreamRuleConfig>,
1541 #[serde(default, skip_serializing_if = "Option::is_none")]
1547 pub headless_ask: Option<HeadlessAskMode>,
1548}
1549
1550#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1552#[serde(rename_all = "kebab-case")]
1553pub enum HeadlessAskMode {
1554 Deny,
1556 Allow,
1558}
1559
1560impl HeadlessAskMode {
1561 pub fn parse(s: &str) -> Option<Self> {
1562 match s.trim().to_ascii_lowercase().as_str() {
1563 "deny" | "ask-fail" | "fail" | "fail-closed" => Some(Self::Deny),
1564 "allow" | "auto" | "approve" => Some(Self::Allow),
1565 _ => None,
1566 }
1567 }
1568
1569 pub fn as_str(self) -> &'static str {
1570 match self {
1571 Self::Deny => "deny",
1572 Self::Allow => "allow",
1573 }
1574 }
1575}
1576
1577#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1579pub struct StreamRuleConfig {
1580 #[serde(default)]
1581 pub name: String,
1582 #[serde(default)]
1583 pub pattern: String,
1584 #[serde(default)]
1585 pub hint: String,
1586 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1588 pub scope: Vec<StreamRuleScope>,
1589 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1591 pub tools: Vec<String>,
1592 #[serde(default, skip_serializing_if = "StreamRuleRepeat::is_always")]
1594 pub repeat: StreamRuleRepeat,
1595}
1596
1597impl StreamRuleConfig {
1598 pub fn scopes(&self) -> Vec<StreamRuleScope> {
1600 if self.scope.is_empty() {
1601 vec![StreamRuleScope::Text]
1602 } else {
1603 self.scope.clone()
1604 }
1605 }
1606}
1607
1608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1610#[serde(rename_all = "snake_case")]
1611pub enum StreamRuleScope {
1612 Text,
1614 Thinking,
1616 ToolArgs,
1618}
1619
1620impl StreamRuleScope {
1621 pub fn parse(s: &str) -> Option<Self> {
1622 match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
1623 "text" => Some(Self::Text),
1624 "thinking" => Some(Self::Thinking),
1625 "tool_args" => Some(Self::ToolArgs),
1626 _ => None,
1627 }
1628 }
1629
1630 pub fn as_str(self) -> &'static str {
1631 match self {
1632 Self::Text => "text",
1633 Self::Thinking => "thinking",
1634 Self::ToolArgs => "tool_args",
1635 }
1636 }
1637}
1638
1639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1641#[serde(rename_all = "snake_case")]
1642pub enum StreamRuleRepeat {
1643 Once,
1645 #[default]
1647 Always,
1648 AfterTurns(u32),
1650}
1651
1652impl StreamRuleRepeat {
1653 pub fn is_always(&self) -> bool {
1654 *self == Self::Always
1655 }
1656
1657 pub fn parse(s: &str) -> Option<Self> {
1659 let s = s
1660 .trim()
1661 .trim_start_matches('{')
1662 .trim_end_matches('}')
1663 .trim();
1664 match s.to_ascii_lowercase().as_str() {
1665 "once" => return Some(Self::Once),
1666 "always" => return Some(Self::Always),
1667 _ => {}
1668 }
1669 let n = match s.split_once([':', '=']) {
1670 Some((k, v)) if k.trim() == "after_turns" => v,
1671 Some(_) => return None,
1672 None => s,
1673 };
1674 n.trim().parse().ok().map(Self::AfterTurns)
1675 }
1676
1677 pub fn allows(self, last: Option<u64>, turn: u64) -> bool {
1679 match (self, last) {
1680 (_, None) | (Self::Always, _) => true,
1681 (Self::Once, Some(_)) => false,
1682 (Self::AfterTurns(n), Some(t)) => turn.saturating_sub(t) >= u64::from(n),
1683 }
1684 }
1685
1686 pub fn label(self) -> String {
1687 match self {
1688 Self::Once => "once".into(),
1689 Self::Always => "always".into(),
1690 Self::AfterTurns(n) => format!("after_turns={n}"),
1691 }
1692 }
1693}
1694
1695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1697pub struct MagicKeywordsConfig {
1698 #[serde(default = "default_true")]
1700 pub enabled: bool,
1701 #[serde(default = "default_true")]
1703 pub ultrathink: bool,
1704 #[serde(default = "default_true")]
1706 pub orchestrate: bool,
1707}
1708
1709impl Default for MagicKeywordsConfig {
1710 fn default() -> Self {
1711 Self {
1712 enabled: true,
1713 ultrathink: true,
1714 orchestrate: true,
1715 }
1716 }
1717}
1718
1719impl Default for SessionConfig {
1720 fn default() -> Self {
1721 Self {
1722 max_context_tokens: default_max_tokens(),
1723 compaction_threshold: default_compaction_threshold(),
1724 compaction_buffer_percent: None,
1725 store_path: None,
1726 auto_title: true,
1727 title_model: None,
1728 tool_profile: default_tool_profile(),
1729 prompt_cache: default_prompt_cache(),
1730 cache_warm_minutes: 0,
1731 undo_depth: default_undo_depth(),
1732 model_fast: None,
1733 goal_max_turns: default_goal_max_turns(),
1734 goal_model: None,
1735 model_smol: None,
1736 model_plan: None,
1737 prewalk: None,
1738 model_race: default_model_race(),
1739 race_after_ms: default_race_after_ms(),
1740 fallback_models: Vec::new(),
1741 stream_retry_secs: default_stream_retry_secs(),
1742 response_cache: default_response_cache(),
1743 preconnect: true,
1744 intent_guidance: default_intent_guidance(),
1745 compaction_llm: default_compaction_llm(),
1746 compaction_method: default_compaction_method(),
1747 reasoning_effort: None,
1748 magic_keywords: MagicKeywordsConfig::default(),
1749 stream_rules: Vec::new(),
1750 headless_ask: None,
1751 }
1752 }
1753}
1754
1755pub(crate) fn default_compaction_llm() -> String {
1756 "auto".into()
1757}
1758
1759pub(crate) fn default_compaction_method() -> String {
1760 "local".into()
1761}
1762
1763pub(crate) fn default_tool_profile() -> String {
1764 "core".into()
1765}
1766
1767pub(crate) fn default_prompt_cache() -> String {
1768 "auto".into()
1769}
1770
1771pub(crate) fn default_model_race() -> String {
1772 "off".into()
1773}
1774
1775pub(crate) fn default_race_after_ms() -> u64 {
1776 800
1777}
1778
1779pub(crate) fn default_stream_retry_secs() -> u64 {
1780 60
1781}
1782
1783pub(crate) fn default_undo_depth() -> usize {
1784 50
1785}
1786
1787pub(crate) fn default_response_cache() -> String {
1788 "auto".into()
1789}
1790
1791pub(crate) fn default_intent_guidance() -> String {
1792 "auto".into()
1793}
1794
1795pub(crate) fn default_max_tokens() -> usize {
1796 200_000
1797}
1798
1799pub(crate) fn default_compaction_threshold() -> usize {
1800 150_000
1801}
1802
1803#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1819pub struct NotifyConfig {
1820 #[serde(default)]
1823 pub on: Vec<String>,
1824 #[serde(default, skip_serializing_if = "Option::is_none")]
1826 pub discord_webhook: Option<String>,
1827 #[serde(default, skip_serializing_if = "Option::is_none")]
1829 pub telegram_bot_token: Option<String>,
1830 #[serde(default, skip_serializing_if = "Option::is_none")]
1832 pub telegram_chat_id: Option<String>,
1833 #[serde(default = "default_notify_timeout_secs")]
1835 pub timeout_secs: u64,
1836 #[serde(default, skip_serializing_if = "Option::is_none")]
1839 pub desktop: Option<bool>,
1840}
1841
1842pub(crate) fn default_notify_timeout_secs() -> u64 {
1843 8
1844}
1845
1846impl Default for NotifyConfig {
1847 fn default() -> Self {
1848 Self {
1849 on: Vec::new(),
1850 discord_webhook: None,
1851 telegram_bot_token: None,
1852 telegram_chat_id: None,
1853 timeout_secs: default_notify_timeout_secs(),
1854 desktop: None,
1855 }
1856 }
1857}
1858
1859impl NotifyConfig {
1860 pub fn wants(&self, event: NotifyEvent) -> bool {
1862 self.on.iter().any(|e| NotifyEvent::parse(e) == Some(event))
1863 }
1864
1865 pub fn discord_webhook_url(&self) -> Option<&str> {
1867 nonempty_str(self.discord_webhook.as_deref())
1868 }
1869
1870 pub fn telegram_token(&self) -> Option<&str> {
1872 nonempty_str(self.telegram_bot_token.as_deref())
1873 }
1874
1875 pub fn telegram_chat(&self) -> Option<&str> {
1877 nonempty_str(self.telegram_chat_id.as_deref())
1878 }
1879
1880 pub fn has_channel(&self) -> bool {
1882 self.discord_webhook_url().is_some()
1883 || (self.telegram_token().is_some() && self.telegram_chat().is_some())
1884 }
1885
1886 pub fn enabled_for(&self, event: NotifyEvent) -> bool {
1888 self.wants(event) && self.has_channel()
1889 }
1890
1891 pub fn desktop_for(&self, event: NotifyEvent) -> bool {
1893 self.desktop == Some(true) && self.wants(event)
1894 }
1895
1896 pub(crate) fn merge_with(&self, other: &NotifyConfig) -> NotifyConfig {
1897 let mut merged = self.clone();
1898 if !other.on.is_empty() {
1899 merged.on = other.on.clone();
1900 }
1901 if other.discord_webhook.is_some() {
1902 merged.discord_webhook = other.discord_webhook.clone();
1903 }
1904 if other.telegram_bot_token.is_some() {
1905 merged.telegram_bot_token = other.telegram_bot_token.clone();
1906 }
1907 if other.telegram_chat_id.is_some() {
1908 merged.telegram_chat_id = other.telegram_chat_id.clone();
1909 }
1910 if other.timeout_secs != default_notify_timeout_secs() {
1911 merged.timeout_secs = other.timeout_secs;
1912 }
1913 if other.desktop.is_some() {
1914 merged.desktop = other.desktop;
1915 }
1916 merged
1917 }
1918}
1919
1920#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1931pub struct WebSearchConfig {
1932 #[serde(default, skip_serializing_if = "Option::is_none")]
1935 pub engine: Option<String>,
1936 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1938 pub fallback: Vec<String>,
1939 #[serde(default, skip_serializing_if = "Option::is_none")]
1941 pub custom: Option<WebSearchCustomConfig>,
1942}
1943
1944impl WebSearchConfig {
1945 pub(crate) fn strip_project_only_keys(&mut self) -> Vec<&'static str> {
1950 let mut dropped = Vec::new();
1951 if self.custom.take().is_some() {
1952 dropped.push("websearch.custom");
1953 }
1954 if self.engine.as_deref().is_some_and(is_custom_engine) {
1955 self.engine = None;
1956 dropped.push("websearch.engine = \"custom\"");
1957 }
1958 let before = self.fallback.len();
1959 self.fallback.retain(|e| !is_custom_engine(e));
1960 if self.fallback.len() != before {
1961 dropped.push("\"custom\" in websearch.fallback");
1962 }
1963 dropped
1964 }
1965
1966 pub(crate) fn merge_with(&self, other: &WebSearchConfig) -> WebSearchConfig {
1967 let mut merged = self.clone();
1968 if other.engine.is_some() {
1969 merged.engine = other.engine.clone();
1970 }
1971 if !other.fallback.is_empty() {
1972 merged.fallback = other.fallback.clone();
1973 }
1974 if other.custom.is_some() {
1975 merged.custom = other.custom.clone();
1976 }
1977 merged
1978 }
1979}
1980
1981fn is_custom_engine(name: &str) -> bool {
1982 name.trim().eq_ignore_ascii_case("custom")
1983}
1984
1985#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1987pub struct WebSearchCustomConfig {
1988 pub endpoint: String,
1990 #[serde(default = "default_websearch_method")]
1992 pub method: String,
1993 #[serde(default = "default_websearch_query_param")]
1995 pub query_param: String,
1996 #[serde(default, skip_serializing_if = "Option::is_none")]
1998 pub count_param: Option<String>,
1999 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2001 pub params: BTreeMap<String, String>,
2002 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2004 pub headers: BTreeMap<String, String>,
2005 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2007 pub header_env: BTreeMap<String, String>,
2008 #[serde(default = "default_websearch_results_pointer")]
2010 pub results_pointer: String,
2011 #[serde(default = "default_websearch_title_pointer")]
2013 pub title_pointer: String,
2014 #[serde(default = "default_websearch_url_pointer")]
2016 pub url_pointer: String,
2017 #[serde(default = "default_websearch_snippet_pointer")]
2019 pub snippet_pointer: String,
2020}
2021
2022pub(crate) fn default_websearch_method() -> String {
2023 "GET".to_string()
2024}
2025pub(crate) fn default_websearch_query_param() -> String {
2026 "q".to_string()
2027}
2028pub(crate) fn default_websearch_results_pointer() -> String {
2029 "/results".to_string()
2030}
2031pub(crate) fn default_websearch_title_pointer() -> String {
2032 "/title".to_string()
2033}
2034pub(crate) fn default_websearch_url_pointer() -> String {
2035 "/url".to_string()
2036}
2037pub(crate) fn default_websearch_snippet_pointer() -> String {
2038 "/snippet".to_string()
2039}
2040
2041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2043pub enum NotifyEvent {
2044 TurnDone,
2046 NeedInput,
2048}
2049
2050impl NotifyEvent {
2051 pub fn as_str(self) -> &'static str {
2052 match self {
2053 Self::TurnDone => "turn_done",
2054 Self::NeedInput => "need_input",
2055 }
2056 }
2057
2058 pub fn parse(raw: &str) -> Option<Self> {
2059 match raw.trim().to_ascii_lowercase().as_str() {
2060 "turn_done" | "turn-done" | "done" => Some(Self::TurnDone),
2061 "need_input" | "need-input" | "input" => Some(Self::NeedInput),
2062 _ => None,
2063 }
2064 }
2065}
2066
2067pub(crate) fn parse_notify_on_csv(raw: &str) -> Vec<String> {
2068 raw.split(',')
2069 .map(str::trim)
2070 .filter(|s| !s.is_empty())
2071 .map(|s| s.to_ascii_lowercase())
2072 .collect()
2073}
2074
2075fn nonempty_str(s: Option<&str>) -> Option<&str> {
2076 s.map(str::trim).filter(|s| !s.is_empty())
2077}
2078
2079pub(crate) fn nonempty_opt(s: String) -> Option<String> {
2080 let t = s.trim();
2081 if t.is_empty() {
2082 None
2083 } else {
2084 Some(t.to_string())
2085 }
2086}
2087
2088pub fn is_discord_webhook_url(url: &str) -> bool {
2090 let url = url.trim();
2091 if !url.starts_with("https://") {
2092 return false;
2093 }
2094 let Ok(host) = network::host_from_url(url) else {
2095 return false;
2096 };
2097 let host = host.to_ascii_lowercase();
2098 if host != "discord.com" && host != "discordapp.com" {
2099 return false;
2100 }
2101 let rest = url.strip_prefix("https://").unwrap_or(url);
2103 let path_start = rest.find('/').unwrap_or(rest.len());
2104 let path = rest[path_start..].split(['?', '#']).next().unwrap_or("");
2105 path.starts_with("/api/webhooks/") && path.len() > "/api/webhooks/".len()
2106}
2107
2108#[derive(Debug, Clone, Serialize, Deserialize)]
2109pub struct TuiConfig {
2110 #[serde(default)]
2111 pub theme: Option<String>,
2112 #[serde(default)]
2113 pub key_bindings: Option<HashMap<String, String>>,
2114 #[serde(default = "default_prompt_suggestions")]
2116 pub prompt_suggestions: String,
2117 #[serde(default)]
2119 pub show_sidebar: bool,
2120 #[serde(default)]
2134 pub agent_colors: HashMap<String, String>,
2135 #[serde(default)]
2138 pub skip_openrouter_key_prompt: bool,
2139}
2140
2141impl Default for TuiConfig {
2142 fn default() -> Self {
2143 Self {
2144 theme: None,
2145 key_bindings: None,
2146 prompt_suggestions: default_prompt_suggestions(),
2147 show_sidebar: false,
2148 agent_colors: HashMap::new(),
2149 skip_openrouter_key_prompt: false,
2150 }
2151 }
2152}
2153
2154pub(crate) fn default_prompt_suggestions() -> String {
2155 "off".into()
2156}
2157
2158#[derive(Debug, Clone, Serialize, Deserialize)]
2159pub struct GeneralConfig {
2160 pub project_path: Option<PathBuf>,
2161 pub log_level: Option<String>,
2162 pub default_gcp_project: Option<String>,
2163 #[serde(default = "default_true")]
2166 pub auto_update: bool,
2167 #[serde(default, skip_serializing_if = "Option::is_none")]
2170 pub approval_mode: Option<ApprovalMode>,
2171}
2172
2173impl Default for GeneralConfig {
2174 fn default() -> Self {
2175 Self {
2176 project_path: None,
2177 log_level: None,
2178 default_gcp_project: None,
2179 auto_update: true,
2180 approval_mode: None,
2181 }
2182 }
2183}
2184
2185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2186pub struct CustomToolConfig {
2187 pub command: String,
2188 pub description: String,
2189 pub parameters: Option<serde_json::Value>,
2190}
2191
2192impl CustomCommandConfig {
2193 pub fn render(&self, args: &str) -> String {
2195 let parts: Vec<&str> = args.split_whitespace().collect();
2196 let mut out = self.template.clone();
2197 out = out.replace("$ARGUMENTS", args);
2198 for (i, p) in parts.iter().enumerate() {
2199 out = out.replace(&format!("${}", i + 1), p);
2200 }
2201 while let Some(start) = out.find("!`") {
2203 if let Some(rel_end) = out[start + 2..].find('`') {
2204 let cmd = &out[start + 2..start + 2 + rel_end];
2205 let output = run_inline_shell(cmd);
2206 out.replace_range(start..start + 2 + rel_end + 1, &output);
2207 } else {
2208 break;
2209 }
2210 }
2211 out
2212 }
2213}
2214
2215fn run_inline_shell(cmd: &str) -> String {
2216 #[cfg(windows)]
2217 let output = std::process::Command::new("cmd").args(["/C", cmd]).output();
2218 #[cfg(not(windows))]
2219 let output = std::process::Command::new("sh").args(["-c", cmd]).output();
2220 match output {
2221 Ok(o) => {
2222 let mut s = String::from_utf8_lossy(&o.stdout).to_string();
2223 let err = String::from_utf8_lossy(&o.stderr);
2224 if !err.is_empty() {
2225 s.push_str(&err);
2226 }
2227 s
2228 }
2229 Err(e) => format!("(command failed: {})", e),
2230 }
2231}
2232
2233#[cfg(test)]
2234mod tests {
2235 use super::*;
2236
2237 fn mcp(
2238 command: Option<&str>,
2239 url: Option<&str>,
2240 transport: Option<McpTransportKind>,
2241 ) -> McpServerConfig {
2242 McpServerConfig {
2243 transport,
2244 command: command.map(str::to_string),
2245 args: vec![],
2246 env: None,
2247 cwd: None,
2248 url: url.map(str::to_string),
2249 headers: None,
2250 }
2251 }
2252
2253 #[test]
2254 fn mcp_notify_hooks_and_discord() {
2255 assert_mcp_transport();
2256 assert_notify_channels();
2257 assert_notify_events_and_discord();
2258 }
2259
2260 fn assert_mcp_transport() {
2261 assert_eq!(
2262 mcp(Some("npx"), None, None).resolved_transport().unwrap(),
2263 McpTransportKind::Stdio
2264 );
2265 assert_eq!(
2266 mcp(None, Some("https://mcp.example"), None)
2267 .resolved_transport()
2268 .unwrap(),
2269 McpTransportKind::Auto
2270 );
2271 assert!(mcp(None, None, None).resolved_transport().is_err());
2272 assert!(
2273 mcp(None, None, Some(McpTransportKind::Stdio))
2274 .resolved_transport()
2275 .is_err()
2276 );
2277 assert!(
2278 mcp(None, None, Some(McpTransportKind::Http))
2279 .resolved_transport()
2280 .is_err()
2281 );
2282 assert!(
2283 mcp(None, None, Some(McpTransportKind::Sse))
2284 .resolved_transport()
2285 .is_err()
2286 );
2287 assert!(
2288 mcp(None, None, Some(McpTransportKind::Auto))
2289 .resolved_transport()
2290 .is_err()
2291 );
2292 assert!(mcp(None, Some("https://x"), None).stdio_problem().is_none());
2293 let empty = mcp(Some(" "), None, None).stdio_problem().unwrap();
2294 assert!(empty.contains("empty"), "{empty}");
2295 let bare = mcp(Some(r"C:\nodejs\npx.cmd"), None, None);
2296 assert!(bare.stdio_problem().unwrap().contains("package"));
2297 let with_pkg = McpServerConfig {
2298 args: vec!["-y".into(), "@scope/server".into()],
2299 ..bare
2300 };
2301 assert!(with_pkg.stdio_problem().is_none());
2302 assert!(mcp(Some("node"), None, None).stdio_problem().is_none());
2303 let remote = mcp(None, Some("https://x"), Some(McpTransportKind::Http));
2304 assert!(remote.is_remote());
2305 assert!(!mcp(Some("npx"), None, Some(McpTransportKind::Stdio)).is_remote());
2306 }
2307
2308 fn assert_notify_channels() {
2309 let mut notify = NotifyConfig::default();
2310 assert!(!notify.wants(NotifyEvent::TurnDone));
2311 assert!(!notify.has_channel());
2312 notify.on = vec!["turn_done".into(), "need-input".into()];
2313 notify.discord_webhook = Some(" https://discord.com/api/webhooks/1/t ".into());
2314 assert!(notify.wants(NotifyEvent::TurnDone));
2315 assert!(notify.wants(NotifyEvent::NeedInput));
2316 assert!(notify.has_channel());
2317 assert!(notify.enabled_for(NotifyEvent::TurnDone));
2318 assert_eq!(
2319 notify.discord_webhook_url(),
2320 Some("https://discord.com/api/webhooks/1/t")
2321 );
2322 notify.telegram_bot_token = Some(" tok ".into());
2323 notify.telegram_chat_id = Some(" chat ".into());
2324 assert_eq!(notify.telegram_token(), Some("tok"));
2325 assert_eq!(notify.telegram_chat(), Some("chat"));
2326 let overlay = NotifyConfig {
2327 on: vec!["turn_done".into()],
2328 discord_webhook: Some("https://other".into()),
2329 telegram_bot_token: Some("b".into()),
2330 telegram_chat_id: Some("c".into()),
2331 timeout_secs: 12,
2332 desktop: Some(true),
2333 };
2334 let merged = notify.merge_with(&overlay);
2335 assert_eq!(merged.timeout_secs, 12);
2336 assert!(merged.desktop_for(NotifyEvent::TurnDone));
2337 assert!(!merged.desktop_for(NotifyEvent::NeedInput), "not in `on`");
2338 assert_eq!(
2339 merged.merge_with(&NotifyConfig::default()).desktop,
2340 Some(true)
2341 );
2342 assert!(!NotifyConfig::default().desktop_for(NotifyEvent::TurnDone));
2343 }
2344
2345 fn assert_notify_events_and_discord() {
2346 assert_eq!(NotifyEvent::TurnDone.as_str(), "turn_done");
2347 assert_eq!(NotifyEvent::NeedInput.as_str(), "need_input");
2348 assert_eq!(NotifyEvent::parse("done"), Some(NotifyEvent::TurnDone));
2349 assert_eq!(NotifyEvent::parse("input"), Some(NotifyEvent::NeedInput));
2350 assert_eq!(NotifyEvent::parse("nope"), None);
2351 assert_eq!(
2352 parse_notify_on_csv("turn_done, need_input"),
2353 vec!["turn_done", "need_input"]
2354 );
2355
2356 assert_eq!(HookEvent::default(), HookEvent::PreTool);
2357 let hook_json = serde_json::to_string(&HookEvent::PostTool).unwrap();
2358 assert!(hook_json.contains("post_tool"));
2359
2360 assert!(is_discord_webhook_url(
2361 "https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyz"
2362 ));
2363 assert!(is_discord_webhook_url(
2364 "https://discordapp.com/api/webhooks/1/token"
2365 ));
2366 assert!(!is_discord_webhook_url(
2367 "https://example.com/api/webhooks/1/t"
2368 ));
2369 assert!(!is_discord_webhook_url("not-a-url"));
2370 assert!(!is_discord_webhook_url("https://discord.com/api/webhooks/"));
2371 assert_eq!(CONFIG_SCHEMA_VERSION, 1);
2372 }
2373}