Skip to main content

aft/
config.rs

1use std::collections::{HashMap, HashSet};
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6pub(crate) const DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 3_000;
7/// `auto`: on the production lane (Bionic / Qwen3-Embedding-0.6B) the model-card
8/// instruction on queries only left concept MRR and exact recall flat within noise,
9/// raised real-query MRR 0.182 -> 0.189, and cut the dense lane's no-vocabulary
10/// admission from 59% to 49% of top-10 rows (docs/investigations/query-instruction-ab-2026-09.md).
11/// Document vectors are untouched, so flipping this never rebuilds an index.
12pub const DEFAULT_SEMANTIC_QUERY_INSTRUCTION: &str = "auto";
13/// Verbatim retrieval task recommended by the Qwen3-Embedding model card:
14/// <https://huggingface.co/Qwen/Qwen3-Embedding-0.6B#usage-tips>
15pub const QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK: &str =
16    "Given a web search query, retrieve relevant passages that answer the query";
17pub const QWEN3_EMBEDDING_CODE_SEARCH_TASK: &str =
18    "Given a code search query, retrieve relevant source code, symbols, and documentation";
19pub(crate) const MIN_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 500;
20pub(crate) const MAX_SEMANTIC_QUERY_TIMEOUT_MS: u64 = 15_000;
21pub(crate) const DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 120_000;
22pub(crate) const MIN_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000;
23pub(crate) const MAX_INSPECT_DIAGNOSTICS_TIMEOUT_MS: u64 = 600_000;
24pub const DEFAULT_BASH_WATCH_SYNC_MAX_MS: u64 = 120_000;
25pub const MIN_BASH_WATCH_SYNC_MAX_MS: u64 = 1_000;
26pub const MAX_BASH_WATCH_SYNC_MAX_MS: u64 = 1_800_000;
27
28/// Unbound-root artifact eviction idle window, in minutes.
29pub const DEFAULT_IDLE_ROOT_TTL_MINUTES: u32 = 30;
30pub const MIN_IDLE_ROOT_TTL_MINUTES: u32 = 5;
31pub const MAX_IDLE_ROOT_TTL_MINUTES: u32 = 30;
32/// Language-server idle window, in minutes. Independent of artifact eviction.
33pub const DEFAULT_IDLE_LSP_TTL_MINUTES: u32 = 10;
34pub const MIN_IDLE_LSP_TTL_MINUTES: u32 = 1;
35pub const MAX_IDLE_LSP_TTL_MINUTES: u32 = 10;
36
37const fn default_semantic_query_timeout_ms() -> u64 {
38    DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS
39}
40
41const fn default_inspect_diagnostics_timeout_ms() -> u64 {
42    DEFAULT_INSPECT_DIAGNOSTICS_TIMEOUT_MS
43}
44
45const fn default_bash_detach_on_user_message() -> bool {
46    true
47}
48
49pub(crate) const fn default_bash_watch_sync_max_ms() -> u64 {
50    DEFAULT_BASH_WATCH_SYNC_MAX_MS
51}
52
53use crate::harness::Harness;
54
55/// Idle reclamation windows for unbound-root artifacts and language servers.
56///
57/// `root_ttl_minutes` controls when an unbound root's indexes are evicted.
58/// `lsp_ttl_minutes` shuts down that root's language servers after no request,
59/// even while the root is still bound. Both rebuild/respawn on the next request.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(default)]
62pub struct IdleConfig {
63    pub root_ttl_minutes: u32,
64    pub lsp_ttl_minutes: u32,
65}
66
67impl Default for IdleConfig {
68    fn default() -> Self {
69        Self {
70            root_ttl_minutes: DEFAULT_IDLE_ROOT_TTL_MINUTES,
71            lsp_ttl_minutes: DEFAULT_IDLE_LSP_TTL_MINUTES,
72        }
73    }
74}
75
76impl IdleConfig {
77    pub fn root_ttl(&self) -> std::time::Duration {
78        std::time::Duration::from_secs(u64::from(self.root_ttl_minutes) * 60)
79    }
80
81    pub fn lsp_ttl(&self) -> std::time::Duration {
82        std::time::Duration::from_secs(u64::from(self.lsp_ttl_minutes) * 60)
83    }
84}
85
86/// The durable index families that a standing root may maintain.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum IndexKind {
90    Search,
91    Semantic,
92    Callgraph,
93}
94
95impl IndexKind {
96    pub const ALL: [Self; 3] = [Self::Search, Self::Semantic, Self::Callgraph];
97
98    pub const fn as_str(self) -> &'static str {
99        match self {
100            Self::Search => "search",
101            Self::Semantic => "semantic",
102            Self::Callgraph => "callgraph",
103        }
104    }
105
106    pub fn from_name(name: &str) -> Option<Self> {
107        match name {
108            "search" => Some(Self::Search),
109            "semantic" => Some(Self::Semantic),
110            "callgraph" => Some(Self::Callgraph),
111            _ => None,
112        }
113    }
114}
115
116/// One user-configured root whose literal path spelling is its durable identity.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct IndexRootConfig {
119    /// The unmodified Unicode spelling supplied in `index.roots[].path`.
120    pub path: String,
121    /// Normalized selected index families, in fixed [`IndexKind::ALL`] order.
122    pub indexes: Vec<IndexKind>,
123}
124
125/// User-tier standing-index configuration.
126#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(default)]
128pub struct IndexConfig {
129    pub roots: Vec<IndexRootConfig>,
130}
131
132/// Expand the supported `~` forms before validating that a configured root is
133/// absolute. The literal string is retained separately for durable identity.
134pub fn expand_index_root_path(
135    path: &str,
136    home: Option<&std::path::Path>,
137) -> Result<PathBuf, String> {
138    let expanded = if path == "~" {
139        home.ok_or_else(|| {
140            "index.roots path uses ~ but no home directory is available".to_string()
141        })?
142        .to_path_buf()
143    } else if let Some(remainder) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
144        home.ok_or_else(|| {
145            "index.roots path uses ~ but no home directory is available".to_string()
146        })?
147        .join(remainder)
148    } else {
149        PathBuf::from(path)
150    };
151
152    if !expanded.is_absolute() {
153        return Err(format!(
154            "index.roots path must be absolute after ~ expansion: {path:?}"
155        ));
156    }
157    Ok(expanded)
158}
159
160/// Semantic backend selected by the currently resolved runtime configuration.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum SemanticBackend {
164    Fastembed,
165    #[serde(rename = "openai_compatible")]
166    OpenAiCompatible,
167    Ollama,
168    Synapse,
169}
170
171impl SemanticBackend {
172    pub const fn as_str(&self) -> &'static str {
173        match self {
174            Self::Fastembed => "fastembed",
175            Self::OpenAiCompatible => "openai_compatible",
176            Self::Ollama => "ollama",
177            Self::Synapse => "synapse",
178        }
179    }
180
181    pub fn from_name(name: &str) -> Option<Self> {
182        match name {
183            "fastembed" => Some(Self::Fastembed),
184            "openai_compatible" => Some(Self::OpenAiCompatible),
185            "ollama" => Some(Self::Ollama),
186            "synapse" => Some(Self::Synapse),
187            _ => None,
188        }
189    }
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct SemanticBackendConfig {
194    pub backend: SemanticBackend,
195    pub model: String,
196    pub base_url: Option<String>,
197    pub api_key_env: Option<String>,
198    /// Per-request floor for background index builds. HTTP batches scale this
199    /// deadline from a successful per-item latency EMA; interactive queries use
200    /// `query_timeout_ms` instead.
201    pub timeout_ms: u64,
202    /// Deadline for one interactive query embedding request. Unlike `timeout_ms`,
203    /// this budget never controls background index builds.
204    #[serde(default = "default_semantic_query_timeout_ms")]
205    pub query_timeout_ms: u64,
206    /// Query-only task instruction. `auto` selects a model-family recipe, `off`
207    /// sends bare queries, and any other value is used as literal task text.
208    #[serde(default = "default_semantic_query_instruction")]
209    pub query_instruction: String,
210    pub max_batch_size: usize,
211    /// Optional whole-row input budget for remote embedding backends. When absent,
212    /// chunk construction retains the legacy MiniLM-era limits.
213    #[serde(default, skip_serializing_if = "Option::is_none")]
214    pub max_input_tokens: Option<usize>,
215    /// Maximum number of project files to semantically index. Guards local
216    /// fastembed memory (model + embeddings + batch buffers) on huge project
217    /// roots; remote backends that embed server-side can raise it freely.
218    pub max_files: usize,
219    /// User-tier SubC connection file used only by the Synapse embedding backend.
220    #[serde(skip)]
221    pub subc_connection_file: Option<PathBuf>,
222    /// Project-root and harness identity used to route Synapse management calls
223    /// to the correct project and execution environment.
224    #[serde(skip)]
225    pub route_project_root: Option<PathBuf>,
226    #[serde(skip)]
227    pub route_harness: Option<String>,
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
231pub struct UserServerDef {
232    pub id: String,
233    pub extensions: Vec<String>,
234    pub binary: String,
235    pub args: Vec<String>,
236    pub root_markers: Vec<String>,
237    pub env: HashMap<String, String>,
238    pub initialization_options: Option<serde_json::Value>,
239    pub disabled: bool,
240}
241
242fn default_semantic_query_instruction() -> String {
243    DEFAULT_SEMANTIC_QUERY_INSTRUCTION.to_string()
244}
245
246impl SemanticBackendConfig {
247    pub fn resolved_query_instruction(&self) -> Option<&str> {
248        if self.backend == SemanticBackend::Fastembed {
249            return None;
250        }
251        match self.query_instruction.as_str() {
252            "off" => None,
253            "auto" if self.model.to_ascii_lowercase().contains("qwen3-embedding") => {
254                Some(QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK)
255            }
256            "auto" => None,
257            literal => Some(literal),
258        }
259    }
260}
261
262impl Default for SemanticBackendConfig {
263    fn default() -> Self {
264        Self {
265            backend: SemanticBackend::Fastembed,
266            model: DEFAULT_SEMANTIC_MODEL.to_string(),
267            base_url: None,
268            api_key_env: None,
269            // Background HTTP batches treat this as a per-request floor and
270            // scale it with measured per-item latency. Query requests have their
271            // own short deadline below.
272            timeout_ms: 25_000,
273            query_timeout_ms: DEFAULT_SEMANTIC_QUERY_TIMEOUT_MS,
274            query_instruction: default_semantic_query_instruction(),
275            max_batch_size: 64,
276            max_input_tokens: None,
277            max_files: 20_000,
278            subc_connection_file: None,
279            route_project_root: None,
280            route_harness: None,
281        }
282    }
283}
284
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286#[serde(default)]
287pub struct InspectConfig {
288    pub enabled: bool,
289    /// Deadline for the blocking LSP diagnostics phase of `aft_inspect`.
290    #[serde(default = "default_inspect_diagnostics_timeout_ms")]
291    pub diagnostics_timeout_ms: u64,
292    pub duplicates: InspectDuplicatesConfig,
293}
294
295#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(default)]
297pub struct InspectDuplicatesConfig {
298    pub expected_mirrors: Vec<[String; 2]>,
299}
300
301impl Default for InspectConfig {
302    fn default() -> Self {
303        Self {
304            enabled: true,
305            diagnostics_timeout_ms: default_inspect_diagnostics_timeout_ms(),
306            duplicates: InspectDuplicatesConfig::default(),
307        }
308    }
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(default)]
313pub struct BackupConfig {
314    pub enabled: Option<bool>,
315    pub max_depth: Option<usize>,
316    pub max_file_size: Option<u64>,
317}
318
319impl Default for BackupConfig {
320    fn default() -> Self {
321        Self {
322            enabled: Some(true),
323            max_depth: Some(crate::backup::DEFAULT_MAX_UNDO_DEPTH),
324            max_file_size: Some(crate::backup::DEFAULT_MAX_BACKUP_FILE_SIZE),
325        }
326    }
327}
328
329/// `gh` routing shim operator hard-off.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331#[serde(default)]
332pub struct GhShimConfig {
333    /// When false, the `gh` routing shim passes bytes through before any
334    /// daemon or catalog probe, so a disabled shim produces no subc traffic.
335    /// Default true. This is an operator hard-off for fleet rollout safety.
336    pub enabled: bool,
337    /// Optional deployed or development AFT image used by managed shim entries.
338    /// The running executable is used when this user-tier field is absent.
339    pub binary_path: Option<PathBuf>,
340}
341
342impl Default for GhShimConfig {
343    fn default() -> Self {
344        Self {
345            enabled: true,
346            binary_path: None,
347        }
348    }
349}
350
351/// GitHub integration gates resolved from the user-only `github` block.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(default)]
354pub struct GithubConfig {
355    /// Master switch. When false, every GitHub integration is disabled.
356    pub enabled: bool,
357    /// Whether AFT interposes the governed `gh` shim in agent child PATHs.
358    pub shim: bool,
359    /// Whether structured `issue://` and `pr://` reads are enabled.
360    pub read: bool,
361    /// Whether issue and pull-request comment writes are enabled.
362    pub write: bool,
363}
364
365impl Default for GithubConfig {
366    fn default() -> Self {
367        Self {
368            enabled: true,
369            shim: true,
370            read: false,
371            write: false,
372        }
373    }
374}
375
376/// Effective structured GitHub read gate retained for the read engine API.
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(default)]
379pub struct GhReadConfig {
380    pub enabled: bool,
381}
382
383impl Default for GhReadConfig {
384    fn default() -> Self {
385        Self { enabled: false }
386    }
387}
388
389/// Git behavior applied only to AFT-spawned agent children.
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391#[serde(default)]
392pub struct GitConfig {
393    /// `off` (default), `auto`, or one explicit `Name <email>` identity.
394    pub co_author: String,
395}
396
397impl Default for GitConfig {
398    fn default() -> Self {
399        Self {
400            co_author: "off".to_string(),
401        }
402    }
403}
404
405/// Normalize configuration values into `off`, `auto`, or `Name <email>`.
406pub fn normalize_git_co_author(value: &str) -> Option<String> {
407    let value = value.trim();
408    if matches!(value, "off" | "auto") {
409        return Some(value.to_string());
410    }
411    if value.contains(['\n', '\r']) || !value.ends_with('>') {
412        return None;
413    }
414    let open = value.rfind('<')?;
415    if open == 0 || !value.as_bytes()[open - 1].is_ascii_whitespace() {
416        return None;
417    }
418    let name = value[..open].trim();
419    let email = value[open + 1..value.len() - 1].trim();
420    if name.is_empty()
421        || name.contains(['<', '>'])
422        || email.is_empty()
423        || !email.contains('@')
424        || email
425            .chars()
426            .any(|character| character.is_whitespace() || matches!(character, '<' | '>'))
427    {
428        return None;
429    }
430    Some(value.to_string())
431}
432
433/// Content-addressed index view assembly.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
435#[serde(default)]
436pub struct ViewsConfig {
437    /// Enable manifest-backed index views. Default false.
438    pub enabled: bool,
439}
440
441/// Linked-worktree behavior that never writes shared on-disk artifacts.
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(default)]
444pub struct WorktreeConfig {
445    /// When true, a borrow-only (linked worktree) root applies its own
446    /// file-watcher events to the in-RAM trigram delta and invalidates the
447    /// symbol cache so search reflects local edits. Default false. Semantic
448    /// search and the callgraph stay frozen. Never persists to the shared
449    /// `cache.bin`.
450    pub ram_overlay: bool,
451}
452
453impl Default for WorktreeConfig {
454    fn default() -> Self {
455        Self { ram_overlay: false }
456    }
457}
458
459pub const DEFAULT_SEMANTIC_MODEL: &str = "all-MiniLM-L6-v2";
460
461impl Config {
462    pub fn semantic_backend_label(&self) -> &'static str {
463        self.semantic.backend.as_str()
464    }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
468#[serde(default)]
469pub struct SandboxConfig {
470    /// Route first-party bash commands through the native platform sandbox.
471    pub enabled: bool,
472    /// User-approved writable roots in addition to projects, task artifacts, and caches.
473    pub write_allow: Vec<PathBuf>,
474    /// Extra paths that native backends should deny reading.
475    pub read_deny: Vec<PathBuf>,
476}
477
478#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(default)]
480pub struct BashConfig {
481    /// Permit plugin-side break-glass execution when its AFT transport is unavailable.
482    /// Rust accepts this for cross-language config parity but never acts on it.
483    pub host_fallback: bool,
484    /// Whether the hosting plugin detaches wait:true bash calls on user messages.
485    /// Rust accepts this for cross-language config parity but never acts on it.
486    #[serde(default = "default_bash_detach_on_user_message")]
487    pub detach_on_user_message: bool,
488    /// Maximum synchronous `bash_watch` wait accepted by the hosting plugin.
489    /// Rust accepts this for cross-language config parity but never acts on it.
490    #[serde(default = "default_bash_watch_sync_max_ms")]
491    pub watch_sync_max_ms: u64,
492    /// Put Linux tool shells in transient user scopes when systemd is available.
493    pub linux_scope: bool,
494    /// Pi-only fallback gate for its optional PowerShell default tool. The Rust
495    /// executor accepts this solely to keep shared config parsing in parity.
496    pub powershell_tool: bool,
497}
498
499impl Default for BashConfig {
500    fn default() -> Self {
501        Self {
502            host_fallback: false,
503            detach_on_user_message: default_bash_detach_on_user_message(),
504            watch_sync_max_ms: default_bash_watch_sync_max_ms(),
505            linux_scope: false,
506            powershell_tool: false,
507        }
508    }
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512#[serde(default)]
513pub struct Config {
514    /// Root directory of the project being analyzed. `None` if not scoped.
515    pub project_root: Option<PathBuf>,
516    /// How many levels of call-graph edges to follow during validation (default: 1).
517    pub validation_depth: u32,
518    /// Hours before legacy backup-session maintenance may collect inactive history
519    /// (default: 24). Named checkpoint retention is intentionally fixed at fourteen
520    /// days and is not controlled by configuration.
521    pub checkpoint_ttl_hours: u32,
522    /// Maximum depth for recursive symbol resolution (default: 10).
523    pub max_symbol_depth: u32,
524    /// Seconds before killing a formatter subprocess (default: 10).
525    pub formatter_timeout_secs: u32,
526    /// Seconds before killing a type-checker subprocess (default: 30).
527    pub type_checker_timeout_secs: u32,
528    /// Whether to auto-format files after edits (default: false).
529    pub format_on_edit: bool,
530    /// Whether the hashline edit/read surface is enabled for eligible sessions.
531    /// Resolved from the public `edit_mode` enum in aft.jsonc.
532    pub hashline_enabled: bool,
533    /// Whether to auto-validate files after edits (default: false).
534    /// When "syntax", only tree-sitter parse check. When "full", runs type checker.
535    pub validate_on_edit: Option<String>,
536    /// Per-language formatter overrides. Keys: "typescript", "python", "rust", "go".
537    /// Values: "biome", "oxfmt", "prettier", "deno", "ruff", "black", "rustfmt", "goimports", "gofmt", "none".
538    pub formatter: HashMap<String, String>,
539    /// Per-language type checker overrides. Keys: "typescript", "python", "rust", "go".
540    /// Values: "tsc", "tsgo", "biome", "pyright", "ruff", "cargo", "go", "staticcheck", "none".
541    pub checker: HashMap<String, String>,
542    /// Whether to restrict file operations to within `project_root` (default: false).
543    /// When true, write-capable commands reject paths outside the project root.
544    pub restrict_to_project_root: bool,
545    /// Enable the trigram search index (default: false).
546    pub search_index: bool,
547    /// User-tier standing roots. Empty by default, so normal session indexing is unchanged.
548    pub index: IndexConfig,
549    /// Enable semantic search (default: false).
550    pub semantic_search: bool,
551    /// Content-addressed index view assembly. Disabled by default.
552    pub views: ViewsConfig,
553    /// Whether the plugin registered the `aft_search` tool for this surface
554    /// (default: false). Forwarded by the plugin's resolved registration
555    /// predicate (semantic on + not minimal + not disabled). Used only to pick
556    /// the grep-rewrite footer: when true the footer steers to `aft_search`,
557    /// otherwise to the `grep` tool. Not a capability gate.
558    pub aft_search_registered: bool,
559    /// Enable the persisted callgraph store substrate (default: true).
560    pub callgraph_store: bool,
561    /// Number of files to parse in a single batch during callgraph store cold build (default: 100).
562    /// Lower values reduce peak memory during cold build.
563    /// Set to 0 to disable chunking and parse all files at once.
564    pub callgraph_chunk_size: usize,
565    /// Enable experimental bash command rewriting (default: false).
566    pub experimental_bash_rewrite: bool,
567    /// Enable experimental bash command compression (default: false).
568    pub experimental_bash_compress: bool,
569    /// Enable experimental bash background execution (default: false).
570    pub experimental_bash_background: bool,
571    /// Maximum number of background bash tasks allowed to run concurrently (default: 8).
572    pub max_background_bash_tasks: usize,
573    /// Emit reminders for long-running bash tasks (default: true).
574    pub bash_long_running_reminder_enabled: bool,
575    /// Milliseconds between long-running bash reminders (default: 10 minutes).
576    pub bash_long_running_reminder_interval_ms: u64,
577    /// Milliseconds to wait before a foreground bash task is promoted to background handling.
578    #[serde(skip, default = "default_foreground_wait_window_ms")]
579    pub foreground_wait_window_ms: u64,
580    /// Plugin-owned bash settings accepted by configure but inert in the engine.
581    pub bash: BashConfig,
582    /// Enable OpenCode-style bash permission prompts (default: false).
583    pub bash_permissions: bool,
584    /// Native sandbox policy for first-party bash and PTY processes.
585    pub sandbox: SandboxConfig,
586    /// Maximum file size to fully index in bytes (default: 1MB).
587    pub search_index_max_file_size: u64,
588    pub semantic: SemanticBackendConfig,
589    pub inspect: InspectConfig,
590    pub backup: BackupConfig,
591    /// Linked-worktree RAM overlay. Default off; see [`WorktreeConfig`].
592    pub worktree: WorktreeConfig,
593    /// Resolved GitHub integration gates. User configuration only.
594    pub github: GithubConfig,
595    /// Effective `gh` shim gate plus its legacy binary override.
596    pub gh_shim: GhShimConfig,
597    /// Effective structured GitHub read gate retained for the read engine API.
598    pub gh_read: GhReadConfig,
599    /// Git attribution for AFT-spawned agent children. Default off.
600    pub git: GitConfig,
601    /// Enable Astral ty as an experimental Python LSP server (default: false).
602    pub experimental_lsp_ty: bool,
603    /// User-defined LSP servers registered by the OpenCode plugin.
604    pub lsp_servers: Vec<UserServerDef>,
605    /// Lowercase LSP server IDs disabled by user config.
606    pub disabled_lsp: HashSet<String>,
607    /// Whether the system should request inline diagnostics after a tool call edits or writes a file.
608    #[serde(skip)]
609    pub diagnostics_on_edit: bool,
610    /// Extra directories to search when resolving LSP binaries.
611    /// The plugin populates these from its own auto-install cache (e.g.
612    /// `~/.cache/aft/lsp-packages/<pkg>/node_modules/.bin/`) so an LSP binary
613    /// installed by AFT is discoverable without needing it on PATH.
614    /// Resolution order: `<project_root>/node_modules/.bin/<bin>` →
615    /// `lsp_paths_extra/<bin>` (in order) → PATH via `which`. Python-family
616    /// servers additionally probe the selected workspace's `.venv`/`venv` first.
617    pub lsp_paths_extra: Vec<PathBuf>,
618    /// Binary names the hosting plugin knows how to auto-install.
619    ///
620    /// Built-in LSPs discovered from files only emit missing-binary warnings
621    /// when their binary is in this set. User-configured `lsp_servers` keep
622    /// warning unconditionally.
623    pub lsp_auto_install_binaries: HashSet<String>,
624    /// Binary names with plugin-managed auto-installs currently in flight.
625    ///
626    /// Missing-binary warnings are suppressed while the install is actively
627    /// running; install failure reporting is handled by the plugin after the
628    /// background work settles.
629    pub lsp_inflight_installs: HashSet<String>,
630    /// Persistent storage directory for indexes (trigram, semantic).
631    /// Set by the plugin to the XDG-compliant path (e.g. ~/.local/share/opencode/storage/plugin/aft/).
632    /// Falls back to ~/.cache/aft/ if not set.
633    pub storage_dir: Option<PathBuf>,
634    /// Allow URL-fetch commands to access private network hosts.
635    /// Default false; hosting plugins only forward this from user-level config.
636    pub url_fetch_allow_private: bool,
637    /// Resolved host-tool registration preference. The Rust core retains this
638    /// value for cross-harness config parity; the hosting plugin owns registration.
639    pub hoist_builtin_tools: bool,
640    /// Resolved tool-surface tier ("minimal", "recommended", or "all"). The
641    /// hosting plugin owns registration; the core keeps the value so it can
642    /// reach the same conclusion about which built-in slots survive.
643    pub tool_surface: String,
644    /// Agent-visible tool names the user switched off. Kept for the same reason
645    /// as `tool_surface`: slot-survival questions must be answered identically
646    /// on both sides of the plugin boundary.
647    pub disabled_tools: Vec<String>,
648    /// Hosting harness identity supplied by configure.
649    #[serde(default)]
650    pub harness: Option<Harness>,
651    /// Maximum number of (server, file) entries kept in the in-memory
652    /// diagnostic cache. Older entries are evicted in LRU order when the
653    /// cap is exceeded. Set to 0 to disable the cap entirely.
654    /// Default: 5000 (covers very large monorepos with bounded memory).
655    pub diagnostic_cache_size: usize,
656    /// Idle reclamation windows for unbound-root artifacts and language servers.
657    pub idle: IdleConfig,
658}
659
660impl Default for Config {
661    fn default() -> Self {
662        Config {
663            project_root: None,
664            validation_depth: 1,
665            checkpoint_ttl_hours: 24,
666            max_symbol_depth: 10,
667            formatter_timeout_secs: 10,
668            type_checker_timeout_secs: 30,
669            // Default OFF: formatting after an edit can silently reflow the file
670            // under the agent (a formatter splitting/joining lines), staling the
671            // context for the next edit/patch. Agents that want formatting opt in
672            // via `format_on_edit: true`.
673            format_on_edit: false,
674            hashline_enabled: false,
675            validate_on_edit: None,
676            formatter: HashMap::new(),
677            checker: HashMap::new(),
678            // Default to false to match OpenCode's existing permission-based model.
679            // The plugin opts into root restriction explicitly when desired.
680            restrict_to_project_root: false,
681            search_index: false,
682            index: IndexConfig::default(),
683            semantic_search: false,
684            views: ViewsConfig::default(),
685            aft_search_registered: false,
686            callgraph_store: true,
687            callgraph_chunk_size: 100,
688            experimental_bash_rewrite: false,
689            experimental_bash_compress: false,
690            experimental_bash_background: false,
691            max_background_bash_tasks: 8,
692            bash_long_running_reminder_enabled: true,
693            bash_long_running_reminder_interval_ms: 600_000,
694            foreground_wait_window_ms: default_foreground_wait_window_ms(),
695            bash: BashConfig::default(),
696            bash_permissions: false,
697            sandbox: SandboxConfig::default(),
698            search_index_max_file_size: 1_048_576,
699            semantic: SemanticBackendConfig::default(),
700            inspect: InspectConfig::default(),
701            backup: BackupConfig::default(),
702            worktree: WorktreeConfig::default(),
703            github: GithubConfig::default(),
704            gh_shim: GhShimConfig::default(),
705            gh_read: GhReadConfig::default(),
706            git: GitConfig::default(),
707            experimental_lsp_ty: false,
708            lsp_servers: Vec::new(),
709            disabled_lsp: HashSet::new(),
710            diagnostics_on_edit: false,
711            lsp_paths_extra: Vec::new(),
712            lsp_auto_install_binaries: HashSet::new(),
713            lsp_inflight_installs: HashSet::new(),
714            storage_dir: None,
715            url_fetch_allow_private: false,
716            hoist_builtin_tools: true,
717            tool_surface: "recommended".to_string(),
718            disabled_tools: Vec::new(),
719            harness: None,
720            diagnostic_cache_size: 5000,
721            idle: IdleConfig::default(),
722        }
723    }
724}
725
726impl Config {
727    /// Whether the host's tagged `read` slot survives surface, hoisting, and
728    /// disable filters.
729    ///
730    /// Only a tagged read mints the `[path#TAG]` snapshots a hashline patch can
731    /// address, so a session that lost the read slot must not be offered the
732    /// hashline edit arm. The predicate is deliberately spelled the same way as
733    /// the plugins' registration check so both sides of the boundary classify a
734    /// given config identically.
735    pub fn read_slot_survives(&self) -> bool {
736        self.tool_surface != "minimal"
737            && self.hoist_builtin_tools
738            && !self.disabled_tools.iter().any(|name| name == "read")
739    }
740}
741
742fn default_foreground_wait_window_ms() -> u64 {
743    15_000
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn read_slot_survival_matches_the_plugin_registration_rule() {
752        let base = Config::default();
753        assert!(base.read_slot_survives());
754
755        let disabled = Config {
756            disabled_tools: vec!["read".to_string()],
757            ..Config::default()
758        };
759        assert!(!disabled.read_slot_survives());
760
761        // An unrelated disabled tool leaves the read slot alone.
762        let unrelated = Config {
763            disabled_tools: vec!["aft_zoom".to_string()],
764            ..Config::default()
765        };
766        assert!(unrelated.read_slot_survives());
767
768        let minimal = Config {
769            tool_surface: "minimal".to_string(),
770            ..Config::default()
771        };
772        assert!(!minimal.read_slot_survives());
773
774        let unhoisted = Config {
775            hoist_builtin_tools: false,
776            ..Config::default()
777        };
778        assert!(!unhoisted.read_slot_survives());
779    }
780
781    #[test]
782    fn semantic_query_instruction_resolves_by_backend_model_and_override() {
783        let mut config = SemanticBackendConfig::default();
784        config.model = "QWEN/Qwen3-Embedding-0.6B".to_string();
785        config.query_instruction = "auto".to_string();
786        assert_eq!(config.resolved_query_instruction(), None);
787
788        config.backend = SemanticBackend::OpenAiCompatible;
789        assert_eq!(
790            config.resolved_query_instruction(),
791            Some(QWEN3_EMBEDDING_MODEL_CARD_RETRIEVAL_TASK)
792        );
793
794        config.model = "text-embedding-3-small".to_string();
795        assert_eq!(config.resolved_query_instruction(), None);
796
797        config.query_instruction = "custom code retrieval task".to_string();
798        assert_eq!(
799            config.resolved_query_instruction(),
800            Some("custom code retrieval task")
801        );
802
803        config.query_instruction = "off".to_string();
804        assert_eq!(config.resolved_query_instruction(), None);
805    }
806
807    #[test]
808    fn bash_watch_sync_max_defaults_to_two_minutes_when_deserialized() {
809        let parsed: BashConfig = serde_json::from_str("{}").unwrap();
810        assert_eq!(parsed.watch_sync_max_ms, DEFAULT_BASH_WATCH_SYNC_MAX_MS);
811        assert_eq!(BashConfig::default().watch_sync_max_ms, 120_000);
812    }
813
814    #[test]
815    fn index_root_path_expands_tilde_before_absolute_validation() {
816        let home = std::env::temp_dir().join("aft-home");
817        assert_eq!(
818            expand_index_root_path("~/workspace", Some(&home)).unwrap(),
819            home.join("workspace")
820        );
821        assert!(expand_index_root_path("relative/root", Some(&home)).is_err());
822        assert!(expand_index_root_path("~", None).is_err());
823    }
824}