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