Skip to main content

whycodes_config/
types.rs

1//! Config schema types and serde defaults.
2//!
3//! Leaf types (`Message`, `Tool`, sandbox policy) live in `whycodes-core`.
4//! This module owns the user-facing `Config` tree.
5
6use 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
16/// First-run / CLI fallback provider when nothing is configured.
17pub const DEFAULT_PROVIDER: &str = "openrouter";
18/// OpenRouter model id used as the first-run default.
19pub const DEFAULT_MODEL_ID: &str = "anthropic/claude-sonnet-4.5";
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct CommandConfig {
23    /// Override the model for this command
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub model: Option<ModelConfig>,
26
27    /// Override the agent for this command
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub agent: Option<String>,
30
31    /// Override the max turns for this command
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub max_turns: Option<usize>,
34}
35
36/// Main configuration structure
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Config {
39    /// API provider configurations
40    #[serde(default)]
41    pub providers: HashMap<String, ProviderConfig>,
42
43    /// Model configurations
44    #[serde(default)]
45    pub models: HashMap<String, ModelConfig>,
46    /// USD per million tokens, keyed `"provider/model"`. Overrides prices
47    /// learned from the provider's `/v1/models` catalog.
48    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
49    pub pricing: HashMap<String, PriceConfig>,
50
51    /// Agent definitions
52    #[serde(default)]
53    pub agents: Vec<AgentInfo>,
54
55    /// Default agent name
56    #[serde(default = "default_agent")]
57    pub default_agent: String,
58
59    /// Default model
60    #[serde(default)]
61    pub default_model: Option<ModelConfig>,
62
63    /// Per-command configuration overrides
64    #[serde(default)]
65    pub command_configs: HashMap<String, CommandConfig>,
66
67    /// Tools configuration
68    #[serde(default)]
69    pub tools: ToolsConfig,
70
71    /// Session configuration
72    #[serde(default)]
73    pub session: SessionConfig,
74
75    /// TUI configuration
76    #[serde(default)]
77    pub tui: TuiConfig,
78
79    /// General settings
80    #[serde(default)]
81    pub general: GeneralConfig,
82
83    /// On-disk schema version. Missing / `0` is migrated to
84    /// [`CONFIG_SCHEMA_VERSION`] on load.
85    #[serde(default)]
86    pub schema_version: u32,
87
88    /// MCP server configurations
89    #[serde(default)]
90    pub mcp_servers: HashMap<String, McpServerConfig>,
91
92    /// Language-server overlay. Empty `servers` keeps built-in auto-detect.
93    #[serde(default)]
94    pub lsp: LspConfig,
95
96    /// Global tool permissions (allow/ask/deny).
97    /// Merged into each agent; agent-level `permission.rules` wins on conflict.
98    #[serde(default)]
99    pub permission: HashMap<String, PermissionAction>,
100
101    /// Custom slash commands keyed by name (from config.toml + markdown files)
102    #[serde(default)]
103    pub commands: HashMap<String, CustomCommandConfig>,
104
105    /// Shell command risk gating.
106    #[serde(default)]
107    pub security: SecurityConfig,
108
109    /// Pre/post tool hooks (shell commands). Empty by default.
110    #[serde(default)]
111    pub hooks: Vec<HookConfig>,
112
113    /// Cross-session semantic / auto memory.
114    #[serde(default)]
115    pub memory: MemoryConfig,
116
117    /// Parallel multi-agent swarm + file conflict notify.
118    #[serde(default)]
119    pub swarm: SwarmConfig,
120
121    /// Background jobs and lightweight scheduling (FEATURES §11).
122    #[serde(default)]
123    pub automation: AutomationConfig,
124
125    /// Discord / Telegram session notifications (off by default).
126    #[serde(default)]
127    pub notify: NotifyConfig,
128
129    /// Code-slop tripwires (`whycodes slop` / `/slop`). Not a training objective.
130    #[serde(default)]
131    pub slop: SlopConfig,
132
133    /// `websearch` engine choice, fallback order, and the custom endpoint.
134    #[serde(default)]
135    pub websearch: WebSearchConfig,
136
137    /// Extra system-prompt text loaded from `prompts/*.md` (not stored in TOML).
138    #[serde(default, skip)]
139    pub system_prompt_overlays: SystemPromptOverlays,
140}
141
142/// Verbosity / erosion / ΔLOC tripwires. Default `verdict` is `review`, not block.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct SlopConfig {
145    /// Review when verbosity ≥ this (default 0.25).
146    #[serde(default = "default_slop_verbosity")]
147    pub verbosity: f64,
148    /// Review when erosion ≥ this (default 0.50).
149    #[serde(default = "default_slop_erosion")]
150    pub erosion: f64,
151    /// Review when |ΔLOC| of the diff ≥ this (default 800).
152    #[serde(default = "default_slop_delta_loc")]
153    pub delta_loc: i64,
154    /// Block when verbosity ≥ this (default 0.40).
155    #[serde(default = "default_slop_block_verbosity")]
156    pub block_verbosity: f64,
157    /// Block when erosion ≥ this (default 0.75).
158    #[serde(default = "default_slop_block_erosion")]
159    pub block_erosion: f64,
160    /// Block when ΔLOC ≥ this (default 5000).
161    #[serde(default = "default_slop_block_delta_loc")]
162    pub block_delta_loc: i64,
163    /// How many hotspot functions to list (default 8).
164    #[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    /// Overlay non-default fields from `other` (higher-priority layer).
206    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/// Process-local background shell jobs and schedule/loop knobs.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct AutomationConfig {
251    /// Max concurrent background shell jobs. Default 8.
252    #[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/// Concurrent multi-agent work (`swarm` tool).
269///
270/// When `worktrees` is on (default) and the project is a git repo, each worker
271/// runs in a detached git worktree under `.whycodes/swarm/`. Changes merge back
272/// into the main checkout with three-way conflict detection. File claims still
273/// gate same-checkout mode and pre-declared path ownership.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct SwarmConfig {
276    /// Advertise and run the `swarm` tool. Default on.
277    #[serde(default = "default_true")]
278    pub enabled: bool,
279    /// Max concurrent workers (hard-capped at 8 in the agent). Default 4.
280    #[serde(default = "default_swarm_max_agents")]
281    pub max_agents: usize,
282    /// Isolate writers in git worktrees (default on). Falls back to same-checkout
283    /// + file claims when the project is not a git repo.
284    #[serde(default = "default_true")]
285    pub worktrees: bool,
286    /// `worktree` or `checkout`. When set, overrides `worktrees`.
287    #[serde(default)]
288    pub isolation: Option<String>,
289}
290
291impl SwarmConfig {
292    /// Whether to try git worktrees for this run.
293    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/// When a hook runs relative to a tool call.
318#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
319#[serde(rename_all = "snake_case")]
320pub enum HookEvent {
321    /// Before the tool executes. Non-zero exit can block when `block_on_failure`.
322    #[default]
323    PreTool,
324    /// Before every gate: a successful hook whose stdout is a JSON object
325    /// replaces the tool input. Failures keep the input (fail open).
326    PreToolTransform,
327    /// After the tool finishes. Failures are logged only.
328    PostTool,
329    /// Once per session in this process (new or resumed), before its first turn.
330    SessionStart,
331    /// Before each user turn. Non-zero exit can block the turn when `block_on_failure`.
332    UserPromptSubmit,
333    /// After a user turn finishes with a reply.
334    Stop,
335    /// Before the transcript is compacted (auto, overflow, or `/compact`).
336    PreCompact,
337    /// After a `task` / `swarm` subagent finishes. `match` filters the agent type.
338    SubagentStop,
339}
340
341impl HookEvent {
342    /// Config spelling (`pre_tool`, `session_start`, …).
343    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    /// Whether `block_on_failure` can refuse the action (tool call / turn).
357    pub fn can_block(self) -> bool {
358        matches!(self, Self::PreTool | Self::UserPromptSubmit)
359    }
360}
361
362/// A single shell hook attached to tool execution.
363///
364/// ```toml
365/// [[hooks]]
366/// event = "pre_tool"
367/// match = "bash"           # tool name; `*` or `prefix*` / `*suffix`
368/// command = "echo check"
369/// block_on_failure = true  # pre_tool / user_prompt_submit: non-zero exit refuses
370/// feedback = false         # stdout of a successful hook goes to the model
371/// timeout_secs = 30
372/// ```
373///
374/// Environment for the command: `WHYCODES_HOOK_EVENT`, `WHYCODES_TOOL_NAME`,
375/// `WHYCODES_TOOL_INPUT` (JSON), `WHYCODES_TOOL_ID`, `WHYCODES_SESSION_ID`,
376/// `WHYCODES_WORKING_DIR`. Post-tool also sets `WHYCODES_TOOL_IS_ERROR` and
377/// `WHYCODES_TOOL_OUTPUT` (truncated). Lifecycle events add their own
378/// variables (`WHYCODES_PROMPT`, `WHYCODES_RESPONSE`, `WHYCODES_SUBAGENT_*`).
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct HookConfig {
381    /// `pre_tool`, `pre_tool_transform`, `post_tool`, `session_start`,
382    /// `user_prompt_submit`, `stop`, `pre_compact`, or `subagent_stop`.
383    #[serde(default)]
384    pub event: HookEvent,
385
386    /// Tool name pattern. Default `*` (all tools).
387    #[serde(default = "default_hook_match", rename = "match")]
388    pub tool_match: String,
389
390    /// Shell command (run via `sh -c` / `cmd /C`).
391    pub command: String,
392
393    /// When true on `pre_tool` / `user_prompt_submit`, a non-zero exit refuses
394    /// the tool call / the turn.
395    #[serde(default)]
396    pub block_on_failure: bool,
397
398    /// When true, a successful hook's stdout is shown to the model: appended
399    /// to the tool result (`post_tool`) or to the user turn (`session_start`,
400    /// `user_prompt_submit`). Ignored for other events.
401    #[serde(default)]
402    pub feedback: bool,
403
404    /// Kill the hook after this many seconds. Default 30.
405    #[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/// Settings for shell safety: risk classification, OS sandbox, and network
431/// domain policy for HTTP tools.
432///
433/// The risk classifier inspects the command *string* a model asked to run.
434/// The sandbox (when enabled) confines the process that runs it. They stack;
435/// neither replaces the other. See the README security section.
436///
437/// `network_allowlist` / `network_denylist` gate `webfetch`, `websearch`, and
438/// GitHub API tools by host pattern. Shell network stays binary
439/// (`sandbox_network`); domain filtering does not apply inside the shell.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SecurityConfig {
442    /// Lowest risk level that requires confirmation: `caution`, `destructive`
443    /// or `off`. Commands classified `catastrophic` are always refused and
444    /// this setting cannot override that.
445    #[serde(default = "default_risk_threshold")]
446    pub bash_risk_threshold: String,
447
448    /// OS sandbox for `bash` / `shell`: `off` | `workspace` (default).
449    #[serde(default = "default_sandbox_mode")]
450    pub sandbox: String,
451
452    /// When `sandbox = "workspace"`, whether the sandboxed shell may use the
453    /// network. Default `true` so `cargo` / `npm` / `git` keep working.
454    #[serde(default = "default_true_bool")]
455    pub sandbox_network: bool,
456
457    /// When the requested sandbox cannot be applied (no `bwrap`, non-Linux):
458    /// `allow` (default — warn and run on host) or `deny` (fail the tool call).
459    #[serde(default = "default_sandbox_fallback")]
460    pub sandbox_fallback: String,
461
462    /// Host patterns allowed for outbound HTTP tools. Empty (default) means
463    /// unrestricted. See [`NetworkPolicy`].
464    #[serde(default, skip_serializing_if = "Vec::is_empty")]
465    pub network_allowlist: Vec<String>,
466
467    /// Host patterns always blocked for outbound HTTP tools (wins over
468    /// allowlist).
469    #[serde(default, skip_serializing_if = "Vec::is_empty")]
470    pub network_denylist: Vec<String>,
471
472    /// Replace credential-looking values (environment variables named like
473    /// secrets, known token shapes, URL passwords) with placeholders in every
474    /// request sent to a provider, and restore them inside the model's tool
475    /// arguments before execution. Default `true`.
476    #[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    /// Build the HTTP-tool network policy from config lists.
512    pub fn network_policy(&self) -> NetworkPolicy {
513        NetworkPolicy {
514            allowlist: self.network_allowlist.clone(),
515            denylist: self.network_denylist.clone(),
516        }
517    }
518
519    /// Resolved OS sandbox policy for shell tools.
520    pub fn sandbox_settings(&self) -> SandboxSettings {
521        SandboxSettings::from_raw(&self.sandbox, self.sandbox_network, &self.sandbox_fallback)
522    }
523}
524
525/// Custom slash command definition (`/commands`)
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct CustomCommandConfig {
528    /// Prompt template (`$ARGUMENTS`, `$1`, `$2`, …)
529    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    /// Force subagent invocation
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub subtask: Option<bool>,
539}
540
541/// How to reach an MCP server.
542///
543/// - `stdio` — spawn a local process (default when `command` is set)
544/// - `http` — Streamable HTTP (spec 2025-03-26+); preferred remote transport
545/// - `sse` — legacy HTTP+SSE (spec 2024-11-05)
546/// - `auto` — for URL endpoints: try Streamable HTTP, fall back to legacy SSE
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
548#[serde(rename_all = "lowercase")]
549pub enum McpTransportKind {
550    #[default]
551    Stdio,
552    /// Streamable HTTP (single MCP endpoint, POST/GET + optional SSE)
553    Http,
554    /// Legacy dual-endpoint HTTP+SSE transport
555    Sse,
556    /// Probe Streamable HTTP first, then fall back to legacy SSE
557    Auto,
558}
559
560/// Language-server overlay stored in `config.toml` under `[lsp]`.
561///
562/// Empty `servers` keeps built-in auto-detect. Named entries overlay matching
563/// built-ins (or register a new server). Nested objects replace.
564#[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    /// Wait (ms) for language-server diagnostics after `edit` / `write`;
573    /// `0` turns it off. Unset uses the runtime default.
574    #[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/// `[pricing."provider/model"]`: USD per million tokens.
585#[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/// One language-server overlay entry.
596#[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    /// Convert this TOML overlay into JSON-valued runtime settings.
624    ///
625    /// The `lsp` crate owns detection and built-in servers; this is the user
626    /// overlay only (empty `servers` still auto-detects).
627    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/// JSON-valued overlay sent to `whycodes-lsp` (avoids a config → lsp edge).
638#[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/// MCP server definition stored in config.
672///
673/// Stdio: `command` + `args`. Remote: `url` (+ optional `type` / `headers`).
674#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct McpServerConfig {
676    /// Explicit transport (`stdio` | `http` | `sse` | `auto`).
677    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
678    pub transport: Option<McpTransportKind>,
679    /// Command to spawn (stdio transport)
680    #[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    /// Remote MCP endpoint URL (Streamable HTTP or legacy SSE)
689    #[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    /// Why this stdio entry cannot work, before anything is spawned: an
720    /// empty `command`, or a package runner (`npx`, `uvx`, …) given no
721    /// package in `args`, which waits for input until the connect times out.
722    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        // Both separators on every OS: a Windows path in a shared config.
728        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, // Loaded from prompts/build.txt at runtime
768            temperature: None,
769            top_p: None,
770        };
771
772        // Plan: primary (Ctrl+T), file edits denied
773        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, // prompts/plan.txt
808            temperature: None,
809            top_p: None,
810        };
811
812        // Ask: primary read-only Q&A (no implementation)
813        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, // prompts/ask.txt
852            temperature: None,
853            top_p: None,
854        };
855
856        // WhyCodes: primary operator agent for WhyCodes itself. Path rules
857        // deny every write except the project `.whycodes/` overlay (the
858        // agent adds the resolved global `config.toml` at runtime); shell
859        // rules allow only `whycodes config …` / `whycodes debug …`.
860        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, // prompts/whycodes.txt
905            temperature: None,
906            top_p: None,
907        };
908
909        // Explore: subagent — fast read-only codebase search
910        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        // General: subagent with full tool access except todo
948        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        // Scout: subagent for external docs / dependency research
972        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(), // limited: clone/inspect only ideally
986                ]),
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/// Provider / model system-prompt extras loaded from markdown files.
1046///
1047/// File layout (global `prompts/` next to `config.toml`, project
1048/// `.whycodes/prompts/` — project keys overwrite):
1049///
1050/// - `prompts/<provider>.md` — every model on that provider
1051/// - `prompts/<provider>/<model>.md` — that provider+model
1052/// - `prompts/models/<model>.md` — that model id on any provider
1053#[derive(Debug, Clone, Default, PartialEq, Eq)]
1054pub struct SystemPromptOverlays {
1055    /// Keyed by lowercase provider id (`xai`).
1056    pub providers: HashMap<String, String>,
1057    /// Keyed by lowercase `provider/model` or bare `model`.
1058    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    /// Provider body (if any) then the most specific model body.
1067    ///
1068    /// Model match order: `provider/model`, then bare `model`.
1069    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/// Cross-session semantic / auto memory (Claude-style index + local recall).
1140#[derive(Debug, Clone, Serialize, Deserialize)]
1141pub struct MemoryConfig {
1142    /// Master switch. Default on (cheap hashing embedder; no ONNX).
1143    #[serde(default = "default_true")]
1144    pub enabled: bool,
1145    /// Auto-inject top-k semantic hits for the current user message.
1146    #[serde(default = "default_true")]
1147    pub auto_inject: bool,
1148    /// Post-turn auto-retain (heuristic and/or LLM). Default on.
1149    #[serde(default = "default_true")]
1150    pub auto_retain: bool,
1151    /// Also call a small LLM to extract durable facts when heuristic finds none
1152    /// (or always when `retain_llm_always`). Default on.
1153    #[serde(default = "default_true")]
1154    pub retain_llm: bool,
1155    /// Run LLM retain even if heuristic already found facts.
1156    #[serde(default)]
1157    pub retain_llm_always: bool,
1158    /// Run retain every N user turns (1 = every turn).
1159    #[serde(default = "default_retain_every_n")]
1160    pub retain_every_n: usize,
1161    /// Max facts retained per turn.
1162    #[serde(default = "default_retain_max_facts")]
1163    pub retain_max_facts: usize,
1164    /// Max lines of MEMORY.md loaded into every session (Claude parity: 200).
1165    #[serde(default = "default_memory_index_lines")]
1166    pub max_index_lines: usize,
1167    /// Max bytes of MEMORY.md loaded into every session (Claude parity: 25 KiB).
1168    #[serde(default = "default_memory_index_bytes")]
1169    pub max_index_bytes: usize,
1170    /// Max recalled facts injected per turn.
1171    #[serde(default = "default_memory_top_k")]
1172    pub recall_top_k: usize,
1173    /// Minimum cosine similarity for a fact to be recalled.
1174    #[serde(default = "default_memory_min_score")]
1175    pub recall_min_score: f32,
1176    /// Token budget for recalled facts (chars ≈ tokens × 4).
1177    #[serde(default = "default_memory_token_budget")]
1178    pub recall_token_budget: usize,
1179    /// Hashing embedder dimension (stored BLOB width).
1180    #[serde(default = "default_memory_embed_dim")]
1181    pub embed_dim: usize,
1182    /// `user` (data_dir, default) or `project` (`.whycodes/memory`, git-shareable).
1183    #[serde(default = "default_memory_scope")]
1184    pub scope: String,
1185    /// `hash` (default) or `onnx` (MiniLM; needs `--features onnx`).
1186    #[serde(default = "default_memory_backend")]
1187    pub embed_backend: String,
1188    /// Inject code-index hits when available.
1189    #[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    /// Give subagents their own memory bank (`project::agent_name`).
1196    #[serde(default = "default_true")]
1197    pub subagent_banks: bool,
1198    /// On session start, ensure a code index exists (skip if already indexed).
1199    #[serde(default = "default_true")]
1200    pub auto_index: bool,
1201    /// Max source files to walk when auto-indexing.
1202    #[serde(default = "default_auto_index_files")]
1203    pub auto_index_max_files: usize,
1204    /// Max chunks when auto-indexing.
1205    #[serde(default = "default_auto_index_chunks")]
1206    pub auto_index_max_chunks: usize,
1207    /// Inject related past-session turns.
1208    #[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    /// Cap the fact bank after retain.
1215    #[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/// Settings for the interactive `question` tool (questionnaire).
1312#[derive(Debug, Clone, Serialize, Deserialize)]
1313pub struct QuestionToolConfig {
1314    /// When true, unanswered questionnaires fail after [`Self::timeout_secs`].
1315    #[serde(default = "default_question_timeout_enabled")]
1316    pub timeout_enabled: bool,
1317    /// Seconds to wait for answers when timeout is enabled (default 1800 = 30 min).
1318    #[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    /// Interactive questionnaire (`question` tool).
1358    #[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    /// Long-running `bash` auto-detach (issue #140).
1365    #[serde(default)]
1366    pub bash: BashConfig,
1367    /// Project formatter after `edit` / `write`.
1368    #[serde(default)]
1369    pub format: FormatConfig,
1370}
1371
1372/// `[tools.format]` — run the project's own formatter (rustfmt, prettier,
1373/// ruff, black, gofmt) on a file `edit` / `write` changed, when the project
1374/// shows it uses one.
1375#[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/// `[tools.bash]` — auto-background a still-running shell after idle.
1388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1389pub struct BashConfig {
1390    /// Detach a still-running command after [`Self::auto_background_after_secs`].
1391    #[serde(default = "default_true")]
1392    pub auto_background: bool,
1393    /// Seconds before a non-catastrophic foreground `bash` is detached (default 20).
1394    #[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
1419/// Current `config.toml` schema. Bump when a load-time rewrite is required.
1420pub 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    /// Share of the model's context window to keep free. Auto-compaction
1429    /// fires at the lower of `compaction_threshold` and
1430    /// `window × (100 − p) / 100`. Unset uses `compaction_threshold` alone.
1431    #[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    /// Auto-name sessions: project placeholder → first-message heuristic →
1436    /// small-model refine after the first turn. Default on.
1437    #[serde(default = "default_true")]
1438    pub auto_title: bool,
1439    /// Optional title model: `provider/model` or bare model id (same provider).
1440    /// Empty = pick a known small/fast sibling (haiku / mini / flash).
1441    #[serde(default, skip_serializing_if = "Option::is_none")]
1442    pub title_model: Option<String>,
1443    /// Tools advertised to the model: `core` (default, ~12 tools, faster TTFT)
1444    /// or `full` (every built-in including github/web/lsp).
1445    #[serde(default = "default_tool_profile")]
1446    pub tool_profile: String,
1447    /// Anthropic-style prompt cache policy: `auto` (default) or `none`.
1448    #[serde(default = "default_prompt_cache")]
1449    pub prompt_cache: String,
1450    /// Keep the prompt cache warm for this many idle minutes after a turn
1451    /// (TUI). Each keep-alive is a paid request that re-reads the cached
1452    /// prefix; `0` (default) turns it off.
1453    #[serde(default)]
1454    pub cache_warm_minutes: u64,
1455    /// Turns `/undo` can step back. The oldest entries drop first past the
1456    /// cap; `0` = unlimited (memory grows with every turn).
1457    #[serde(default = "default_undo_depth")]
1458    pub undo_depth: usize,
1459    /// Fast model for trivial chat (`provider/model` or bare model id).
1460    /// Empty = auto-pick small sibling of the session model (haiku/mini/flash).
1461    #[serde(default, skip_serializing_if = "Option::is_none")]
1462    pub model_fast: Option<String>,
1463    /// Turns a `/goal` may take before it pauses (counted from when it was
1464    /// set or last resumed); `0` = no cap.
1465    #[serde(default = "default_goal_max_turns")]
1466    pub goal_max_turns: u32,
1467    /// Model that checks `/goal` (`provider/model` or bare id). Empty = the
1468    /// session model.
1469    #[serde(default, skip_serializing_if = "Option::is_none")]
1470    pub goal_model: Option<String>,
1471    /// Cheap model for `task` / `swarm` fan-out (`provider/model` or bare id).
1472    /// Empty = auto-pick the small sibling of the session model.
1473    #[serde(default, skip_serializing_if = "Option::is_none")]
1474    pub model_smol: Option<String>,
1475    /// Model used while the `plan` agent is active (`provider/model` or bare id).
1476    #[serde(default, skip_serializing_if = "Option::is_none")]
1477    pub model_plan: Option<String>,
1478    /// Plan on the strong model, then hand the rest of the turn to the cheap
1479    /// one (`model_smol`, else the small sibling) after the first edit.
1480    /// Unset = off.
1481    #[serde(default, skip_serializing_if = "Option::is_none")]
1482    pub prewalk: Option<bool>,
1483    /// First-token race partner: `off` (default), `auto` (small sibling), or
1484    /// `provider/model`. When set, a slow primary TTFT starts the partner.
1485    #[serde(default = "default_model_race")]
1486    pub model_race: String,
1487    /// How long to wait for the primary first token before opening `model_race`.
1488    #[serde(default = "default_race_after_ms")]
1489    pub race_after_ms: u64,
1490    /// `provider/model` refs tried in order when the active model fails to
1491    /// open a stream (rate limit after named credentials, 5xx, network,
1492    /// timeout). Each needs a key: same provider, config `api_key`, or
1493    /// `<PROVIDER>_API_KEY`.
1494    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1495    pub fallback_models: Vec<String>,
1496    /// Re-send a step whose stream drops mid-response (network, timeout,
1497    /// 5xx, truncated body) for up to this many seconds after the first
1498    /// drop. The partial draft is discarded. Default 60; `0` turns it off.
1499    #[serde(default = "default_stream_retry_secs")]
1500    pub stream_retry_secs: u64,
1501    /// Process-local text-only response cache: `auto` (default) or `off`.
1502    #[serde(default = "default_response_cache")]
1503    pub response_cache: String,
1504    /// Open the provider connection while the prompt is being typed, so the
1505    /// first request skips DNS/TCP/TLS setup. Sends no credentials or prompt.
1506    #[serde(default = "default_true")]
1507    pub preconnect: bool,
1508    /// Heuristic intent posture for build turns: `auto` (default), `off`, or `always`.
1509    ///
1510    /// When enabled, high-confidence question/plan signals inject an ephemeral
1511    /// `<whycodes_intent>` block into the LLM request (not session history) so
1512    /// the model answers or plans instead of over-eager edits. Hard modes
1513    /// (`ask` / `plan`) still enforce tool denylists regardless of this flag.
1514    #[serde(default = "default_intent_guidance")]
1515    pub intent_guidance: String,
1516    /// Full-replace compact (manual `/compact` and auto over threshold):
1517    /// `"auto"` (default) asks the session model for a structured summary;
1518    /// `"off"` is the local stub only.
1519    #[serde(default = "default_compaction_llm")]
1520    pub compaction_llm: String,
1521    /// Which compaction runs first: `"auto"` uses the provider's own
1522    /// compaction when the model has it (Anthropic on-demand compaction,
1523    /// beta) and falls back to full-replace; `"local"` (default) is
1524    /// full-replace only.
1525    #[serde(default = "default_compaction_method")]
1526    pub compaction_method: String,
1527    /// OpenAI-compat / xAI `reasoning_effort`: `low` | `medium` | `high` | `xhigh`.
1528    /// Empty = family default (`medium` when the model supports effort).
1529    /// `xhigh` (Max) is grok-4.6+ and GLM-5+ only; a missing level clamps to
1530    /// `high` (GLM-5+ has no `medium` and defaults to `high`).
1531    #[serde(default, skip_serializing_if = "Option::is_none")]
1532    pub reasoning_effort: Option<String>,
1533    /// Standalone prose keywords (`ultrathink`, `orchestrate`) that inject a
1534    /// hidden per-turn instruction. Default on.
1535    #[serde(default)]
1536    pub magic_keywords: MagicKeywordsConfig,
1537    /// Regexes matched against streamed assistant text. A hit aborts the
1538    /// current LLM request and injects `hint` before the next step.
1539    #[serde(default)]
1540    pub stream_rules: Vec<StreamRuleConfig>,
1541    /// How `--format json` / `stream-json` treat permission `ask`.
1542    ///
1543    /// Unset = deny (fail-closed). `allow` restores the old auto-approve
1544    /// behaviour; `--approve-tools` on the CLI also forces allow. TUI and
1545    /// `--plain` ignore this — they still prompt (or follow `WHYCODES_AUTO_*`).
1546    #[serde(default, skip_serializing_if = "Option::is_none")]
1547    pub headless_ask: Option<HeadlessAskMode>,
1548}
1549
1550/// Structured-run policy for permission `ask` (issue #122).
1551#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1552#[serde(rename_all = "kebab-case")]
1553pub enum HeadlessAskMode {
1554    /// Deny the tool, stamp `denied:headless`, do not execute.
1555    Deny,
1556    /// Auto-allow (legacy CI behaviour). Same as `--approve-tools`.
1557    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/// One time-traveling stream rule (abort + inject).
1578#[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    /// What the pattern is matched against. Empty = `["text"]`.
1587    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1588    pub scope: Vec<StreamRuleScope>,
1589    /// `tool_args` scope only: tool names the rule watches. Empty = every tool.
1590    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1591    pub tools: Vec<String>,
1592    /// How often the rule may fire. Default `always` (every matching draft).
1593    #[serde(default, skip_serializing_if = "StreamRuleRepeat::is_always")]
1594    pub repeat: StreamRuleRepeat,
1595}
1596
1597impl StreamRuleConfig {
1598    /// `scope`, or `["text"]` when unset.
1599    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/// Streamed channel a stream rule watches.
1609#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1610#[serde(rename_all = "snake_case")]
1611pub enum StreamRuleScope {
1612    /// Assistant reply text.
1613    Text,
1614    /// Accumulated thinking text of the step.
1615    Thinking,
1616    /// Each tool call's JSON arguments as they stream.
1617    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/// `repeat = "once" | "always" | { after_turns = N }`.
1640#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1641#[serde(rename_all = "snake_case")]
1642pub enum StreamRuleRepeat {
1643    /// Fire at most once per session.
1644    Once,
1645    /// Fire on every match.
1646    #[default]
1647    Always,
1648    /// Fire again once N user turns have passed since the last hit.
1649    AfterTurns(u32),
1650}
1651
1652impl StreamRuleRepeat {
1653    pub fn is_always(&self) -> bool {
1654        *self == Self::Always
1655    }
1656
1657    /// `once`, `always`, `after_turns = N`, `{ after_turns: N }`, or a bare `N`.
1658    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    /// Whether a rule last fired at `last` may fire on user turn `turn`.
1678    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/// Per-turn hidden notices when the user writes a magic keyword in prose.
1696#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1697pub struct MagicKeywordsConfig {
1698    /// Master switch. Default on.
1699    #[serde(default = "default_true")]
1700    pub enabled: bool,
1701    /// `ultrathink` — careful multi-step reasoning + highest thinking effort.
1702    #[serde(default = "default_true")]
1703    pub ultrathink: bool,
1704    /// `orchestrate` — parallel subagent contract until the request is done.
1705    #[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/// Session notifications over Discord Incoming Webhooks, Telegram bots, and/or
1804/// the terminal (desktop notification via OSC 9 / OSC 777).
1805///
1806/// Off until `on` is non-empty **and** at least one channel is configured.
1807/// Secrets should live in env (`WHYCODES_NOTIFY_*`) or `${VAR}` substitution,
1808/// not in a committed project config.
1809///
1810/// ```toml
1811/// [notify]
1812/// on = ["turn_done", "need_input"]
1813/// discord_webhook = "${DISCORD_WEBHOOK_URL}"
1814/// telegram_bot_token = "${TELEGRAM_BOT_TOKEN}"
1815/// telegram_chat_id = "${TELEGRAM_CHAT_ID}"
1816/// desktop = true   # TUI only, while the terminal window is not focused
1817/// ```
1818#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1819pub struct NotifyConfig {
1820    /// Events that fire a message. Empty = disabled.
1821    /// Known values: `turn_done`, `need_input`.
1822    #[serde(default)]
1823    pub on: Vec<String>,
1824    /// Discord Incoming Webhook URL. Empty / omitted = skip Discord.
1825    #[serde(default, skip_serializing_if = "Option::is_none")]
1826    pub discord_webhook: Option<String>,
1827    /// Telegram bot token from BotFather. Empty / omitted = skip Telegram.
1828    #[serde(default, skip_serializing_if = "Option::is_none")]
1829    pub telegram_bot_token: Option<String>,
1830    /// Telegram chat / group / channel id (`sendMessage` `chat_id`).
1831    #[serde(default, skip_serializing_if = "Option::is_none")]
1832    pub telegram_chat_id: Option<String>,
1833    /// HTTP timeout per channel. Default 8s, clamped 1–60.
1834    #[serde(default = "default_notify_timeout_secs")]
1835    pub timeout_secs: u64,
1836    /// Desktop notification from the TUI through the terminal (OSC 9 / 777),
1837    /// sent only while the terminal window is not focused. Omitted = off.
1838    #[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    /// True when this event is listed in `on` (case-insensitive).
1861    pub fn wants(&self, event: NotifyEvent) -> bool {
1862        self.on.iter().any(|e| NotifyEvent::parse(e) == Some(event))
1863    }
1864
1865    /// Discord webhook after trim; `None` if unset/blank.
1866    pub fn discord_webhook_url(&self) -> Option<&str> {
1867        nonempty_str(self.discord_webhook.as_deref())
1868    }
1869
1870    /// Telegram bot token after trim; `None` if unset/blank.
1871    pub fn telegram_token(&self) -> Option<&str> {
1872        nonempty_str(self.telegram_bot_token.as_deref())
1873    }
1874
1875    /// Telegram chat id after trim; `None` if unset/blank.
1876    pub fn telegram_chat(&self) -> Option<&str> {
1877        nonempty_str(self.telegram_chat_id.as_deref())
1878    }
1879
1880    /// True when Discord and/or a complete Telegram pair is configured.
1881    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    /// True when this event should actually be sent.
1887    pub fn enabled_for(&self, event: NotifyEvent) -> bool {
1888        self.wants(event) && self.has_channel()
1889    }
1890
1891    /// True when the TUI should raise a desktop notification for this event.
1892    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/// `[websearch]` — which engine the `websearch` tool queries.
1921///
1922/// ```toml
1923/// [websearch]
1924/// engine = "custom"          # serpapi | duckduckgo | custom
1925/// fallback = ["duckduckgo"]  # tried in order when the primary fails
1926/// [websearch.custom]
1927/// endpoint = "https://search.example.com/api"
1928/// header_env = { "X-Api-Key" = "SEARCH_API_KEY" }
1929/// ```
1930#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1931pub struct WebSearchConfig {
1932    /// `serpapi`, `duckduckgo` or `custom`. Omitted = SerpAPI when
1933    /// `SERPAPI_API_KEY` is set, else DuckDuckGo (the behaviour before this key).
1934    #[serde(default, skip_serializing_if = "Option::is_none")]
1935    pub engine: Option<String>,
1936    /// Engines tried in order when the primary returns an error.
1937    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1938    pub fallback: Vec<String>,
1939    /// The `custom` engine. A layer that sets this table replaces the whole table.
1940    #[serde(default, skip_serializing_if = "Option::is_none")]
1941    pub custom: Option<WebSearchCustomConfig>,
1942}
1943
1944impl WebSearchConfig {
1945    /// Remove what a project layer may not set: the `custom` table (its
1946    /// endpoint, headers and `header_env` could send env secrets to a host the
1947    /// repository chose) and `custom` as `engine` or in `fallback`. Returns
1948    /// the dropped keys.
1949    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/// `[websearch.custom]` — a JSON search API mapped with JSON Pointers.
1986#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1987pub struct WebSearchCustomConfig {
1988    /// Request URL. The network allowlist applies to its host.
1989    pub endpoint: String,
1990    /// `GET` (query string) or `POST` (JSON body). Default `GET`.
1991    #[serde(default = "default_websearch_method")]
1992    pub method: String,
1993    /// Parameter that carries the query. Default `q`.
1994    #[serde(default = "default_websearch_query_param")]
1995    pub query_param: String,
1996    /// Parameter that carries the result count. Omitted = not sent.
1997    #[serde(default, skip_serializing_if = "Option::is_none")]
1998    pub count_param: Option<String>,
1999    /// Extra static parameters sent with every request.
2000    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2001    pub params: BTreeMap<String, String>,
2002    /// Static request headers.
2003    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2004    pub headers: BTreeMap<String, String>,
2005    /// Header name → environment variable that holds its value (never stored).
2006    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2007    pub header_env: BTreeMap<String, String>,
2008    /// JSON Pointer to the result array. Default `/results`.
2009    #[serde(default = "default_websearch_results_pointer")]
2010    pub results_pointer: String,
2011    /// JSON Pointer (inside one result) to its title. Default `/title`.
2012    #[serde(default = "default_websearch_title_pointer")]
2013    pub title_pointer: String,
2014    /// JSON Pointer (inside one result) to its URL. Default `/url`.
2015    #[serde(default = "default_websearch_url_pointer")]
2016    pub url_pointer: String,
2017    /// JSON Pointer (inside one result) to its snippet. Default `/snippet`.
2018    #[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/// Lifecycle events that can fire a session notification.
2042#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2043pub enum NotifyEvent {
2044    /// Parent agent turn finished (success or cancel). Subagents do not fire this.
2045    TurnDone,
2046    /// Interactive wait: permission dialog, `question` tool, or SDK/daemon ask.
2047    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
2088/// Discord Incoming Webhook host allowlist (no arbitrary POST target).
2089pub 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    // Prefix already required above; `HTTPS://` is rejected by `starts_with`.
2102    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    /// Idle follow-up suggestions: `"off"` (default) or `"idle"`.
2115    #[serde(default = "default_prompt_suggestions")]
2116    pub prompt_suggestions: String,
2117    /// Show the sidebar when the TUI opens.
2118    #[serde(default)]
2119    pub show_sidebar: bool,
2120    /// Per-agent (and `model`) colors for prompt chrome.
2121    ///
2122    /// Values are `#rgb` / `#rrggbb`, a theme role (`accent`, `success`,
2123    /// `info`, `warning`, `error`, `primary`, `secondary`, `thinking`, `dim`),
2124    /// or an ANSI name (`red`, `green`, …).
2125    ///
2126    /// ```toml
2127    /// [tui.agent_colors]
2128    /// build = "#7aa2f7"
2129    /// plan = "accent"
2130    /// ask = "info"
2131    /// model = "secondary"
2132    /// ```
2133    #[serde(default)]
2134    pub agent_colors: HashMap<String, String>,
2135    /// First-launch TUI asked for an OpenRouter API key and the user skipped.
2136    /// Do not re-prompt on later launches (`/connect` and Ctrl+P stay available).
2137    #[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    /// Check GitHub for a newer release when an interactive TUI session starts
2164    /// (home-screen confirm; never a silent replace).
2165    #[serde(default = "default_true")]
2166    pub auto_update: bool,
2167    /// Session-level overlay for when to interrupt (`auto` | `important` | `manual`).
2168    /// Omitted = `auto`: auto-answer questions and auto-allow permission asks.
2169    #[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    /// Expand `$ARGUMENTS`, `$1`… placeholders.
2194    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        // shell injection: !`cmd` — run and replace (best-effort, sync)
2202        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}